trips/mock/app.js
Greg Pomerantz 6457501520 Unified leg system: every movement is a first-class leg + fix 4 in-the-sea coordinates
Legs:
- day.firstLeg (hotel -> first stop) is now a first-class leg object exactly
  like retLeg/transferIn: own mode/dur/geometry, same leg-row rendering, same
  click-to-edit L3 card (drag waypoint + walk/taxi toggle), same router +
  sanity guards, same strip/budget/context accounting
- hotel rendered as a timeline NODE on every day: 'arrive HH:MM - drop bags'
  on the arrival day (after the airport->hotel leg row), 'depart HH:MM'
  (startMin minus the out-leg) on ordinary days; the arrival drop-off is a
  stand-alone leg row instead of being embedded in the flight card
- arrival day: the first stop starts at hotel-arrival + out-leg; ordinary
  days keep startMin = time AT the first stop (the out-leg happens before)
- out-leg suppressed when the day doesn't start at the base hotel: travel
  days (city change, first stop > 25km away) and breakfast-at-hotel days
  (< 120m); stale firstLeg objects self-heal
- set_start tool shifts arrLandingMin on arrival days so the whole chain
  (hotel arrival, first stop, ...) follows the new landing time

Coordinates (verified on land via Nominatim + tile coastline scan):
- S14 Castillo San Diego -> Bodeguita promontory [10.4205,-75.5517]
- S16 Malecon de Bocagrande -> seawall [10.43545,-75.539]
- S18 Punta Canoa -> sandbar beach [10.4403,-75.530]
- S19 beachside lunch -> same sandbar [10.4402,-75.5294]
healCoordinates syncs saved plans from data.js on load
2026-09-11 00:46:08 -04:00

3350 lines
182 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* Interaction demo (redesigned). All data is fake (data.js).
*
* Design pillars (see recommendations):
* 1. ONE unified canvas — no modal "modes". Discovery is a drawer, not a
* mode-switch: the plan rail and map stay visible while browsing.
* 2. Smoother, proactive discovery — drawer chips + a "you might like"
* suggestion block in the rail, not just chat prompts.
* 3. Frictionless replacement — every stop card expands to inline
* alternatives with one-click swap, and any candidate can be dragged
* straight onto the plan (drop on a same-slot stop = swap).
* 4. Multi-scale comparison — a persistent bottom "compare dock" lines up
* any 2–4 candidates/stops side by side (hotel scale and stop scale).
* 5. Lower cognitive load — states read as "in plan / idea / backup",
* strict validators are rephrased as gentle nudges with one-tap fixes.
*/
let M = window.MOCK; // active trip dataset (reassigned on trip switch)
const $ = s => document.querySelector(s);
const el = (tag, cls, html) => { const e = document.createElement(tag); if (cls) e.className = cls; if (html != null) e.innerHTML = html; return e; };
const fmt = m => String(Math.floor(m / 60)).padStart(2, '0') + ':' + String(Math.round(m % 60)).padStart(2, '0');
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)));
// how the traveler gets between stops: on foot, or by taxi. Affects every
// on-ground leg's estimate + routing profile (the router is asked for the
// matching OSRM profile; out of coverage → straight-line estimate at the
// mode's average speed). Airport↔hotel transfers are always by car.
let travelMode = 'foot'; // 'foot' | 'car' — loaded per-app in enterTrip
let thinkOn = true; // LLM reasoning: on = deliberate thinking (better planning, slower) · off = no_think (quick, faster)
try { thinkOn = (localStorage.getItem('trips.think') !== 'off'); } catch {}
const legSpeedKmh = m => (m === 'car' ? 26 : WALK_KMH); // urban taxi incl. traffic/parking
const estLegMin = (m, a, b) => Math.max(1, Math.round(haversine(a, b) / 1000 / (legSpeedKmh(m) / 60)));
// The OSRM extract only covers the trip area. A point outside the coverage — or a route
// through a coverage hole — comes back wildly longer than the straight line (a 5 km
// airport drop-off once “took 9 hours by sea”). Such a “route” is worse than the estimate,
// so treat it as no route.
function saneRoute(res, pts, mode) {
if (!res) return null;
let km = 0;
for (let i = 1; i < pts.length; i++) km += haversine(pts[i - 1], pts[i]) / 1000;
const est = Math.max(4, km / legSpeedKmh(mode || 'foot') * 60);
if (res.durMin > Math.max(150, est * 6)) return null;
return res;
}
// ---------------- 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;
// each trip declares which local OSRM extract covers it (server.js maps
// the key to an upstream; default is the NE-US car extract)
const routerKey = () => (M && M.router) || 'northeast';
async function checkRouter() {
try { const r = await fetch(`/router-status?router=${routerKey()}`); routerOn = !!(await r.json()).router; }
catch { routerOn = false; }
}
// ---------------- LLM client (local llama.cpp via the /llm-chat proxy) -----
// Free-form chat answers come from the model; deterministic UI intents
// (drawer discovery, time nudges, swaps) stay local — the LLM proposes,
// the user's UI commits. Falls back to canned replies when the server is
// down or the model isn't loaded.
let llmOn = false, llmState = 'unknown'; // 'loaded' | 'unloaded' | 'unreachable' | 'unknown' (from /llm-status)
let tripActive = false; // true once the user has entered a trip (startApp → enterTrip)
async function checkLLM() {
try { const r = await fetch('/llm-status'); const j = await r.json(); llmState = j.status || 'unknown'; llmOn = (llmState === 'loaded'); }
catch { llmOn = false; llmState = 'unreachable'; }
}
// Re-probe model state every 20s so the guard + badge stay accurate without a
// page reload: after a llama-server restart the model is 'unloaded' (router
// still up), and once a request auto-loads it we want to notice it flipped to
// 'loaded'.
setInterval(async () => {
await checkLLM();
try { const b = $('#offline-badge'); if (b && offlineState !== 'ready') b.textContent = badgeText(); } catch {}
// Proactively LOAD a cold model. After a llama-server restart (or if the
// router evicted it on idle) the model is 'unloaded' but the router is up —
// the old code did nothing here, so the user's first message paid the full
// cold load. Loading it in the background means the next turn lands warm.
if (llmState === 'unloaded' && !scriptBusy && tripActive)
ensureLLM().then(ok => { if (ok) warmLLM(); }); // once loaded, warm the KV prefix too
}, 20000);
// Wake the model when the router is up but the model isn't loaded. The
// llama.cpp router auto-loads a registered model on the first inference
// request (models_autoload=true), so a tiny request triggers the load. The
// old code never sent a request in this state, so the model could not be
// loaded by the app at all — the user just saw "offline". Resolves true once
// the model reports 'loaded'.
let loadingPromise = null; // in-flight model load — every caller joins it, none duplicate
async function ensureLLM() {
if (llmOn) return true;
if (loadingPromise) return loadingPromise; // a load is in flight — wait for it to finish
if (llmState !== 'unloaded') { await checkLLM(); return llmOn; }
loadingPromise = (async () => {
try {
await fetch('/llm-chat', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: [{ role: 'user', content: 'ping' }], max_tokens: 1, temperature: 0, reasoning_effort: 'none' }),
signal: AbortSignal.timeout(240000), // a cold 27B load can take a couple of minutes
});
} catch {}
await checkLLM();
return llmOn;
})();
try { return await loadingPromise; }
finally { loadingPromise = null; }
}
// Warm-up / keep-alive: the local model's FIRST call after idle pays a full
// cold prompt-process (measured ~7–10× slower than warm — a 238s first turn vs
// ~38s for the next one). A max_tokens=1 probe carrying the live system prompt
// keeps the GPU + KV prefix warm, so the user's first real turn lands at warm
// speed instead of the cold outlier. Re-warmed every 2 min while the tab is open.
let warming = false;
async function warmLLM() {
if (!llmOn || warming) return;
warming = true;
try {
const sys = (M && typeof llmSystemPrompt === 'function') ? llmSystemPrompt() : 'You are a trip-planning assistant. Ready when you are.';
await fetch('/llm-chat', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: [{ role: 'system', content: sys }, { role: 'user', content: 'ready' }], max_tokens: 1, temperature: 0, reasoning_effort: 'none' }) });
} catch {}
warming = false;
}
setInterval(() => { if (llmOn && !scriptBusy) warmLLM(); }, 120000); // keep the model warm while the tab is open
// ---------------- web search client (SearXNG via the /search proxy) ------
// Live facts (opening hours, fees, seasonal notes) with provenance URLs —
// the third leg of the 3-source blend. Enrichment APPENDS to suggestion
// cards; it never reorders, removes, or rewrites the plan (DESIGN.md
// "streaming enrichment").
let webOn = false;
async function checkWeb() {
try { const r = await fetch('/search-status'); webOn = !!(await r.json()).web; }
catch { webOn = false; }
}
// ---------------- spatial client (PostGIS spatiald via /spatial proxy) --
let spatialOn = false, spatialPoi = 0;
async function checkSpatial() {
try { const r = await fetch('/spatial-status'); const j = await r.json(); spatialOn = !!j.spatial; spatialPoi = j.poi_count || 0; }
catch { spatialOn = false; }
}
const badgeText = () => '📶 online' + (routerOn ? ' · router' : '') + (llmOn ? ' · llm' : '') + (webOn ? ' · web' : '') + (spatialOn ? ' · spatial' : '');
const WEB_TTL = 60 * 60 * 1000; // a cached live check is good for an hour
const tripCity = () => (M?.trip?.places?.[0]?.name) || (M?.trip?.title || '').split(' ')[0] || '';
const webQuery = (c, cat) => {
const tail = cat === 'hotel' ? 'price per night reviews'
: ['lunch', 'dinner', 'breakfast', 'snack'].includes(cat) ? 'menu prices opening hours'
: 'opening hours entry fee';
return `"${c.name}" ${tripCity()} ${tail}`;
};
// Suggestion surfaces (discovery cards, "you might like" rows) embed a web
// element via webEl(c, cat); it is re-rendered on every refresh, so results
// are kept on the candidate (c.web) and every live node for that place is
// refreshed via the data-webfor attribute.
const webBusy = new Set();
function webEl(c, cat) {
const n = el('div', 'cc-web');
n.dataset.webfor = c.id;
n.dataset.webcat = cat || '';
refreshWebEl(n, c);
return n;
}
function refreshWebEl(n, c) {
if (!n || !n.isConnected) return;
if (c.web) {
n.classList.remove('busy');
if (c.web.failed) { n.innerHTML = '🌐 live check unavailable (retry in a few minutes)'; return; }
const rs = (c.web.results || []).filter(r => r.url);
if (!rs.length) { n.innerHTML = '🌐 no live results'; return; }
n.innerHTML = rs.slice(0, 3).map(r => {
const host = (() => { try { return new URL(r.url).hostname.replace(/^www\./, ''); } catch { return r.source || 'web'; } })();
const snip = (r.snippet || r.title || '').replace(/Missing:.*$/i, '').trim();
return `<span class="web-src"><a href="${esc(r.url)}" target="_blank" rel="noopener">${esc(host)}</a></span>` +
`<span class="web-snip">${esc(snip.slice(0, 150))}${snip.length > 150 ? '…' : ''}</span>`;
}).join('<br>');
} else if (webBusy.has(c.id)) {
n.classList.add('busy');
n.innerHTML = '🌐 checking <b>' + esc(c.name) + '</b>…';
} else n.innerHTML = '';
}
// fire-and-forget: at most one in-flight query per place, cached for WEB_TTL
async function enrichWeb(c, cat) {
if (!webOn || !c) return;
if (c.web && Date.now() - c.web.at < WEB_TTL) return;
if (webBusy.has(c.id)) return;
webBusy.add(c.id);
document.querySelectorAll(`[data-webfor="${c.id}"]`).forEach(n => refreshWebEl(n, c));
try {
const r = await fetch('/search?q=' + encodeURIComponent(webQuery(c, cat)) + '&count=3');
const j = await r.json();
if (!j.ok) throw new Error(j.error || 'search failed');
c.web = { q: webQuery(c, cat), at: Date.now(), results: j.results || [] };
} catch {
// remember the failure so live nodes can show it, but age it out fast
// (retry after ~5 min, not after the full TTL)
c.web = { q: webQuery(c, cat), at: Date.now() - (WEB_TTL - 5 * 60 * 1000), results: [], failed: true };
} finally {
webBusy.delete(c.id);
document.querySelectorAll(`[data-webfor="${c.id}"]`).forEach(n => refreshWebEl(n, c));
}
}
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
// Compact itinerary snapshot (the design doc's LLM context form): stops with
// times + stays + bookings. No geometry, no tool output. layout() fills
// s.start in place, so it is safe to call at any time.
function llmContext() {
const L = [`Trip: ${M.trip.title}`];
for (const b of M.trip.bookings)
L.push(`Booking (${b.type}): ${b.name}${b.ref ? ' — ref ' + b.ref : ''}${b.dates ? ', ' + b.dates : ''}${b.from ? `, ${b.from} → ${b.to}, ${b.date} dep ${b.depart}` : ''}`);
for (const st of stays)
L.push(`Stay: ${st.name} (${st.place || ''}) — ${st.checkIn} → ${st.checkOut}${st.price ? ', ' + st.price + ' €/night' : ''}`);
for (const d of M.days) {
const dd = days[d.id];
layout(dd);
const stops = dd.stops.filter(s => s.state === 'planned').map(
s => `${fmt(s.start)} ${s.name} (${s.dur} min${s.price ? ', ' + s.price.amount + s.price.cur : ''})`);
const arrInfo = (dd.isArrival && dd.transferIn && dd.arrLandingMin != null)
? ` — lands ${fmt(dd.arrLandingMin)}, ${dd.transferIn.mode || 'car'} to hotel ${dd.transferIn.dur} min, first stop at ${fmt((dd.stops[0] || { start: dd.startMin }).start)}`
: '';
L.push(`${dd.label} [${dd.id} · ${dd.date}]: ${stops.length ? stops.join(' · ') : '(empty)'}${arrInfo} — day ends ~${fmt(layout(dd))}`);
}
return L.join('\n');
}
// ---------------- LLM agent: tools + calling loop ----------------
// The model is the orchestrator: it reads the plan, calls deterministic
// tools (search / route / review), and proposes small fix-ops (add / remove /
// move / re-time). The tools decide — they validate, compute, and commit, so
// every edit is undo-able. This llama.cpp build does not honor the native
// OpenAI `tools` field, so tool calls use a text protocol: the model emits
// <tool>{"name":...,"args":{...}}</tool>, we execute it, and feed the JSON
// result back as the next turn; the loop ends when the model stops calling.
const LLM_TOOL_MAX = 10; // max tool-call turns per user message
// Live-state lookups (the source of truth is `days`, not the original M.days)
function liveStopById(id) {
for (const d of Object.values(days)) { const s = d.stops.find(x => x.id === id); if (s) return s; }
return null;
}
function candById(id) {
for (const cat of Object.keys(M.candidates)) {
const c = M.candidates[cat].find(x => x.id === id);
if (c) return { ...c, cat };
}
return null;
}
// Resolve a model-given place ref — "hotel", a stop/candidate id, or a name
// (exact then substring) — to {name, at, id}
function resolvePlace(ref) {
const q = String(ref || '').trim(); const ql = q.toLowerCase();
if (!q) return null;
if (ql === 'hotel' || ql === 'base' || ql === 'the hotel' || ql === 'my hotel') {
const b = dayBase(); return { name: b.name, at: b.at, id: b.id || 'hotel' };
}
const s = liveStopById(q); if (s) return { name: s.name, at: s.at, id: s.id };
const c = candById(q); if (c) return { name: c.name, at: c.at, id: c.id };
const all = [];
Object.values(days).forEach(d => d.stops.forEach(x => all.push(x)));
Object.values(M.candidates).forEach(pool => pool.forEach(x => all.push(x)));
const hit = all.find(x => x.name.toLowerCase() === ql) || all.find(x => x.name.toLowerCase().includes(ql));
return hit ? { name: hit.name, at: hit.at, id: hit.id } : null;
}
// Resolve a live stop by id or name (the model often names stops, not ids)
function resolveStop(ref) {
const s = liveStopById(ref); if (s) return s;
const q = String(ref || '').trim().toLowerCase();
if (!q) return null;
for (const d of Object.values(days)) {
const hit = d.stops.find(x => x.name.toLowerCase() === q) || d.stops.find(x => x.name.toLowerCase().includes(q));
if (hit) return hit;
}
return null;
}
// ---- flight-anchor reconfiguration: the user drops a real ticket and the
// plan re-anchors around the booked arrival/departure (times + locations) ----
const AIRPORTS = {
bog: [4.7016, -74.1469], ctg: [10.4425, -75.5131], smr: [11.1165, -74.2330],
mtr: [8.7443, -75.8744], clo: [1.2804, -77.1733], elc: [6.8731, -71.6474],
mde: [6.2447, -75.5801], baq: [3.5757, -76.5716], cuc: [5.6958, -75.6504],
mia: [25.7959, -80.2870], jfk: [40.6413, -73.7781], lax: [33.9416, -118.4085],
sfo: [37.6213, -122.3790], yyz: [43.6771, -79.6240], yvr: [49.1939, -123.1833],
lhr: [51.4700, -0.4543], cdg: [49.0097, 2.5479], ams: [52.3105, 4.7683],
fra: [50.0379, 8.5622], mad: [40.4983, -3.5676], bcn: [41.2974, 2.0833],
zrh: [47.4647, 8.5492], mex: [19.4363, -99.0721], lim: [-12.0219, -77.1143],
};
const ARR_DAY_END = 21 * 60 + 30; // an arrival day should wrap by ~21:30
const DOWS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
function parseHM(t) {
const m = /^(\d{1,2}):(\d{2})$/.exec(String(t || '').trim());
if (!m) return null;
const h = +m[1], mn = +m[2];
return (h > 23 || mn > 59) ? null : h * 60 + mn;
}
const CITY_AIRPORT = {
'santa marta': 'smr', cartagena: 'ctg', marta: 'smr', bogota: 'bog', 'bogotá': 'bog',
barranquilla: 'baq', medellin: 'mde', 'medellín': 'mde', 'santa fe': 'mde',
'el banquete': 'elc', palmira: 'cvc', cali: 'cvc', toronto: 'yyz',
'new york': 'jfk', london: 'lhr', paris: 'cdg', amsterdam: 'ams', frankfurt: 'fra',
madrid: 'mad', barcelona: 'bcn', zurich: 'zrh', 'zürich': 'zrh', mexico: 'mex', lima: 'lim',
};
function airportOf(ref) {
if (!ref) return null;
const s = String(ref).trim(), up = s.toUpperCase();
const code = (up.match(/\b([A-Z]{3})\b/) || [])[1];
let key = code ? code.toLowerCase() : null;
if (!key) { const cl = s.toLowerCase(); for (const [city, c] of Object.entries(CITY_AIRPORT)) if (cl.includes(city)) { key = c; break; } }
const hit = key && AIRPORTS[key];
return hit ? { code: code || key.toUpperCase(), at: hit } : null;
}
function driveMin(ap, hotelAt) { // rough airport<->hotel taxi time
if (!ap || !hotelAt) return null;
const km = haversine(ap.at, hotelAt) / 1000;
return Math.min(90, Math.max(20, Math.round(km / 35 * 60)));
}
// An arrival day's activities can't start until the taxi from the airport has
// dropped the traveller at the hotel, so its start tracks (landing + transfer).
// The transfer is routed asynchronously and can change (routing resolves, or the
// walk<->taxi toggle), so re-sync whenever its duration changes — this keeps the
// day tab, rail start and every stop time agreeing (previously the day started
// ~20 min after landing while the drop-off took an hour, so "lunch" was scheduled
// before the traveller had even reached the hotel).
function syncArrivalStart(d) {
if (!d || !d.isArrival || d.arrLandingMin == null || !d.transferIn) return;
d.startMin = d.arrLandingMin + d.transferIn.dur; // time at the HOTEL (bag drop)
const eff = d.startMin + firstLegDur(d); // + hotel → first stop = time at the first stop
const arrEnd = Math.min(ARR_DAY_END, eff + (d.wakingHours || 12) * 60);
d.wakingHours = Math.max(2, Math.round((arrEnd - eff) / 60));
}
function relabelDay(d, idx, dateStr) {
const [y, mo, da] = String(dateStr).split('-').map(Number);
const dt = new Date(Date.UTC(y, mo - 1, da));
d.date = dateStr;
d.label = 'Day ' + idx + ' · ' + DOWS[dt.getUTCDay()] + ' ' + da;
}
const dayList = () => Object.keys(days).sort().map(k => days[k]);
const ptEq = (a, b) => !!a && !!b && Math.abs(a[0] - b[0]) < 1e-6 && Math.abs(a[1] - b[1]) < 1e-6;
// keep hotel stays, city place-dates and hotel booking records in sync after
// a re-date (e.g. flight re-anchoring) — otherwise stays no longer "cover"
// the new day dates and the base-hotel logic silently falls back
function redateStaysAndPlaces() {
const all = dayList();
const segDates = seg => [seg.reduce((m, d) => d.date < m ? d.date : m, seg[0].date),
seg.reduce((m, d) => d.date > m ? d.date : m, seg[0].date)];
stays.forEach(st => {
const seg = all.filter(d => (d.base && d.base.at && ptEq(d.base.at, st.at)) || covers(st, d.date));
if (!seg.length) return;
const [a, b] = segDates(seg);
st.checkIn = a; st.checkOut = b;
});
(M.trip.places || []).forEach(pl => {
const st = stays.find(s => (s.place || '').toLowerCase().includes(pl.name.toLowerCase())
|| pl.name.toLowerCase().includes((s.place || '').toLowerCase()));
const seg = st ? all.filter(d => covers(st, d.date)) : [];
if (!seg.length) return;
if (pl.dates) pl.dates = segDates(seg);
});
(M.trip.bookings || []).forEach(b => {
if (b.type !== 'hotel' || !b.dates) return;
const st = stays.find(s => s.name === b.name);
if (st) b.dates = stayRange(st);
});
}
// re-derive the arrival/departure anchors for plans saved before the day
// objects carried them: the flights are in the bookings, so the airport card +
// ground transfer can be rebuilt on load (idempotent — fresh plans are a no-op)
function healTravelAnchors() {
const flights = (M.trip.bookings || []).filter(b => b.type === 'flight');
const ids = Object.keys(days).sort();
if (!ids.length || !flights.length) return;
const first = days[ids[0]], last = days[ids[ids.length - 1]];
const hotelFirst = (first.base && first.base.at) || (stays[0] && stays[0].at);
const hotelLast = (last.base && last.base.at) || (stays[stays.length - 1] && stays[stays.length - 1].at);
const codeOf = s => (String(s || '').match(/\b([A-Z]{3})\b/) || [])[1] || '';
const arr = flights.find(b => b.ref === 'arr-booking') || flights.find(b => b.stations && !b.stations.from && b.stations.to);
const dep = flights.find(b => b.ref === 'dep-booking') || flights.find(b => b.stations && b.stations.from && !b.stations.to);
if (arr && !first.arrivalFlight) {
const ap = arr.stations && arr.stations.to;
first.isArrival = true;
first.arrivalFlight = { code: codeOf(arr.to) || codeOf(arr.name) || arr.to || 'airport', at: ap || null, time: arr.arrive || '', flight: String(arr.name || '').split(' · ')[0], from: arr.from || '', to: arr.to || '' };
first.transferIn = { at: ap || null, hotelAt: hotelFirst, dur: (ap && hotelFirst) ? (driveMin({ at: ap }, hotelFirst) || 60) : 60, mode: 'car', conf: 2, geometry: null, hotelName: hotelFirst ? hotelFirst.name : 'hotel' };
first.arrLandingMin = parseHM(arr.arrive);
}
if (dep && !last.departureFlight) {
const ap = dep.stations && dep.stations.from;
last.isDeparture = true;
last.departureFlight = { code: codeOf(dep.from) || codeOf(dep.name) || dep.from || 'airport', at: ap || null, time: dep.depart || '', flight: String(dep.name || '').split(' · ')[0], from: dep.from || '', to: dep.to || '' };
last.transferOut = { at: ap || null, hotelAt: hotelLast, dur: (ap && hotelLast) ? (driveMin({ at: ap }, hotelLast) || 60) : 60 };
}
}
// Re-anchoring for stored plans after the source coordinates are corrected.
// Original day-stops are fixed places (there is no "drag this stop" edit), so
// the source dataset is authoritative for *where* each one is: re-sync every
// stop's `at` by id, plus the per-day base, and re-derive the arrival/departure
// airport coordinates from the AIRPORTS table. Stops the user swapped in carry
// their own (candidate) ids and are left untouched. Idempotent — returns true
// only if something actually changed (so a load can re-estimate the legs).
function healCoordinates() {
let changed = false;
const sync = (o, v) => { if (o && v && o.at && (o.at[0] !== v[0] || o.at[1] !== v[1])) { o.at = [v[0], v[1]]; changed = true; } };
if (M && M.days) {
const srcStop = {}, srcBase = {};
M.days.forEach(md => { (md.stops || []).forEach(s => { if (s.id && s.at) srcStop[s.id] = s.at; }); if (md.base && md.base.at) srcBase[md.id] = md.base.at; });
Object.values(days).forEach(d => {
if (d.base && srcBase[d.id]) sync(d.base, srcBase[d.id]);
(d.stops || []).forEach(s => { if (s.id && srcStop[s.id]) sync(s, srcStop[s.id]); });
});
}
const ids = Object.keys(days).sort();
if (ids.length) {
const first = days[ids[0]], last = days[ids[ids.length - 1]];
if (first && first.arrivalFlight && first.arrivalFlight.code) { const ap = airportOf(first.arrivalFlight.code); if (ap) { sync(first.arrivalFlight, ap.at); sync(first.transferIn, ap.at); } }
if (last && last.departureFlight && last.departureFlight.code) { const ap = airportOf(last.departureFlight.code); if (ap) { sync(last.departureFlight, ap.at); sync(last.transferOut, ap.at); } }
}
return changed;
}
// Repair the arrival/departure ground transfers of a saved plan. A plan saved
// before the coordinate fix — or after a transfer waypoint was dragged outside the
// router's coverage — can still carry stale endpoints and a route the router computed
// for those (a “9-hour taxi” for a 5 km airport drop-off). Re-sync the endpoints against
// the current hotels + the AIRPORTS table, drop any implausible stored route so it
// re-routes cleanly, default a missing mode, and make sure the arrival day's start
// actually tracks the drop-off. Idempotent — a consistent plan is a no-op.
function healTransfers() {
let changed = false;
const fixOne = (d, t, base) => {
if (!d || !t) return;
const fl = d.arrivalFlight || d.departureFlight;
const ap = fl && airportOf(fl.code);
const hotel = base && base.at;
if (ap && t.at && (t.at[0] !== ap.at[0] || t.at[1] !== ap.at[1])) { t.at = [ap.at[0], ap.at[1]]; changed = true; }
if (hotel && t.hotelAt && (t.hotelAt[0] !== hotel[0] || t.hotelAt[1] !== hotel[1])) {
t.hotelAt = [hotel[0], hotel[1]];
if (base.name) t.hotelName = base.name;
changed = true;
}
if (!t.mode || (t.mode !== 'foot' && t.mode !== 'car')) { t.mode = 'car'; changed = true; }
const est = (t.at && t.hotelAt) ? (driveMin({ at: t.at }, t.hotelAt) || 60) : 60;
// a stored route that is implausibly long (coverage-hole garbage), or an unfinished
// drag, goes back to the estimate so it is re-routed for the corrected endpoints
const garbage = (t.geometry && t.dur > Math.max(90, est * 3)) || (t._pts && !t.geometry);
if (garbage) {
t.dur = est; t.conf = 2; t.geometry = null; delete t._pts; t._routedMode = null; changed = true;
}
// a stored route whose ends no longer touch the (corrected) endpoints was computed
// for stale coordinates — same treatment
if (t.geometry && t.geometry.length && ap && hotel) {
const isIn = !!d.arrivalFlight;
const eStart = isIn ? ap.at : hotel, eEnd = isIn ? hotel : ap.at;
if (haversine(t.geometry[0], eStart) > 300 || haversine(t.geometry[t.geometry.length - 1], eEnd) > 300) {
t.dur = est; t.conf = 2; t.geometry = null; delete t._pts; t._routedMode = null; changed = true;
}
}
};
// dayBase() resolves each day's hotel from stays[], but an older saved stays[] can
// carry pre-fix hotel coordinates while the (already corrected) per-day base objects
// hold the current ones — re-sync stays against the day bases before fixing anything
const baseBy = new Map();
Object.values(days).forEach(dd => {
if (dd.base && dd.base.at && dd.base.name) baseBy.set(dd.base.name, dd.base.at);
});
stays.forEach(s => {
const want = baseBy.get(s.name);
if (want && s.at && (s.at[0] !== want[0] || s.at[1] !== want[1])) { s.at = [want[0], want[1]]; changed = true; }
});
const ids = Object.keys(days).sort();
if (!ids.length) return false;
// a saved return leg (retLeg) may have been routed to a pre-fix hotel location — if its
// geometry ends anywhere else than the current hotel point, drop it so it re-routes
ids.forEach(id => {
const dd = days[id];
const h = dd.base && dd.base.at;
const leg = dd.retLeg;
if (leg && leg.geometry && leg.geometry.length) {
if (h && haversine(leg.geometry[leg.geometry.length - 1], h) > 300) {
const lastSt = dd.stops[dd.stops.length - 1];
leg.dur = (lastSt && h) ? estLegMin(leg.mode || travelMode, lastSt.at, h) : leg.dur;
leg.conf = 1; leg.geometry = null; delete leg._routedMode; changed = true;
}
}
const fl = dd.firstLeg; // the hotel → first-stop leg gets the same endpoint-drift check
if (fl && fl.geometry && fl.geometry.length) {
const f0 = dd.stops[0];
if (f0 && h && (haversine(fl.geometry[0], h) > 300 || haversine(fl.geometry[fl.geometry.length - 1], f0.at) > 300)) {
fl.dur = estLegMin(fl.mode || travelMode, h, f0.at); fl.conf = 1; fl.geometry = null; delete fl._routedMode; changed = true;
}
}
});
const first = days[ids[0]], last = days[ids[ids.length - 1]];
const h0 = (first.base && first.base.at) ? first.base : (stays[0] || null);
const h1 = (last.base && last.base.at) ? last.base : (stays[stays.length - 1] || null);
fixOne(first, first && first.transferIn, h0);
fixOne(last, last && last.transferOut, h1);
// a null arrLandingMin (older saves) means the arrival day's start never followed the
// drop-off when its duration changed — derive it from the booked flight time
if (first && first.isArrival && first.arrivalFlight && first.arrLandingMin == null) {
const lm = parseHM(first.arrivalFlight.time);
if (lm != null) { first.arrLandingMin = lm; changed = true; }
}
if (changed && first && first.isArrival && first.arrLandingMin != null && first.transferIn) syncArrivalStart(first);
return changed;
}
function rangeStr(a, b) {
const f = s => { const [y, mo, da] = s.split('-').map(Number); return da + ' ' + MONTHS[mo - 1]; };
return a && b ? (a === b ? f(a) : f(a) + '–' + f(b)) : '';
}
// drop stops (visits first; keep breakfast/meals/transit) until the day fits endMin
function trimDayTo(d, endMin, removed) {
rebuildLegs(d);
let guard = 0;
while (layout(d) > endMin && d.stops.length > 1 && guard++ < 60) {
let idx = -1;
for (let i = d.stops.length - 1; i >= 0; i--) if (d.stops[i].slot === 'visit') { idx = i; break; }
if (idx < 0) for (let i = d.stops.length - 1; i >= 0; i--)
if (d.stops[i].slot !== 'breakfast' && d.stops[i].slot !== 'transit') { idx = i; break; }
if (idx < 0) break;
removed.push(d.stops[idx].name);
d.stops.splice(idx, 1);
rebuildLegs(d);
}
}
const TOOLS = {
// ---- queries (read-only) ----
itinerary_summary: () => ({ plan: llmContext() }),
search_places: (a) => {
const cat = String(a.category || '').toLowerCase();
if (!M.candidates[cat]) return { error: `unknown category "${a.category}"; use ${Object.keys(M.candidates).join(', ')}` };
let region = null;
if (a.region) {
region = REGION_MAP[String(a.region).toLowerCase()];
if (!region) return { error: `unknown region "${a.region}"; use ${Object.keys(REGION_MAP).join(', ')} or omit` };
}
const results = poolFor(cat, region).map(c => ({
id: c.id, name: c.name, price_eur: c.price, dur_min: c.dur, tags: c.tags,
pitch: c.pitch, walk_from_hotel_min: hotelWalkMin(c.at),
}));
return { category: cat, region: a.region || null, count: results.length, results };
},
place_facts: (a) => {
const p = resolvePlace(a.id || a.name);
if (!p) return { error: `no place matching "${a.id || a.name}"` };
const c = candById(p.id) || liveStopById(p.id);
return { name: p.name, price_eur: c?.price, dur_min: c?.dur, tags: c?.tags, pitch: c?.pitch,
walk_from_hotel_min: hotelWalkMin(p.at), url: c?.url || null, summary: c?.detail?.summary || null };
},
// LIVE web search (SearXNG) — for anything that can change between the
// data snapshot and the trip: opening hours, fees, seasonal events, prices.
// Results carry provenance URLs; the model must cite the ones it used.
web_search: async (a) => {
const q = String(a.query || '').trim();
if (!q) return { error: 'query is required' };
if (!webOn) return { error: 'the web search backend is offline — say so and fall back to what the plan already knows' };
const r = await fetch('/search?q=' + encodeURIComponent(q) + '&count=5' + (a.categories ? '&categories=' + encodeURIComponent(a.categories) : ''));
const j = await r.json();
if (!j.ok) return { error: j.error || 'search failed' };
return { count: j.count, results: (j.results || []).map(x => ({ title: x.title, url: x.url, source: x.source, snippet: x.snippet.slice(0, 300) })) };
},
// POI lookup against the local PostGIS extract (native ST_DWithin / KNN /
// corridor queries — real OSM data, not the curated demo pools).
poi_near: async (a) => { // lat/lng are also returned — the UI drops pins
if (!spatialOn) return { error: 'the spatial database is offline — use search_places instead' };
const lat = +a.lat, lng = +a.lng, r = Math.min(50000, Math.max(100, +a.radius_m || 500));
if (!isFinite(lat) || !isFinite(lng)) return { error: 'lat/lng must be numbers (lat, lng in degrees)' };
let qs = `lat=${lat}&lng=${lng}&r=${r}`;
if (a.kind) qs += '&kind=' + encodeURIComponent(a.kind); // e.g. amenity=restaurant, tourism=museum
if (a.name) qs += '&name=' + encodeURIComponent(a.name);
if (a.corridor) qs += '&points=' + encodeURIComponent(a.corridor); // 'lng,lat;lng,lat;…' route polyline (encoded: Go parses raw ';' as a param separator)
const rj = await fetch('/spatial/' + (a.corridor ? 'corridor' : 'near') + '?' + qs);
const j = await rj.json();
if (j.error) return { error: j.error };
return { count: (j.results || []).length, results: (j.results || []).map(x =>
({ name: x.name, kind: x.kind, lat: x.lat, lng: x.lng, dist_m: Math.round(x.dist_m), opening_hours: x.opening_hours || null, fee: x.fee || null, website: x.website || null })) };
},
// semantic POI search over the pgvector index (zembed embeddings, cosine).
// "museums of ancient Etruscan art near the city center" style queries.
// browse the OSM tag vocabulary that actually exists in this extract —
// grounds the agent's concept→tag choices in real data instead of guesses.
poi_kinds: async (a) => {
if (!spatialOn) return { error: 'the spatial database is offline — use search_places instead' };
let qs = 'limit=' + Math.min(50, Math.max(1, +a.limit || 30));
if (a.q) qs += '&q=' + encodeURIComponent(a.q);
if (a.extract) qs += '&extract=' + encodeURIComponent(a.extract);
const rj = await fetch('/spatial/kinds?' + qs);
const j = await rj.json();
if (j.error) return { error: j.error };
return { count: (j.kinds || []).length, kinds: j.kinds || [] };
},
// Targeted POI search: the agent maps the user's concept to OSM kinds +
// name keywords (local-language variants); Postgres retrieves with indexed
// kind/FTS/trigram filters — no embedding model. kinds: "historic=fort",
// a family "tourism", or a bare value; terms: ["wine","vino","cava"].
poi_search: async (a) => {
if (!spatialOn) return { error: 'the spatial database is offline — use search_places instead' };
const kinds = (Array.isArray(a.kinds) ? a.kinds : [a.kinds]).filter(Boolean);
const terms = (Array.isArray(a.terms) ? a.terms : [a.terms]).filter(Boolean);
if (!kinds.length && !terms.length) return { error: 'pass kinds and/or terms' };
let qs = '';
if (kinds.length) qs += 'kinds=' + encodeURIComponent(kinds.join('|')) + '&';
if (terms.length) qs += 'terms=' + encodeURIComponent(terms.join('|')) + '&';
if (a.lat != null && a.lng != null) {
qs += 'lat=' + a.lat + '&lng=' + a.lng;
if (a.r) qs += '&r=' + Math.min(50000, Math.max(50, +a.r));
}
qs += 'limit=' + Math.min(30, Math.max(1, +a.limit || 10));
if (a.extract) qs += '&extract=' + encodeURIComponent(a.extract);
const rj = await fetch('/spatial/search?' + qs);
const j = await rj.json();
if (j.error) return { error: j.error };
return { count: (j.results || []).length, results: (j.results || []).map(x =>
({ name: x.name, kind: x.kind, lat: x.lat, lng: x.lng, score: x.score, dist_m: Math.round(x.dist_m), opening_hours: x.opening_hours || null, fee: x.fee || null, website: x.website || null })) };
},
route_between: async (a) => {
const A = resolvePlace(a.from), B = resolvePlace(a.to);
if (!A) return { error: `cannot resolve "from" = "${a.from}"` };
if (!B) return { error: `cannot resolve "to" = "${a.to}"` };
if (A.id === B.id || A.name === B.name) return { from: A.name, to: B.name, minutes: 0, km: 0, source: 'same place' };
let minutes, km, source;
const res = saneRoute(await routePts([A.at, B.at], 'foot'), [A.at, B.at], 'foot');
if (res) { minutes = res.durMin; km = Math.round(res.distance / 100) / 10; source = 'local router (OSRM walk)'; }
else { minutes = walkMin(A.at, B.at); km = Math.round(haversine(A.at, B.at) / 100) / 10;
source = routerOn ? 'straight-line est (outside router coverage)' : 'straight-line est (router offline)'; }
return { from: A.name, to: B.name, minutes, km, source };
},
// The model's EYES: capture exactly what the user sees on the map for the
// current day — raster tiles + route legs + the numbered pins — as a PNG
// that is attached to this tool result (the model is multimodal). Use it
// when the user describes something visual ("the marker is in the water",
// "what do I see?"), and after relocate_stop to verify the fix.
screenshot_map: () => {
const url = mapScreenshot();
if (!url) return { error: 'could not capture the map (no tiles loaded yet) — ask the user to describe what they see instead' };
const cur = days[curDay];
return {
ok: true,
day: cur.label + ' · ' + cur.date,
zoom: map.getZoom(),
pins: cur.stops.map((s, i) => `${i + 1} = ${s.name} [${s.state}] at ${s.at[0].toFixed(4)},${s.at[1].toFixed(4)}`),
hotels: staysForDay().map(o => 'H = ' + o.name + ' at ' + o.at[0].toFixed(4) + ',' + o.at[1].toFixed(4)),
legend: 'numbered pins are this day’s stops in day order (red=planned, grey=idea/backup); H = hotel; green line = the route the user sees. The attached image is the actual map viewport.',
image: url,
};
},
review_plan: (a) => {
const ids = a.dayId ? [a.dayId] : Object.keys(days);
const issues = [];
for (const id of ids) {
const d = days[id]; if (!d) { issues.push(`${id}: not a day`); continue; }
layout(d);
const prim = d.stops.filter(s => s.state === 'planned');
const planned = prim.reduce((t, s) => t + s.dur, 0) + d.legs.reduce((t, g) => t + (g?.dur || 0), 0);
const endMin = d.startMin + planned;
const capMin = d.startMin + (d.wakingHours || 11) * 60;
if (endMin > capMin) issues.push(`${d.label}: planned time ends ~${fmt(endMin)} but the waking window closes ~${fmt(capMin)} — ~${Math.round(endMin - capMin)} min over`);
prim.forEach(s => {
if (s.slot === 'lunch' && (s.start < 11 * 60 || s.start > 15 * 60)) issues.push(`${d.label}: lunch at ${s.name} starts ${fmt(s.start)} — outside a normal 11:00–15:00 window`);
if (s.slot === 'dinner' && (s.start < 17 * 60 || s.start > 21 * 60)) issues.push(`${d.label}: dinner at ${s.name} starts ${fmt(s.start)} — outside a normal 17:00–21:00 window`);
});
const seen = {};
prim.forEach(s => { const k = s.name.toLowerCase(); if (seen[k] && !s.name.includes('—')) issues.push(`${d.label}: "${s.name}" appears more than once in the plan`); seen[k] = (seen[k] || 0) + 1; });
if (!prim.length) issues.push(`${d.label}: has no planned stops`);
}
return { issues: issues.length ? issues : ['no problems found'] };
},
// ---- fix-ops (mutations; each commits → undo-able) ----
add_stop: (a) => {
const SLOTS = ['lunch', 'dinner', 'visit'];
const d = days[a.dayId]; if (!d) return { error: `unknown day "${a.dayId}"; days are ${Object.keys(days).join(', ')}` };
const c = candById(a.candidateId); if (!c) return { error: `no candidate "${a.candidateId}" — call search_places first` };
const slot = SLOTS.includes(String(a.slot || c.cat).toLowerCase()) ? String(a.slot || c.cat).toLowerCase() : c.cat;
if (slot === 'hotel') return { error: 'hotels are stays, not day stops — point the user to the hotel drawer' };
const stop = makeStop(c, slot, a.state && ['planned', 'maybe', 'alt'].includes(a.state) ? a.state : 'planned');
const inc = d.stops.find(x => x.slot === slot && x.state === 'planned' && x.id !== stop.id);
if (inc && stop.state === 'planned') inc.state = 'alt'; // one slot, one plan
d.stops.push(stop);
rebuildLegs(d); commit(`[llm] ${stop.name} → ${stateLabel(stop.state)} on ${d.label}`); renderAll([stop.id]);
return { ok: true, action: 'add_stop', stop: stop.name, slot, state: stop.state, day: d.label, demoted: inc ? inc.name : null };
},
remove_stop: (a) => {
const stop = resolveStop(a.stopId); if (!stop) return { error: `no stop "${a.stopId}"` };
const d = days[Object.keys(days).find(k => days[k].stops.includes(stop))];
d.stops.splice(d.stops.indexOf(stop), 1);
rebuildLegs(d); commit(`[llm] removed ${stop.name} from ${d.label}`); renderAll();
return { ok: true, action: 'remove_stop', removed: stop.name, day: d.label };
},
move_stop: (a) => {
const stop = resolveStop(a.stopId); if (!stop) return { error: `no stop "${a.stopId}"` };
const to = days[a.toDayId]; if (!to) return { error: `unknown day "${a.toDayId}"` };
const fromId = Object.keys(days).find(k => days[k].stops.includes(stop));
days[fromId].stops.splice(days[fromId].stops.indexOf(stop), 1);
to.stops.push(stop);
rebuildLegs(days[fromId]); rebuildLegs(to);
commit(`[llm] moved ${stop.name} → ${to.label}`); renderAll();
return { ok: true, action: 'move_stop', stop: stop.name, from: days[fromId].label, to: to.label };
},
set_duration: (a) => {
const stop = resolveStop(a.stopId); if (!stop) return { error: `no stop "${a.stopId}"` };
const mins = Math.round(+a.minutes || 0); if (!mins || mins < 5) return { error: 'minutes must be a number ≥ 5' };
const d = days[Object.keys(days).find(k => days[k].stops.includes(stop))];
stop.dur = mins; stop.suggested = { value: mins, conf: 4, src: 'assistant' };
rebuildLegs(d); layout(d); commit(`[llm] ${stop.name} → ${mins} min`); renderAll([stop.id]);
return { ok: true, action: 'set_duration', stop: stop.name, minutes: mins, day: d.label, day_ends: fmt(layout(d)) };
},
set_start: (a) => {
const d = days[a.dayId]; if (!d) return { error: `unknown day "${a.dayId}"` };
const m = /^(\d{1,2}):(\d{2})$/.exec(String(a.time || '').trim()); if (!m) return { error: 'time must be HH:MM' };
const h = +m[1], mn = +m[2]; if (h > 23 || mn > 59) return { error: 'time must be HH:MM' };
if (d.isArrival && d.arrLandingMin != null) {
// the arrival day's start is DERIVED (landing → transfer → hotel → first stop);
// moving "start" moves the landing time so the whole chain stays consistent
d.arrLandingMin = h * 60 + mn;
syncArrivalStart(d);
} else d.startMin = h * 60 + mn;
rebuildLegs(d); layout(d); commit(`[llm] ${d.label} starts ${fmt(d.startMin)}`); renderAll();
return { ok: true, action: 'set_start', day: d.label, start: fmt(d.startMin), day_ends: fmt(layout(d)) };
},
// Re-pin a stop at real-world coordinates. The dataset coordinates can be
// wrong (a pin 2–4 km off, or in the water) — when that happens, find the
// place's TRUE position first (poi_search/poi_near by name), then fix it
// here. Legs re-route and the edit is undo-able like every other fix-op.
relocate_stop: (a) => {
const stop = resolveStop(a.stopId); if (!stop) return { error: `no stop "${a.stopId}"` };
const lat = +a.lat, lng = +a.lng;
if (!isFinite(lat) || !isFinite(lng) || Math.abs(lat) > 90 || Math.abs(lng) > 180) return { error: 'lat/lng must be valid decimal degrees' };
const old = stop.at.slice();
stop.at = [lat, lng];
const d = days[Object.keys(days).find(k => days[k].stops.includes(stop))];
stop.suggested = { ...(stop.suggested || {}), value: [lat, lng], conf: 4, src: 'assistant' };
rebuildLegs(d);
commit(`[llm] relocated ${stop.name} to ${lat.toFixed(5)}, ${lng.toFixed(5)}`);
renderAll([stop.id]);
return { ok: true, action: 'relocate_stop', stop: stop.name, from: old, to: [lat, lng], day: d.label,
note: 'legs re-routed from the new pin; call screenshot_map if you want to confirm it sits where the named place actually is' };
},
// Re-anchor the whole trip around a real booked flight (arrival + departure).
// Deterministic: re-dates the day window, records the flights as hard-anchor
// bookings, shortens the arrival day (start after landing+transfer) and the
// departure day (wrap before the flight), and trims what no longer fits.
apply_flight_anchors: (a) => {
const arr = a.arrival || {}, dep = a.departure || {};
const arrMin = parseHM(arr.time), depMin = parseHM(dep.time);
if (arrMin == null) return { error: 'arrival.time must be "HH:MM" (got "' + arr.time + '")' };
if (depMin == null) return { error: 'departure.time must be "HH:MM" (got "' + dep.time + '")' };
const ids = Object.keys(days).sort();
const first = days[ids[0]], last = days[ids[ids.length - 1]];
const hotelFirst = (first.base && first.base.at) || (stays[0] && stays[0].at);
const hotelLast = (last.base && last.base.at) || (stays[stays.length - 1] && stays[stays.length - 1].at);
const arrAp = airportOf(arr.airport), depAp = airportOf(dep.airport);
const arrTransfer = driveMin(arrAp, hotelFirst) != null ? driveMin(arrAp, hotelFirst) : 60; // land → hotel
const depTransfer = driveMin(depAp, hotelLast) != null ? driveMin(depAp, hotelLast) : 60; // hotel → airport
const removed = [], notes = [];
// remember the anchors ON the day objects so the rail can render the
// airport card + the ground transfer in the right place. The flights are
// bookings (hard anchors), not stops — the day starts/ends with them.
first.isArrival = true;
first.arrivalFlight = { code: arrAp ? arrAp.code : (arr.airport || arr.city || 'airport'), at: arrAp ? arrAp.at : null, time: arr.time, flight: arr.flight || '', from: arr.from || '', to: arr.city || '' };
first.transferIn = { at: arrAp ? arrAp.at : null, hotelAt: hotelFirst, dur: arrTransfer, mode: 'car', conf: 2, geometry: null, hotelName: hotelFirst ? hotelFirst.name : 'hotel' };
last.isDeparture = true;
last.departureFlight = { code: depAp ? depAp.code : (dep.airport || dep.city || 'airport'), at: depAp ? depAp.at : null, time: dep.time, flight: dep.flight || '', from: dep.city || '', to: dep.to || '' };
last.transferOut = { at: depAp ? depAp.at : null, hotelAt: hotelLast, dur: depTransfer };
// 1) re-date across the real [arrival, departure] window — spread the plan's
// days evenly between the booked first and last date (contiguous when the
// day counts match, monotonic when they don't)
if (arr.date && dep.date) {
const pa = (y, m, d) => Date.UTC(+y, m - 1, +d);
const [ay, am, ad] = arr.date.split('-'), [by, bm, bd] = dep.date.split('-');
const A = pa(ay, am, ad), B = pa(by, bm, bd);
const N = ids.length;
if (B >= A) {
const spanDays = Math.round((B - A) / 86400000) + 1;
if (N === 1) { if (first.date !== arr.date) relabelDay(first, 1, arr.date); }
else {
for (let i = 0; i < N; i++)
relabelDay(days[ids[i]], i + 1, new Date(A + Math.round(i * (B - A) / (N - 1))).toISOString().slice(0, 10));
if (spanDays !== N) notes.push('the booked window is ' + spanDays + ' days but the plan has ' + N + ' — I spread the ' + N + ' days evenly across ' + rangeStr(arr.date, dep.date));
}
}
}
// 2) record the booked flights as hard anchors — the user's real ticket
// supersedes any placeholder flight records (hotels stay untouched);
// dropping all flights first keeps a re-apply idempotent
M.trip.bookings = (M.trip.bookings || []).filter(b => b.type !== 'flight');
const flightRec = (role, o, ap, t) => ({
type: 'flight', source: 'user_flight', status: 'booked',
name: (o.flight ? o.flight + ' · ' : '') + (ap ? ap.code : (o.city || 'flight')),
ref: o.ref || (role === 'arrival' ? 'arr-booking' : 'dep-booking'),
from: o.from || '—', to: o.to || '—', date: o.date || '',
depart: role === 'departure' ? t : (o.depart || ''), arrive: role === 'arrival' ? t : (o.arrive || ''),
stations: ap ? { from: role === 'arrival' ? null : ap.at, to: role === 'arrival' ? ap.at : null } : {},
});
M.trip.bookings.push(flightRec('arrival', arr, arrAp, arr.time));
M.trip.bookings.push(flightRec('departure', dep, depAp, dep.time));
const rng = rangeStr(arr.date, dep.date);
if (rng) {
// idempotent: replace a trailing date range in any of the forms we emit or
// the dataset uses ("30 Sep–12 Oct" | "21–28 Sep" | "21 Sep")
const RE = /(\d{1,2}\s+[A-Za-z]{3}\s*[\u2013\u2014-]\s*\d{1,2}\s+[A-Za-z]{3}|\d{1,2}\s*[\u2013\u2014-]\s*\d{1,2}\s+[A-Za-z]{3}|\d{1,2}\s+[A-Za-z]{3})\s*$/;
const t = M.trip.title.replace(RE, rng);
if (t !== M.trip.title) M.trip.title = t;
}
// 3) arrival day: start after landing + transfer; drop the meaningless
// breakfast; shorten the waking window; trim what no longer fits
first.arrLandingMin = arrMin; // landing time — startMin is derived from it + the transfer
syncArrivalStart(first); // startMin = landing + transferIn.dur (arrTransfer for now)
for (let i = first.stops.length - 1; i >= 0; i--)
if (first.stops[i].slot === 'breakfast') { removed.push(first.stops[i].name); first.stops.splice(i, 1); }
trimDayTo(first, first.startMin + first.wakingHours * 60, removed);
// 4) departure day: keep only check-out, and REPLACE the (placeholder)
// transit stop with the user's actual departure flight; size the flight
// block so the day lands exactly on the booked departure time
last.isDeparture = true;
last.startMin = Math.min(last.startMin || 540, 8 * 60);
last.stops.forEach(s => { if (s.slot !== 'breakfast') removed.push(s.name); });
last.stops = last.stops.filter(s => s.slot === 'breakfast');
if (!last.stops.length)
last.stops.push({ id: 'CHKOUT', name: 'Breakfast & check-out', kind: 'meal', mealKind: 'breakfast', slot: 'breakfast', state: 'planned', at: hotelLast || [0, 0], dur: 45, durConf: 3 });
last.stops.push({
id: 'FLYOUT', name: 'Flight out — ' + (depAp ? depAp.code : (dep.airport || 'airport')), kind: 'transit', slot: 'transit', state: 'planned',
at: (depAp && depAp.at) || hotelLast || [0, 0], dur: 60, durConf: 3,
transit: { name: dep.flight || 'flight', to: dep.to || (depAp ? depAp.code : (dep.city || 'airport')), depart: dep.time, arriveBy: fmt(Math.max(0, depMin - 60)), buffer: '1 h before departure' },
price: null,
});
rebuildLegs(last);
let pre = last.startMin;
for (let i = 0; i < last.stops.length; i++) {
const s = last.stops[i];
if (s.slot === 'transit') { s.dur = Math.max(30, depMin - pre); break; }
pre += s.dur; if (last.legs[i]) pre += last.legs[i].dur;
}
last.wakingHours = Math.max(1, Math.round((depMin - last.startMin) / 60));
// 5) commit (persisted immediately — this is the edit we must never lose;
// commit() also re-syncs stays/places/booking dates to the new day
// dates) + re-render every date-bearing UI piece (renderAll alone only
// covers rail + map, which left tabs, chips and title showing old dates)
ids.forEach(id => rebuildLegs(days[id]));
commit('[flight] re-anchored around booked arrival + departure');
persistTrip(true);
renderAll();
buildDayTabs();
renderModeToggle();
renderTripMenu();
renderVersionPill();
const fs2 = dayStats(first), ls2 = dayStats(last);
return {
ok: true,
arrival: { city: arr.city, airport: arr.airport, date: arr.date, time: arr.time, land_to_hotel_min: arrTransfer },
departure: { city: dep.city, airport: dep.airport, date: dep.date, time: dep.time, hotel_to_airport_min: depTransfer },
first_day: { id: first.id, label: first.label, start: fmt(first.startMin), end: fmt(fs2.end), stops: first.stops.length },
last_day: { id: last.id, label: last.label, start: fmt(last.startMin), end: fmt(ls2.end), stops: last.stops.length },
removed: removed, notes: notes,
};
},
};
function llmSystemPrompt() {
const cats = Object.keys(M.candidates);
const regions = Object.keys(REGION_MAP).map(k => REGION_MAP[k].label);
return (
'You are the planning assistant inside a trip-planning app. The user is refining a trip and you are its orchestrator. Never invent times, prices, or places — read them from tools. Deterministic tools compute routes and validate the plan; you decide which to call and turn the results into a short, warm answer.\n\n' +
'You may call TOOLS. To call one, reply with ONLY this exact format — a single fenced json block, nothing else on that turn:\n' +
'<tool>\n{"name":"TOOL_NAME","args":{...}}\n</tool>\n' +
'The tool result is then returned to you. From there you either call another tool the same way, or give your final natural-language answer.\n\n' +
'TOOLS (name — args — what it does):\n' +
' itinerary_summary() — the current plan: bookings, stays, and each day\'s scheduled stops with times.\n' +
' search_places(category, region?) — candidate places. category ∈ {' + cats.join(', ') + '} ; region ∈ {' + regions.join(', ') + '} or omit. Returns id, name, price_eur, dur_min, tags, pitch, walk_from_hotel_min.\n' +
' place_facts(id) — full facts for one place (id or name): price, duration, tags, pitch, walk from hotel, summary.\n' +
' web_search(query) — LIVE web search. Use for anything that can change since the data snapshot: opening hours, entry fees, seasonal events, current prices, whether a place still exists. Returns results with source URLs — always cite the URL(s) you relied on in your answer.\n' +
' poi_near(lat, lng, radius_m?, kind?, name?) — real POIs within a radius of a point from the local OSM/PostGIS extract (e.g. kind "amenity=restaurant"). Use when the curated pools are empty or the user asks "what is around X". Results appear as map pins.\n' +
' poi_kinds(q?, limit?) — list the OSM POI tags that actually exist in the local extract, with counts (q filters, e.g. q="wine"). Call this FIRST when you are not sure which tag fits the user\'s idea.\n' +
' poi_search(kinds?, terms?, lat?, lng?, r?, limit?) — targeted POI search over the local OSM extract. kinds = OSM tags (e.g. "historic=fort", or a family like "tourism"); terms = name keywords (give local-language variants, e.g. ["wine","vino","cava"]). Translate the user\'s concept into tags + keywords — YOU are the semantic layer; Postgres does the exact retrieval. Optional lat/lng/r restricts to a radius (default 10 km). Results appear as map pins.\n' +
' route_between(from, to) — real walking minutes + km between two places ("hotel" or a place id/name).\n' +
' review_plan(dayId?) — deterministic checks: day overrun vs the waking window, meals at implausible hours, duplicates. dayId optional.\n' +
' add_stop(dayId, slot, candidateId, state?) — put a candidate on a day. slot ∈ lunch,dinner,visit. If the slot is taken the current stop is demoted to a backup (a swap). state ∈ planned,idea,backup (default planned). candidateId comes from search_places.\n' +
' remove_stop(stopId) — remove a stop from its day. stopId is a stop id or its name.\n' +
' move_stop(stopId, toDayId) — move a stop to another day. stopId is a stop id or its name.\n' +
' set_duration(stopId, minutes) — change a stop\'s duration in minutes. stopId is a stop id or its name.\n' +
' set_start(dayId, time) — set a day\'s start time as "HH:MM".\n' +
' relocate_stop(stopId, lat, lng) — re-pin a stop at real-world coordinates. The dataset coordinates can be WRONG (pin 2–4 km off, in the water). When a pin looks misplaced: find the true position first with poi_search/poi_near (search by the place\'s name — use the OSM result\'s lat/lng, never your own memory), then relocate_stop, then screenshot_map to confirm.\n' +
' screenshot_map() — a photo of the map exactly as the user sees it (tiles, route line, numbered pins; H = hotel). You CAN see the image it returns. Call it when the user describes something visual ("the marker is in the sea", "what do I see?", "does this look right?"), after relocate_stop, or whenever you are unsure about geometry.\n' +
' apply_flight_anchors({arrival, departure}) — re-anchor the WHOLE trip around real booked flights. arrival/departure = {date:"YYYY-MM-DD", time:"HH:MM" local, airport:"code or name e.g. CTG", city, from?, to?, flight?, ref?}. Re-dates the day window, records the flights, shortens the arrival day (start after landing+transfer) and the departure day (wrap before the flight), and trims stops that no longer fit. Use this when the user provides their actual ticket/booking.\n\n' +
'Guidelines:\n' +
'- dayId is the day id like "D1" or "D2" — shown in brackets in the plan rows, e.g. [D1 · 2026-09-12]. Never use the calendar date as a dayId.\n' +
'- Answer concisely (1–4 sentences), plain text, no markdown.\n' +
'- For any factual question about times, distances, or prices, CALL the tool — do not guess. For CURRENT facts ("is it open?", "how much now", seasonal), prefer web_search and cite its source URLs.\n' +
'- Visual complaints ("marker in the water", "point is in the wrong place", "route crosses the bay"): believe the user, call screenshot_map to see it yourself, find the true coordinates with poi_search, fix with relocate_stop, confirm with screenshot_map. NEVER dismiss a placement complaint as a rendering glitch.\n' +
'- For a requested change, call the matching fix-op, then confirm in one sentence what changed. The user can undo any edit.\n' +
'- Before a big rework, consider review_plan() and mention any issues it reports.\n' +
'- Be efficient: call each tool at most once unless you need to, and never re-fetch facts you already have. A fix-op alone is enough — do not re-search or re-fetch the stop you are moving/editing.\n' +
'- When a real flight/booking document is attached, read out the arrival and departure (date, local time, airport, city), then call apply_flight_anchors exactly once. Apply it directly even if the booked dates differ from the current plan — the user’s ticket is the truth and the edit is undo-able — then report what changed, including the date shift. Only ask a clarifying question if the document is genuinely ambiguous (e.g. two candidate departure flights).\n' +
'- Stay consistent with your earlier turns: if I am confirming a change you already proposed, apply it using the details from your own earlier message — never claim you can’t see information you quoted yourself.\n\n' +
'Current plan:\n' + llmContext() + docSection());
}
// Parse a <tool>{...}</tool> block from the model output, if present.
function parseToolBlock(text) {
const m = /<tool>\s*([\s\S]*?)\s*<\/tool>/.exec(text);
if (!m) return null;
try { return JSON.parse(m[1]); } catch { return null; }
}
// A subtle activity line so the user sees the model working its tools.
function toolLine(name, args) {
const m = el('div', 'msg tool', '⚙ ' + esc(name) + (args && Object.keys(args).length ? ' <span class="ta">' + esc(JSON.stringify(args)) + '</span>' : ''));
$('#chat-body').append(m); m.scrollIntoView({ behavior: 'smooth', block: 'end' });
return m;
}
async function askLLM(v) {
if (scriptBusy) return msg('ai', 'Hold on — I’m still working on the last request.');
scriptBusy = true;
await storeReady.catch(() => {}); // make sure the persisted history is loaded
pushChatLog('user', v);
persistChatTurn('user', v);
let t = typing();
const messages = [{ role: 'system', content: llmSystemPrompt() }, ...recentHistory(), { role: 'user', content: v }];
const finish = (html, plain) => { t.remove(); msg('ai', html); if (plain) { pushChatLog('assistant', plain); persistChatTurn('assistant', plain); } scriptBusy = false; };
try {
let finalText = null;
for (let i = 0; i < LLM_TOOL_MAX; i++) {
const llmBody = { messages, max_tokens: 8192 }; if (!thinkOn) llmBody.reasoning_effort = 'none';
const r = await fetch('/llm-chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(llmBody) });
const j = await r.json();
const content = (j.choices?.[0]?.message?.content || '').trim();
const call = parseToolBlock(content);
if (call) {
t.remove();
if (TOOLS[call.name]) toolLine(call.name, call.args || {});
t = typing(); // the assistant keeps "thinking"
messages.push({ role: 'assistant', content });
let res;
if (TOOLS[call.name]) res = await Promise.resolve(TOOLS[call.name](call.args || {}));
else res = { error: 'unknown tool. available: ' + Object.keys(TOOLS).join(', ') };
// surface spatial lookups as map pins, not just JSON in the transcript
if ((call.name === 'poi_near' || call.name === 'poi_search') && res && Array.isArray(res.results) && res.results.length) {
showSpatialPins(res.results, call.name);
}
// screenshot tools return an image — attach it as a vision payload so
// the model can actually see the map, with the text fields alongside
if (res && res.image) {
const { image, ...rest } = res;
messages.push({ role: 'user', content: [
{ type: 'text', text: '[tool result for ' + call.name + ']\n' + JSON.stringify(rest) },
{ type: 'image_url', image_url: { url: image } },
]});
} else {
messages.push({ role: 'user', content: '[tool result for ' + call.name + ']\n' + JSON.stringify(res) });
}
continue;
}
finalText = content; break;
}
if (finalText === null) finish('I ran out of tool steps without a final answer — please rephrase.');
else if (finalText) finish(esc(finalText).replace(/\n+/g, '<br>'), finalText);
else finish('The model used its whole token budget thinking and returned no visible answer — try a shorter question.');
} catch (e) {
t.remove();
msg('ai', 'LLM call failed — falling back to canned replies.');
scriptBusy = 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}&router=${routerKey()}&points=${encodeURIComponent(q)}`);
const j = await r.json();
const route = j?.routes?.[0];
if (!route) return null; // NoTable / out of coverage
if (!route.distance && !route.duration) return null;
// distance 0 = both points snapped to the same node → adjacent stops
return {
durMin: Math.max(1, Math.round(route.duration / 60)),
distance: route.distance,
geometry: route.geometry ? decodePolyline(route.geometry).map(p => [p[1], p[0]]) : [], // → [lat, lng]
};
} catch { return null; }
}
let legTokens = {}; // per-day enrichment token — each day enriches independently
// background enrichment: upgrade each straight-line leg to a real route
async function enrichLegs(d = day) {
if (!routerOn) return;
const key = d.id, token = (legTokens[key] = (legTokens[key] || 0) + 1); // only a newer enrich OF THIS DAY cancels it
// the arrival drop-off (airport→hotel) is a first-class leg — route it FIRST, before the
// stop-leg loop, so the stop-leg token bails below can't starve it. The value is day-scoped
// and idempotent (apply even if a newer enrich started); skip when already routed in this
// mode. Only re-render if it's still the day on screen.
if (d.isArrival && d.transferIn && d.transferIn.at && d.transferIn.hotelAt) {
const t = d.transferIn, want = t.mode || 'car';
if (!(t.conf === 3 && t.geometry && t._routedMode === want)) {
const before = t.dur;
const res = saneRoute(await routePts([t.at, t.hotelAt], want), [t.at, t.hotelAt], want);
if (res) { t.dur = res.durMin; t.conf = 3; t.geometry = res.geometry; t._routedMode = want; }
else { t.conf = 1; }
if (t.dur !== before) syncArrivalStart(d); // the arrival day starts after this drop-off
if (l3 && l3.leg === t) l3.refreshCard && l3.refreshCard();
if (d === day) renderAll();
}
}
// hotel → first stop: routed like any other leg (same router, same mode, same sanity
// guards) — the arrival day and every ordinary day get a real, editable time for it
if (d.stops.length && staysForDay(d).length === 1 && !stopAtHotel(d)) {
const f0 = d.stops[0];
if (f0.kind !== 'region' && f0.kind !== 'transit') {
const leg = getFirstLeg(d);
if (leg) {
const want = leg.mode;
if (!(leg.conf === 3 && leg.geometry && leg._routedMode === want)) {
const h = dayBase(d).at;
const res = saneRoute(await routePts([h, f0.at], want), [h, f0.at], want);
if (legTokens[key] !== token) return;
if (res) { leg.dur = res.durMin; leg.conf = 3; leg.geometry = res.geometry; leg._routedMode = want; }
else { leg.conf = 1; leg.src = 'estimate — outside router coverage'; }
if (d === day) renderAll();
}
}
}
}
for (let i = 0; i < d.stops.length - 1; i++) {
const a = d.stops[i], b = d.stops[i + 1];
if (a.kind === 'region' || b.kind === 'region' || !d.legs[i]) continue;
// legs touching a transit stop are fixed (flight/taxi/bus), never re-routed
if (a.kind === 'transit' || b.kind === 'transit') continue;
const legMode = d.legs[i].mode === 'multimodal' ? 'foot' : d.legs[i].mode;
const res = saneRoute(await routePts([a.at, b.at], legMode), [a.at, b.at], legMode);
if (legTokens[key] !== token) return; // this day was re-enriched 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 && d === day) renderAll(); // only re-render if it's still the day on screen
} else {
leg.conf = 1; leg.src = 'estimate — outside router coverage';
if (d === day) renderAll();
}
}
// return-to-hotel ride when a single stay anchors the day — an editable leg (retLeg),
// so the rail row can be clicked into the route card (drag the drop-off, walk vs taxi)
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 leg = getReturnLeg(d);
if (leg) {
const want = leg.mode;
if (!(leg.conf === 3 && leg.geometry && leg._routedMode === want)) {
const res = saneRoute(await routePts([last.at, dayBase(d).at], want), [last.at, dayBase(d).at], want);
if (legTokens[key] !== token) return;
if (res) { leg.dur = res.durMin; leg.conf = 3; leg.geometry = res.geometry; leg._routedMode = want; }
else { leg.conf = 1; leg.src = 'estimate — outside router coverage'; }
if (d === day) renderAll();
}
}
}
}
}
// ---------------- state ----------------
// (filled by enterTrip() — the app can switch between window.TRIPS at runtime)
let days = {};
let curDay, day;
const stopById = id => day.stops.find(s => s.id === id);
const allStops = id => M.days.flatMap(d => d.stops).find(s => s.id === id);
let markers = {}, legEls = {}, otherLayers = [], tempMarkers = [], retLine = null, retLines = [], arrLine = null, arrLines = [], firstLine = null, firstLines = [];
let map, l3 = null, scriptBusy = false;
let dragCand = null; // candidate object currently being dragged (drawer / dock)
let compareItems = []; // items in the comparison dock (max 4)
// user-facing state vocabulary (engine keeps planned/maybe/alt underneath)
const stateLabel = st => st === 'planned' ? 'in plan' : st === 'maybe' ? 'idea' : 'backup';
// ---------------- mobile: single pane + bottom tabs ----------------
(function () {
const tabs = $('#mobiletabs'); if (!tabs) return;
const panes = { plan: $('#rail'), map: $('#mapwrap'), chat: $('#chat') };
const setTab = t => {
Object.entries(panes).forEach(([k, e]) => e.classList.toggle('pane-hidden', k !== t));
tabs.querySelectorAll('button').forEach(b => b.classList.toggle('on', b.dataset.t === t));
if (t === 'map') setTimeout(() => map.invalidateSize(), 80);
};
tabs.querySelectorAll('button').forEach(b => b.onclick = () => setTab(b.dataset.t));
if (window.matchMedia('(max-width: 860px)').matches) setTab('plan');
})();
// ---------------- stays / base ----------------
// stays, not a single hotel: each stay anchors the dates it covers.
// >1 candidate stay covering the same dates → worst-case anchoring for those dates.
let stays, staySeq; // filled by enterTrip()
const covers = (s, date) => s.checkIn <= date && date <= s.checkOut;
const staysForDay = (d = day) => stays.filter(s => covers(s, d.date));
const dayBase = (d = day) => {
const list = staysForDay(d);
return list.find(s => s.state === 'booked') || list[0] || stays[0];
};
const hotelWalkMin = (at, d = day) => {
const list = staysForDay(d);
return list.length ? Math.max(1, ...list.map(o => Math.round(haversine(at, o.at) / 1000 / (WALK_KMH / 60)))) : 1;
};
const stayRange = s => {
const [y1, m1, d1] = String(s.checkIn).split('-').map(Number);
const [y2, m2, d2] = String(s.checkOut).split('-').map(Number);
return m1 === m2 ? `${d1}–${d2} ${MONTHS[m1 - 1]}` : `${d1} ${MONTHS[m1 - 1]}–${d2} ${MONTHS[m2 - 1]}`;
};
function renderHotelMks() {
hotelMks.forEach(m => map.removeLayer(m)); hotelMks = [];
(stays || []).forEach(o => {
const booked = o.state === 'booked';
hotelMks.push(L.marker(o.at, {
icon: L.divIcon({ className: 'hotel-ico', html: `<div class="hotel-pin${booked ? '' : ' hotel-alt'}">🏨</div>`, iconSize: [0, 0] })
}).addTo(map).bindTooltip(`${o.name} · ${stayRange(o)} — ${booked ? 'booked, anchors these nights' : 'open option (worst-case anchor)'}`));
});
}
let hotelMks = [], trainMks = [];
function initMap() {
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
}).addTo(map);
renderHotelMks();
// booked trains: origin + destination markers only, no route line
M.trip.bookings.filter(b => b.type === 'train').forEach(b => {
const mk = (pt, label) => L.marker(pt, {
icon: L.divIcon({ className: 'hotel-ico', html: '<div class="hotel-pin">🚄</div>', iconSize: [0, 0] })
}).addTo(map).bindTooltip(label);
trainMks.push(mk(b.stations.from, `${b.name} · dep ${b.depart} · ${b.from}`));
trainMks.push(mk(b.stations.to, `${b.name} · arr ${b.arrive} · ${b.to}`));
});
}
function flashMarker(mk, on) {
const e = mk.getElement()?.querySelector('.hotel-pin');
if (e) e.style.transform = on ? 'translate(-50%,-90%) scale(1.35)' : 'translate(-50%,-90%)';
if (on) mk.openTooltip(); else mk.closeTooltip();
if (on && !map.getBounds().contains(mk.getLatLng())) map.flyTo(mk.getLatLng(), 15, { duration: .5 });
}
const CONF_CLS = { 5: 't-user', 4: 't-sched', 3: 't-computed', 2: 't-search', 1: 't-llm' };
function tchip(mins, conf, src) {
const e = el('span', 'tc ' + CONF_CLS[conf], fmt(mins) + ' min');
if (src) e.title = 'source: ' + src;
return e;
}
const MODE_ICO = { foot: '🚶', tram: '🚊', train: '🚄', car: '🚗', bus: '🚌', fly: '✈️' };
// ---------------- layout / legs ----------------
// every day ends by returning to the hotel anchor
// The last-stop→hotel return ride as a first-class leg (like the arrival drop-off):
// one object per day that the route card can edit — drag the drop-off point, toggle
// walk vs taxi. A previously auto-routed return (retRouted) seeds it so the card opens
// on the real geometry. Multi-stay days (or a region/transit last stop) have no single
// drop-off to edit, so no leg.
function getReturnLeg(d = day) {
if (d.isDeparture || !d.stops.length || staysForDay(d).length !== 1) return null;
const last = d.stops[d.stops.length - 1];
if (last.kind === 'region' || last.kind === 'transit') return null;
const h = dayBase(d) && dayBase(d).at;
if (!h) return null;
if (!d.retLeg) {
const mode = d.retMode || travelMode;
d.retLeg = d.retRouted
? { mode, dur: d.retRouted.durMin, conf: 3, geometry: d.retRouted.geometry }
: { mode, dur: estLegMin(mode, last.at, h), conf: 1 };
delete d.retMode;
}
return d.retLeg;
}
// Hotel → first stop: the OUT-leg of a day, kept as a first-class leg object exactly
// like getReturnLeg (the in-leg), so it has its own mode, duration and route, renders
// as the same leg-row, and is click-to-edit in the same L3 card. null = there is no
// leg to render (no single base for the day, region/transit first stop).
// The hotel → first-stop leg only makes sense when the day actually STARTS at the
// base hotel. A travel day (city change) starts at the PREVIOUS city’s hotel — e.g.
// “Breakfast & check-out” in Cartagena before the flight to Santa Marta, whose stay
// is the Santa Marta hotel — so a hotel→first-stop leg from the new hotel would be a
// 500 km nonsense line. First stops beyond the city radius ⇒ the day starts elsewhere.
const LEG_CITY_KM = 25;
function firstLegApplies(d = day) {
if (d.isArrival && !d.transferIn) return false; // nothing to leave from before the arrival drop-off
if (!d.stops.length || staysForDay(d).length !== 1) return false;
const first = d.stops[0];
if (first.kind === 'region' || first.kind === 'transit') return false;
const b = dayBase(d);
return !!(b && b.at && first.at && haversine(b.at, first.at) <= LEG_CITY_KM * 1000);
}
function getFirstLeg(d = day) {
if (!firstLegApplies(d)) { delete d.firstLeg; return null; } // self-heal a stale leg from an earlier layout
const first = d.stops[0], b = dayBase(d);
if (!d.firstLeg) {
const mode = d.firstMode || travelMode;
d.firstLeg = { mode, dur: estLegMin(mode, b.at, first.at), conf: 1 };
delete d.firstMode;
}
return d.firstLeg;
}
const stopAtHotel = (d = day) => firstLegApplies(d) && haversine(dayBase(d).at, d.stops[0].at) < 120;
function firstLegDur(d = day) {
const leg = getFirstLeg(d);
return (leg && !stopAtHotel(d)) ? leg.dur : 0;
}
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;
// the return ride is an editable leg when a single stay anchors the day
const leg = d.retLeg;
if (leg && staysForDay(d).length === 1 && last.kind !== 'transit' && last.kind !== 'region') return leg.dur;
const list = staysForDay(d);
// worst case across every candidate stay for these nights (equals the real leg when one)
return Math.max(1, Math.ceil(Math.max(...list.map(o => haversine(last.at, o.at))) / 1000 / (legSpeedKmh(travelMode) / 60)));
}
function layout(d = day) {
// Arrival day: startMin is the time you reach the HOTEL (landing + transfer); the
// hotel → first-stop leg is added on top. Other days: startMin is the time you are
// AT the first stop — the hotel leg happens before it (shown as the "depart HH:MM"
// hotel node), so it never shifts the stop times.
let t = d.startMin + (d.isArrival ? firstLegDur(d) : 0);
d.stops.forEach((s, i) => { s.start = t; t += s.dur; if (d.legs[i]) t += d.legs[i].dur; });
return t + returnDur(d);
}
function rebuildLegs(d = day, enrich = true) {
const prevModes = (d.legs || []).map(g => (g && (g.mode === 'foot' || g.mode === 'car')) ? g.mode : null); // keep each leg's walk/taxi choice across rebuilds
const pm = i => prevModes[i] || travelMode;
d.legs = [];
const prevRet = d.retLeg && (d.retLeg.mode === 'foot' || d.retLeg.mode === 'car') ? d.retLeg.mode : null; // keep the return ride's choice too
delete d.retRouted;
delete d.retLeg;
if (prevRet) d.retMode = prevRet;
const prevFirst = d.firstLeg && (d.firstLeg.mode === 'foot' || d.firstLeg.mode === 'car') ? d.firstLeg.mode : null; // and the out-leg's choice
delete d.firstLeg;
if (prevFirst) d.firstMode = prevFirst;
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: pm(i), dur: pm(i) === 'car' ? 9 : 17, conf: 1, vague: true });
} else {
// legs touching a transit stop: fixed duration (flight + taxi/bus),
// optionally broken down via M.multimodal entries
if (a.kind === 'transit' || b.kind === 'transit') {
// ground transfer INTO the departure flight (hotel → airport) — always by car
if (b.kind === 'transit' && d.isDeparture && d.transferOut) {
d.legs.push({ mode: 'car', dur: d.transferOut.dur, conf: 2, toAirport: true });
continue;
}
const mm = (M.multimodal || []).find(x => x.into === b.id)
|| (M.multimodal || []).find(x => x.into === a.id);
d.legs.push(mm
? { mode: 'multimodal', dur: mm.dur, conf: 3, sub: mm.sub }
: { mode: 'fly', dur: 30, conf: 3, sub: [{ mode: 'fly', dur: 30 }] });
continue;
}
const mm = (M.multimodal || []).find(x => x.into === b.id);
if (mm) {
d.legs.push({ mode: 'multimodal', dur: mm.dur, conf: 3, sub: mm.sub });
} else {
// straight-line estimate at the current travel mode's speed, until the
// router upgrades it (or proves out of coverage)
d.legs.push({ mode: pm(i), dur: estLegMin(pm(i), a.at, b.at), conf: 1 });
}
}
}
if (enrich) enrichLegs(d);
}
// ---------------- vague / region placeholder stops ----------------
const REGION_MAP = {
oltrarno: { label: 'Oltrarno', at: [43.7606, 11.2485], r: 520 },
smn: { label: 'near SMN', at: [43.7731, 11.2563], r: 620 },
duomo: { label: 'around the Duomo', at: [43.7731, 11.2556], r: 620 },
};
let nextStopId = 30;
function addRegionStop(slot, region) {
const existing = day.stops.find(x => x.slot === slot && x.state === 'planned');
const stop = { id: 'S' + (nextStopId++),
name: `${slot[0].toUpperCase() + slot.slice(1)} — somewhere ${region.label}`,
kind: 'region', mealKind: slot, slot, state: 'planned',
at: region.at, region: { ...region },
dur: slot === 'breakfast' ? 30 : 90, durConf: 1,
suggested: { value: slot === 'breakfast' ? 30 : 90, conf: 1, src: null } };
if (existing) { existing.state = 'alt'; day.stops[day.stops.indexOf(existing)] = stop; }
else day.stops.push(stop);
rebuildLegs(); renderAll([stop.id]);
ai(`Placed a <b>placeholder</b> for ${slot} ${region.label} — it holds the slot and reserves time; travel times are <b>worst-case</b> until you pick a specific place. Open the ${slot} drawer or tap the card to see options.`, 900);
}
// ---------------- map ----------------
function renderMap() {
Object.values(markers).forEach(m => map.removeLayer(m)); markers = {};
Object.values(legEls).forEach(l => map.removeLayer(l)); legEls = {};
otherLayers.forEach(l => map.removeLayer(l)); otherLayers = [];
retLine = null; retLines = []; arrLine = null; arrLines = []; firstLine = null; firstLines = [];
// every day, faintly — the whole trip is always visible
M.days.forEach(d => {
const obj = days[d.id];
if (!obj.stops.length) return;
if (d.id !== curDay) {
const pts = obj.stops.map(s => s.at);
for (let i = 0; i < pts.length - 1; i++)
otherLayers.push(L.polyline([pts[i], pts[i + 1]], { color: '#b6bcc8', weight: 3, opacity: .8, dashArray: '4 7' }).addTo(map));
if (pts.length && !obj.isDeparture)
staysForDay(obj).forEach(o =>
otherLayers.push(L.polyline([pts[pts.length - 1], o.at], { color: '#c8cdd6', weight: 2.5, opacity: .8, dashArray: '2 6' }).addTo(map)));
obj.stops.forEach(s => otherLayers.push(L.circleMarker(s.at, { radius: 5, color: '#9aa3b2', weight: 2, fillColor: '#fff', fillOpacity: 1 })
.addTo(map).bindTooltip(`${d.label} · ${s.name}`)));
return;
}
const pts = obj.stops.map(s => s.at);
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' : obj.legs[i]?.mode === 'fly' ? '#8a93a6' : '#2e9e5b',
weight: 5, opacity: .85,
dashArray: obj.legs[i]?.mode === 'fly' ? '6 6' : undefined
}).addTo(map);
}
if (pts.length && !obj.isDeparture) { // return leg to the hotel (hoverable via its rail row)
staysForDay(obj).forEach(o => {
const g = (obj.retLeg && staysForDay(obj).length === 1 && obj.retLeg.geometry) ? obj.retLeg.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);
});
}
const flg = getFirstLeg(obj); // hotel → first stop: drawn like the other legs, coloured by mode
if (pts.length && !obj.isDeparture && flg && !stopAtHotel(obj)) {
const hb = dayBase(obj);
if (hb && hb.at) {
const g = flg.geometry || [hb.at, pts[0]];
firstLine = L.polyline(g, { color: flg.mode === 'foot' ? '#5a7d5a' : '#274b8f', weight: 4, opacity: .85, dashArray: '5 5' }).addTo(map);
otherLayers.push(firstLine);
firstLines.push(firstLine);
}
}
if (obj.isArrival && obj.transferIn && obj.transferIn.at && obj.transferIn.hotelAt) { // airport → hotel drop-off: a first-class leg, drawn from its own routed geometry
const t = obj.transferIn;
const pts = t.geometry || [t.at, t.hotelAt];
arrLine = L.polyline(pts, { color: t.mode === 'foot' ? '#5a7d5a' : '#274b8f', weight: 5, opacity: .85, dashArray: '6 4' }).addTo(map);
otherLayers.push(arrLine);
arrLines.push(arrLine);
}
obj.stops.forEach((s, i) => {
const dim = s.state !== 'planned';
markers[s.id] = L.marker(s.at, {
icon: L.divIcon({ className: '', html: dim
? `<div class="mk mkd">${i + 1}</div>`
: `<div class="mk">${i + 1}</div>`,
iconSize: [26, 26], iconAnchor: [13, 13] })
}).addTo(map).bindTooltip(`${s.name} · ${fmt(s.start)}${s.state !== 'planned' ? ' · ' + stateLabel(s.state) : ''}`);
if (s.region) // vague stop: show the search area, not a point
otherLayers.push(L.circle(s.region.at, { radius: s.region.r, color: '#b5533c', weight: 2, dashArray: '6 6', fillColor: '#b5533c', fillOpacity: .08 }).addTo(map));
});
});
const cur = days[curDay];
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] };
// day scope: only the hotels anchoring THIS day (a multi-city trip has several)
const stayPts = scope === 'trip' ? stays.map(o => o.at) : staysForDay().map(o => o.at);
map.fitBounds(L.latLngBounds([dayBase().at, ...stayPts, ...pts]), pad);
}
}
// Composite the map viewport exactly as the user sees it (tiles + current
// day's route + numbered pins) into a PNG data-URL. Feeds the screenshot_map
// tool so the multimodal model can SEE layout problems (pin in the bay, line
// crossing water) instead of the user having to describe them.
function mapScreenshot() {
const cont = map.getContainer();
const cr = cont.getBoundingClientRect();
if (!cr.width || !cr.height) return null;
const scale = Math.min(1, 1024 / cr.width); // cap tokens: ≤1024px wide
const W = Math.round(cr.width * scale), H = Math.round(cr.height * scale);
const cv = document.createElement('canvas'); cv.width = W; cv.height = H;
const cx = cv.getContext('2d');
cx.fillStyle = '#dfe3ea'; cx.fillRect(0, 0, W, H); // unloaded-tile background
try {
// 1) the raster tiles currently in the DOM (same-origin → canvas untainted)
cont.querySelectorAll('img.leaflet-tile').forEach(img => {
const r = img.getBoundingClientRect();
cx.drawImage(img, (r.left - cr.left) * scale, (r.top - cr.top) * scale, r.width * scale, r.height * scale);
});
// 2) current day's route legs — real routed geometry when available
const cur = days[curDay];
const pts = cur.stops.map(s => s.at);
cx.lineCap = 'round'; cx.lineJoin = 'round';
for (let i = 0; i < pts.length - 1; i++) {
const g = cur.legs[i]?.geometry || [pts[i], pts[i + 1]];
cx.beginPath();
g.forEach((p, k) => { const q = map.latLngToContainerPoint(p).multiplyBy(scale); k ? cx.lineTo(q.x, q.y) : cx.moveTo(q.x, q.y); });
cx.strokeStyle = 'rgba(46,158,91,.9)'; cx.lineWidth = 4 * scale; cx.stroke();
}
// 3) numbered pins — same order and numbering as the on-screen markers
const pin = (p, label, bg) => {
const q = map.latLngToContainerPoint(p).multiplyBy(scale);
cx.beginPath(); cx.arc(q.x, q.y, 11 * scale, 0, Math.PI * 2);
cx.fillStyle = bg; cx.fill();
cx.lineWidth = 2 * scale; cx.strokeStyle = '#fff'; cx.stroke();
cx.fillStyle = '#fff'; cx.font = `bold ${Math.round(12 * scale)}px sans-serif`;
cx.textAlign = 'center'; cx.textBaseline = 'middle';
cx.fillText(label, q.x, q.y + 0.5 * scale);
};
cur.stops.forEach((s, i) => pin(s.at, String(i + 1), s.state === 'planned' ? '#b5533c' : '#9aa3b2'));
staysForDay().forEach(o => pin(o.at, 'H', '#2f6fd6'));
// 4) legend so the model knows what it is looking at
cx.fillStyle = 'rgba(255,255,255,.93)';
cx.fillRect(8 * scale, 8 * scale, 300 * scale, 38 * scale);
cx.fillStyle = '#1c2230'; cx.textAlign = 'left'; cx.textBaseline = 'alphabetic';
cx.font = `bold ${Math.round(13 * scale)}px sans-serif`;
cx.fillText(`${cur.label} · ${cur.date} · zoom ${map.getZoom().toFixed(1)}`, 14 * scale, 24 * scale);
cx.font = `${Math.round(11 * scale)}px sans-serif`;
cx.fillText('pins = day stops in order · H = hotel · green = route', 14 * scale, 40 * scale);
return cv.toDataURL('image/png');
} catch (e) { console.warn('[screenshot]', e); return null; }
}
function highlightStop(id, on) {
const m = markers[id]; if (!m) return;
m.getElement()?.style?.setProperty('transform', on ? 'scale(1.25)' : '');
if (on) m.openTooltip(); else m.closeTooltip();
}
// pulse in place; only reposition the map if the point is out of view
function focusPoint(latlng, zoom = 16) {
if (!map.getBounds().contains(latlng)) map.flyTo(latlng, Math.max(map.getZoom(), zoom), { duration: .5 });
const ring = L.circleMarker(latlng, { radius: 16, color: '#b5533c', weight: 3, fill: false, opacity: .9 }).addTo(map);
setTimeout(() => map.removeLayer(ring), 1500);
}
// ---------------- spatial tool pins ----------------
// poi_near / poi_search results land here as pins (one layer group, so a
// new search replaces the old set). Clicking a pin pops its facts card.
let pinLayer = null;
function showSpatialPins(points, title) {
if (!map) return;
if (!pinLayer) { pinLayer = L.layerGroup().addTo(map); }
pinLayer.clearLayers();
const pts = (points || []).filter(p => isFinite(p.lat) && isFinite(p.lng)).slice(0, 50);
if (!pts.length) return;
pts.forEach(p => {
const mk = L.marker([p.lat, p.lng], { icon: L.divIcon({ className: 'sp-pin', html: '<div class="sp-dot"></div>', iconSize: [12, 12], iconAnchor: [6, 6] }) });
const f = [];
if (isFinite(p.dist_m)) f.push('📍 ' + (p.dist_m >= 1000 ? (p.dist_m / 1000).toFixed(1) + ' km' : p.dist_m + ' m') + ' away');
if (isFinite(p.score)) f.push('match ' + (100 * p.score).toFixed(0) + '%');
if (p.opening_hours) f.push('🕑 ' + p.opening_hours);
if (p.fee) f.push('💶 fee: ' + p.fee);
if (p.website) f.push('🔗 ' + p.website);
mk.bindPopup('<div class="sp-card"><b>' + esc(p.name) + '</b><span class="sp-kind">' + esc(p.kind || '') + '</span>' +
f.map(x => '<div>' + esc(x) + '</div>').join('') + '</div>');
pinLayer.addLayer(mk);
});
map.fitBounds(L.latLngBounds(pts.map(p => [p.lat, p.lng])).pad(0.3), { maxZoom: 16 });
}
const flyTo = s => focusPoint(s.at);
function tempShow(s) {
if (stopById(s.id)) return;
const m = L.circleMarker(s.at, { radius: 8, color: '#5b6472', weight: 2, fillColor: '#8b93a3', fillOpacity: .6 })
.addTo(map).bindTooltip(s.name);
tempMarkers.push(m);
m.openTooltip();
if (!map.getBounds().contains(s.at)) map.flyTo(s.at, Math.max(map.getZoom(), 15), { duration: .4 });
}
function tempClear() { tempMarkers.forEach(m => map.removeLayer(m)); tempMarkers = []; }
// ---------------- day strip (multi-scale L1 overview) ----------------
function renderStrip() {
const p = $('#daystrip'); if (!p) return;
p.innerHTML = '';
layout();
const t0 = Math.floor(day.startMin / 60) * 60;
const t1 = Math.max(layout() + 15, day.startMin + 360);
const span = t1 - t0;
const pos = t => (t - t0) / span * 100;
const track = el('div', 'strip-track');
const block = (t, dur, cls, label, tip, onClick) => {
const b = el('div', 'sb ' + cls, label);
b.style.left = pos(t) + '%';
b.style.width = Math.max(1.4, dur / span * 100) + '%';
if (tip) b.title = tip;
if (onClick) b.onclick = onClick;
track.append(b);
};
let cur = t0;
const pre = firstLegDur(); // hotel → first stop
if (day.isArrival) {
if (cur < day.startMin) block(cur, day.startMin - cur, 'gap', '', 'landing + drop-off');
cur = day.startMin;
if (pre) { block(cur, pre, 'leg', '', `to first stop · ${pre} min`); cur += pre; }
} else {
const leaveAt = day.startMin - pre; // the hotel leg is BEFORE startMin
if (leaveAt <= t0) {
if (cur < day.startMin) block(cur, day.startMin - cur, 'gap', '', pre ? `morning free + ${pre}′ from hotel` : 'morning free');
cur = day.startMin;
} else {
if (cur < leaveAt) block(cur, leaveAt - cur, 'gap', '', 'morning free');
if (pre) block(leaveAt, pre, 'leg', '', `leave hotel · ${pre} min`);
cur = day.startMin;
}
}
day.stops.forEach((s, i) => {
if (cur < s.start) block(cur, s.start - cur, 'gap', '', 'free time');
const kindCls = s.kind === 'meal' ? 'meal' : s.kind === 'transit' ? 'transit' : s.kind === 'region' ? 'region' : 'visit';
block(s.start, s.dur, kindCls + (s.state !== 'planned' ? ' dimmed' : ''), String(i + 1),
`${s.name} · ${fmt(s.start)}–${fmt(s.start + s.dur)}${s.state !== 'planned' ? ' · ' + stateLabel(s.state) : ''}`,
() => flyTo(s));
cur = s.start + s.dur;
if (day.legs[i]) { block(cur, day.legs[i].dur, 'leg', '', `leg · ${day.legs[i].dur} min`); cur += day.legs[i].dur; }
});
const ret = returnDur();
if (ret) block(cur, ret, 'hotel', '🏨', 'back to the hotel');
p.append(track);
const axis = el('div', 'strip-axis');
for (let t = t0 + 60; t < t1; t += 60) {
const h = el('span', 'tick', String(Math.floor(t / 60)).padStart(2, '0'));
h.style.left = pos(t) + '%';
axis.append(h);
}
p.append(axis);
}
// ---------------- discovery (a drawer, not a mode) ----------------
const CATS = [
{ id: 'lunch', label: 'Lunch' },
{ id: 'dinner', label: 'Dinner' },
{ id: 'visit', label: 'Afternoon' },
{ id: 'hotel', label: 'Hotel' },
];
const excluded = new Set(); // the exclusion list — survives across searches
let disc = null; // {cat, region, results}
let candMks = {};
const isExcluded = c => [...excluded].some(x => c.name.toLowerCase().includes(x) || x.includes(c.name.toLowerCase()));
// where the user is when they make a choice: the stop right before the slot,
// else the last planned stop, else the hotel
function anchorStop(slot) {
const idx = slot ? day.stops.findIndex(x => x.slot === slot) : -1;
if (idx > 0) return day.stops[idx - 1];
if (idx === 0) return { name: dayBase().name, at: dayBase().at };
const planned = day.stops.filter(x => x.state === 'planned');
if (planned.length) return planned[planned.length - 1];
return { name: dayBase().name, at: dayBase().at };
}
function poolFor(cat, region) {
let pool = (M.candidates[cat] || []).filter(c => !isExcluded(c));
if (region) pool = pool.filter(c => haversine(c.at, region.at) <= region.r);
return pool;
}
function openDiscover(cat, region = null) {
if (!M.candidates[cat]?.length) { ai(`Nothing to browse for <b>${cat}</b> here yet — try “lunch”, “afternoon” or “hotel”.`, 500); return; }
disc = { cat, region: region || null, results: poolFor(cat, region) };
$('#discover').classList.remove('hidden');
renderDiscover();
renderMap();
}
function closeDiscover(note) {
if (!disc) return;
Object.values(candMks).forEach(m => map.removeLayer(m)); candMks = {};
disc = null;
$('#discover').classList.add('hidden');
renderMap();
if (note) ai(note, 400);
}
function renderDiscover() {
const chipRow = $('#disc-chips'); chipRow.innerHTML = '';
CATS.forEach(c => {
const b = el('button', 'dchip' + (disc.cat === c.id ? ' on' : ''), c.label);
b.onclick = () => { disc = { cat: c.id, region: disc.region, results: poolFor(c.id, disc.region) }; renderDiscover(); renderMap(); };
chipRow.append(b);
});
const catLabel = CATS.find(c => c.id === disc.cat).label;
$('#disc-title').innerHTML = `<b>${catLabel}</b>${disc.region ? ' · ' + disc.region.label : ' · ' + dayLabel()} — ${disc.results.length} option${disc.results.length !== 1 ? 's' : ''}`;
// numbered pins on the map
Object.values(candMks).forEach(m => map.removeLayer(m)); candMks = {};
const anchor = anchorStop(disc.cat);
const isHotel = disc.cat === 'hotel';
const list = $('#disc-list'); list.innerHTML = '';
disc.results.forEach((c, i) => {
const card = candCard(c, anchor, isHotel);
list.append(card);
const mk = L.marker(c.at, {
icon: L.divIcon({ className: '', html: `<div class="candpin">${i + 1}</div>`, iconSize: [24, 32], iconAnchor: [12, 30] })
}).addTo(map).bindTooltip(c.name);
mk.on('click', () => {
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
card.classList.add('flash'); setTimeout(() => card.classList.remove('flash'), 1200);
focusPoint(c.at);
});
candMks[c.id] = mk;
});
if (!disc.results.length)
list.append(el('div', 'disc-empty', M.candidates[disc.cat]?.length
? 'Nothing left — loosen a filter in chat, or “reset”.'
: 'No options in this pool yet — try another category.'));
}
function findCand(id) {
for (const [slot, pool] of Object.entries(M.candidates)) {
const c = pool.find(x => x.id === id);
if (c) return { c, slot };
}
return null;
}
function candCard(c, anchor, isHotel) {
const card = el('div', 'candcard');
card.draggable = true;
card.addEventListener('dragstart', e => { dragCand = c; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', 'cand:' + c.id); });
card.addEventListener('dragend', () => { dragCand = null; document.querySelectorAll('.candcard.dragging, .drop-target').forEach(x => x.classList.remove('dragging', 'drop-target')); });
const head = el('div', 'cc-head');
if (c.img) {
const im = el('div', 'cc-img'); im.style.background = c.img[2];
if (c.img[3]) { const i = document.createElement('img'); i.src = c.img[3]; i.alt = c.img[1]; im.append(i); }
else im.append(el('span', 'cc-emoji', c.img[0]));
head.append(im);
}
head.append(el('div', 'cc-name', c.name));
card.append(head);
if (c.pitch) card.append(el('div', 'cc-pitch', c.pitch));
const chips = el('div', 'cc-chips');
chips.append(el('span', 'pchip', `↦ ${walkMin(anchor.at, c.at)} min from ${anchor.name}`));
if (anchor.name !== dayBase().name) chips.append(el('span', 'pchip', `→ ${hotelWalkMin(c.at)} min to hotel${staysForDay().length > 1 ? ' (worst)' : ''}`));
if (c.dur) chips.append(el('span', 'tc t-search', '~' + fmt(c.dur)));
chips.append(el('span', 'pchip', c.price ? '~' + c.price + '€ / ' + (isHotel ? 'night' : 'person') : 'free'));
card.append(chips);
if (c.tags?.length) card.append(el('div', 'cc-tags', c.tags.map(t => '#' + t).join(' ')));
card.append(webEl(c, isHotel ? 'hotel' : disc.cat)); // live web facts + provenance (background)
enrichWeb(c, isHotel ? 'hotel' : disc.cat);
const acts = el('div', 'cc-acts');
const pick = el('button', 'btn primary small', isHotel ? 'Make this the hotel' : 'Choose this');
pick.onclick = () => chooseCand(c);
const idea = el('button', 'btn ghost small', 'Idea');
idea.onclick = () => holdIdea(c);
const cmp = el('button', 'btn ghost small', '⚖ Compare');
cmp.onclick = () => toggleCompare(itemFromCand(c, isHotel ? 'hotel' : disc.cat));
const no = el('button', 'cc-no', 'Not for me');
no.title = 'remember as a preference';
no.onclick = () => {
excluded.add(c.name.toLowerCase().includes('—') ? c.name.split('—').pop().trim() : c.name);
disc.results.splice(disc.results.indexOf(c), 1);
renderDiscover(); renderMap();
};
acts.append(pick, idea, cmp, no);
card.append(acts);
return card;
}
function makeStop(c, slot, state) {
return { id: c.id, name: c.name, kind: slot === 'visit' ? 'visit' : 'meal',
mealKind: slot === 'visit' ? undefined : slot, slot, state,
at: c.at, dur: c.dur, durConf: 3,
suggested: { value: c.dur, conf: 2, src: 'tripadvisor.com' },
price: c.price ? { amount: c.price, cur: '€', conf: 2, src: 'tripadvisor.com' } : null,
img: c.img, url: c.url };
}
// one slot, one plan: promote the candidate, demote the incumbent to backup
function promoteCand(c, slot, state = 'planned', flash = true) {
const existing = day.stops.find(x => x.id === c.id);
const stop = existing || makeStop(c, slot, state);
let incumbent = null;
if (state === 'planned') {
incumbent = day.stops.find(x => x.slot === slot && x.state === 'planned' && x !== stop) || null;
if (incumbent) incumbent.state = 'alt';
}
stop.state = state;
if (!existing) {
// take the incumbent’s sequence position so the day’s order stays sensible
const at = incumbent ? day.stops.indexOf(incumbent) : -1;
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 };
}
function chooseCand(c, slot) {
slot = slot || disc?.cat;
if (slot === 'hotel') {
const cur = dayBase();
const inRange = s => s.checkIn <= cur.checkOut && s.checkOut >= cur.checkIn; // same nights
stays = stays.filter(s => !inRange(s));
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(); renderModeToggle();
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);
return;
}
const loose = day.stops.find(x => x.slot === slot && x.kind === 'region');
if (loose) {
day.stops[day.stops.indexOf(loose)] = makeStop(c, slot, 'planned');
rebuildLegs(); closeDiscover(); renderAll([c.id]);
ai(`Refined: <b>${c.name}</b> is your ${slot} now — the placeholder is gone and the leg times are real.`, 600);
return;
}
const { incumbent } = promoteCand(c, slot);
closeDiscover();
ai(`Locked in <b>${c.name}</b> for ${dayLabel()}${incumbent ? ` — <b>${incumbent.name}</b> becomes the backup: one ${slot}, one plan, swap any time.` : '. See it in the day.'}`, 600);
}
function holdIdea(c, slot) {
slot = slot || disc?.cat;
if (slot === 'hotel') {
if (stays.some(o => o.name === c.name)) { closeDiscover(); return; }
const cur = dayBase();
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(); renderModeToggle();
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);
return;
}
const loose = day.stops.find(x => x.slot === slot && x.kind === 'region');
if (loose) {
const stop = makeStop(c, slot, 'maybe');
day.stops[day.stops.indexOf(loose)] = stop;
rebuildLegs(); closeDiscover(); renderAll([stop.id]);
ai(`Holding <b>${c.name}</b> as an idea — the placeholder became a specific option, off the time budget.`, 600);
return;
}
if (day.stops.some(x => x.id === c.id)) { closeDiscover(); return; }
day.stops.push(makeStop(c, slot, 'maybe'));
rebuildLegs();
closeDiscover();
renderAll([c.id]);
ai(`Holding <b>${c.name}</b> as an idea — dashed on the day, not counted against the time budget.`, 500);
}
// chat still filters an open drawer: "cheaper", "near Boboli", "no Mario"
function drawerFilter(text) {
const t = text.toLowerCase();
let res = [...disc.results];
const notes = [];
if (/cheaper|cheap|less|budget|under/.test(t)) {
const m = t.match(/under\s*€?(\d+)/);
const cap = m ? +m[1] : (disc.cat === 'hotel' ? 220 : 25); // “cheaper” means different things per slot
res = res.filter(c => c.price <= cap);
notes.push(`≤ €${cap}`);
}
if (/boboli/.test(t)) { res = res.filter(c => haversine(c.at, [43.7579, 11.2463]) < 1200); notes.push('near Boboli'); }
if (/pitti|accademia/.test(t)) { res = res.filter(c => haversine(c.at, [43.7639, 11.2518]) < 900); notes.push('near the sights you have planned'); }
if (/views|terrace|rooftop/.test(t)) { res = res.filter(c => c.tags.some(x => /view|terrace/.test(x))); notes.push('views'); }
if (/veg/.test(t)) { res = res.filter(c => c.tags.some(x => /veg|garden/.test(x))); notes.push('vegetarian-friendly'); }
if (/quiet|calm|cozy|cellar/.test(t)) { res = res.filter(c => c.tags.some(x => /quiet|calm|cozy|cellar/.test(x))); notes.push('quieter'); }
if (/free/.test(t)) { res = res.filter(c => !c.price); notes.push('free'); }
if (/fast|quick|casual/.test(t)) { res = res.filter(c => c.tags.some(x => /fast|casual/.test(x))); notes.push('fast & casual'); }
const noM = text.match(/\b(?:no|not|without|skip)\s+([a-zàé'. ]{3,30})/i);
if (noM) {
const nm = noM[1].trim();
const hit = disc.results.find(c => c.name.toLowerCase().includes(nm));
if (hit) { excluded.add(hit.name); res = res.filter(c => c !== hit); notes.push(`excluded ${hit.name}`); }
}
disc.results = res;
renderDiscover();
if (res.length) {
const a = anchorStop(disc.cat);
const closest = res.reduce((m, c) => walkMin(a.at, c.at) < walkMin(a.at, m.at) ? c : m, res[0]);
ai(`${res.length} left${notes.length ? ' — ' + notes.join(', ') : ''}. Closest to ${a.name}: <b>${closest.name}</b> (${walkMin(a.at, closest.at)} min walk). Keep filtering or tap a card.`, 600);
} else ai(`Nothing left${notes.length ? ' after ' + notes.join(', ') : ''} — loosen a filter, or say “reset” to start from the full list.`, 600);
}
$('#disc-x').onclick = () => closeDiscover('Back to the plan.');
document.addEventListener('keydown', e => { if (e.key === 'Escape') { if (disc) closeDiscover(); else closeL3(); } });
// ---------------- rail ----------------
function renderRail(flashIds = []) {
layout(); // assign start times before the cards render them
const body = $('#rail-body'); body.innerHTML = '';
// every day lives between two hotel nodes: you LEAVE the hotel (or land and drop
// bags on the travel day), move to the first stop, and come back at the end. Each
// movement is a leg row — identical in style and behaviour to the stop-to-stop ones.
if (day.isArrival && day.arrivalFlight) {
renderArrivalFlightCard(body); // ✈️ landed
const t = day.transferIn;
if (t && t.at && t.hotelAt)
renderGroundLegRow(body, {
leg: t, ico: MODE_ICO[t.mode] || '🚗',
label: `airport → ${t.hotelName || (dayBase() ? dayBase().name : 'hotel')}`,
hover: arrLines, color: t.mode === 'foot' ? '#5a7d5a' : '#274b8f',
title: 'Click to edit — drag the drop-off, walk or taxi', onClick: openArrivalEditor,
});
renderHotelNode(body, 'arrive'); // 🏨 arrive HH:MM · drop bags
} else if (firstLegApplies()) { // travel days (city change) start elsewhere — no node
renderHotelNode(body, 'depart'); // 🏨 depart HH:MM
}
const fl = getFirstLeg();
if (fl && day.stops.length && !stopAtHotel())
renderGroundLegRow(body, {
leg: fl, ico: MODE_ICO[fl.mode] || '🚶',
label: `${dayBase() ? dayBase().name : 'hotel'} → ${day.stops[0].name}`,
hover: firstLines, color: fl.mode === 'foot' ? '#5a7d5a' : '#274b8f',
title: 'Click to edit — drag the route, walk or taxi', onClick: openFirstEditor,
});
day.stops.forEach((s, i) => renderStopCard(body, s, i, flashIds));
// every day but the departure closes back at the hotel — the ride back, then
// the hotel card for the night, in the timeline where it belongs
if (day.stops.length && !day.isDeparture) {
renderReturnRow(body);
renderHotelCard(body);
}
renderStrip(); renderBudget(); renderNudges(); renderSugg();
}
// arrival-day flight card. The airport → hotel drop-off is NOT embedded here: it is a
// stand-alone first-class leg row (renderGroundLegRow), exactly like every other leg.
function renderArrivalFlightCard(body) {
const f = day.arrivalFlight; if (!f) return;
const c = el('div', 'stop-card struct-card arr-card');
const top = el('div', 'sc-top');
top.append(el('span', 'leg-ico', '✈️'));
top.append(el('span', 'sc-name', 'Landed — ' + (f.code || f.to || 'airport')));
top.append(el('span', 'sc-times', 'arr ' + f.time));
c.append(top);
const meta = el('div', 'sc-meta');
meta.append(el('span', 'tc t-sched', 'booked'));
if (f.flight) meta.append(el('span', 'pchip', f.flight));
if (f.from) meta.append(el('span', 'leg-sub', 'from ' + f.from));
c.append(meta);
if (f.at) c.addEventListener('click', () => focusPoint(f.at));
body.append(c);
}
// every ground leg in the timeline renders through this one function — the arrival
// drop-off, hotel → first stop, and the return ride. Same row, same chip, same click
// → the same route card (drag the route, walk/taxi toggle).
function renderGroundLegRow(body, o) {
const leg = o.leg;
const lr = el('div', 'leg-row' + (o.dim ? ' dim' : ''));
lr.append(el('span', 'leg-ico', o.ico));
lr.append(document.createTextNode(o.label + ' '));
lr.append(tchip(leg.dur || 0, leg.conf || 2));
if (o.title) lr.title = o.title;
const on = { weight: 7, opacity: 1, color: o.color, dashArray: null };
const off = { color: o.color, weight: 5, opacity: .85, dashArray: '5 5' };
if (o.hover) {
lr.addEventListener('mouseenter', () => o.hover.forEach(l => l.setStyle(on)));
lr.addEventListener('mouseleave', () => o.hover.forEach(l => l.setStyle(off)));
}
lr.addEventListener('click', o.onClick);
body.append(lr);
return lr;
}
// the hotel as a timeline NODE — "arrive HH:MM · drop bags" on the arrival day, "depart
// HH:MM" on every other day (startMin minus the hotel → first-stop leg). Same card
// family as the closing "tonight" card.
function renderHotelNode(body, phase) {
const b = dayBase(); if (!b) return;
const c = el('div', 'stop-card struct-card hotel-card');
const top = el('div', 'sc-top');
top.append(el('span', 'leg-ico', '🏨'));
top.append(el('span', 'sc-name', b.name));
top.append(el('span', 'sc-times',
phase === 'arrive' ? `arrive ${fmt(day.startMin)} · drop bags`
: `depart ${fmt(Math.max(0, day.startMin - firstLegDur()))}`));
c.append(top);
const meta = el('div', 'sc-meta');
meta.append(el('span', 'tc t-sched', b.state === 'booked' ? 'booked' : 'base'));
meta.append(el('span', 'leg-sub', stayRange(b)));
c.append(meta);
const hm = hotelMks.filter((_, i) => staysForDay().some(s => s === stays[i]));
c.addEventListener('mouseenter', () => hm.forEach(m => flashMarker(m, true)));
c.addEventListener('mouseleave', () => hm.forEach(m => flashMarker(m, false)));
c.addEventListener('click', () => openDiscover('hotel'));
body.append(c);
}
// the ride back to the hotel at the end of a (non-departure) day — same row style and
// click-to-edit as every other leg
function renderReturnRow(body) {
const leg = getReturnLeg(day);
if (leg) {
const last = day.stops[day.stops.length - 1];
renderGroundLegRow(body, {
leg, ico: MODE_ICO[leg.mode] || '🚶',
label: `${last.name} → ${dayBase() ? dayBase().name : 'hotel'}`,
hover: retLines, color: '#7a8291',
title: 'Click to edit the ride — drag the drop-off, walk or taxi',
onClick: openReturnEditor,
});
return;
}
// no single anchor (multi-stay day or region/transit last stop): show the estimate
const ret = el('div', 'leg-row', `<span class="leg-ico">${MODE_ICO[travelMode] || '•'}</span><span>back to ${dayBase() ? dayBase().name : 'hotel'}</span> `);
ret.append(tchip(returnDur(), 1));
if (staysForDay().length > 1) ret.append(el('span', 'leg-sub', `worst-case across ${staysForDay().length} hotels`));
body.append(ret);
}
function openReturnEditor() {
const leg = getReturnLeg(day); if (!leg) return;
const b = dayBase(); if (!b) return;
const last = day.stops[day.stops.length - 1];
openLegEditor(leg, last.at, b.at, `${last.name} → ${b.name}`, 'return');
}
function openFirstEditor() {
const leg = getFirstLeg(day); if (!leg) return;
const b = dayBase(); if (!b) return;
openLegEditor(leg, b.at, day.stops[0].at, `${b.name} → ${day.stops[0].name}`, 'first');
}
// the hotel for tonight — in the timeline, after the ride back
function renderHotelCard(body) {
const b = dayBase(); if (!b) return;
const n = staysForDay().length;
const c = el('div', 'stop-card struct-card hotel-card');
const top = el('div', 'sc-top');
top.append(el('span', 'leg-ico', '🏨'));
top.append(el('span', 'sc-name', b.name));
top.append(el('span', 'sc-times', 'tonight'));
c.append(top);
const meta = el('div', 'sc-meta');
meta.append(el('span', 'tc t-sched', b.state === 'booked' ? 'booked' : 'base'));
meta.append(el('span', 'leg-sub', stayRange(b)));
if (n > 1) meta.append(el('span', 'leg-sub', `${n} options — worst-case anchor`));
c.append(meta);
// flash only THIS day's hotel(s) — flashing every stay's marker makes a
// multi-city trip fly the map to the other city's off-screen hotel (and it
// alternated back and forth on every hover).
const hm = hotelMks.filter((_, i) => staysForDay().some(s => s === stays[i]));
c.addEventListener('mouseenter', () => hm.forEach(m => flashMarker(m, true)));
c.addEventListener('mouseleave', () => hm.forEach(m => flashMarker(m, false)));
c.addEventListener('click', () => openDiscover('hotel'));
body.append(c);
}
const POOL_KEY = { lunch: 'lunch', dinner: 'dinner', visit: 'visit' };
// inline alternatives for a planned stop: same-slot candidates, not already in the day
function slotAlternatives(s, n = 3) {
const key = POOL_KEY[s.slot]; if (!key) return [];
const a = anchorStop(s.slot);
return [...(M.candidates[key] || [])]
.filter(c => c.id !== s.id && !isExcluded(c) && !day.stops.some(x => x.id === c.id))
.sort((x, y) => walkMin(a.at, x.at) - walkMin(a.at, y.at)).slice(0, n);
}
function renderStopCard(body, s, i, flashIds) {
if (s.kind === 'transit') {
// booked external transport: fixed, shown only on its own day
const c = el('div', 'stop-card transit-card');
if (flashIds.includes(s.id)) c.classList.add('flash');
const top = el('div', 'sc-top');
top.append(el('span', 'num', String(i + 1)));
top.append(el('span', 'sc-name', `${s.transit.name} → ${s.transit.to}`));
top.append(el('span', 'sc-times', `${s.transit.arriveBy}–${s.transit.depart}`));
c.append(top);
const meta = el('div', 'sc-meta');
meta.append(el('span', 'tc t-sched', 'booked'));
if (s.price) meta.append(el('span', 'pchip', `${s.price.amount}${s.price.cur}`));
c.append(meta);
c.append(el('div', 'sc-sub', `arrive by ${s.transit.arriveBy} — ${s.transit.buffer}`));
c.addEventListener('mouseenter', () => flashMarker(trainMks[0], true));
c.addEventListener('mouseleave', () => flashMarker(trainMks[0], false));
c.addEventListener('click', () => focusPoint(s.at));
body.append(c);
return;
}
const c = el('div', 'stop-card st-' + s.state);
if (flashIds.includes(s.id)) c.classList.add('flash');
const top = el('div', 'sc-top');
top.append(el('span', 'grip', '⠿'));
top.append(el('span', 'num', String(i + 1)));
top.append(el('span', 'sc-name', s.name));
top.append(el('span', 'sc-times', `${fmt(s.start)}–${fmt(s.start + s.dur)}`));
c.append(top);
const meta = el('div', 'sc-meta');
if (s.state !== 'planned') {
const st = el('span', 'sc-state ' + (s.state === 'maybe' ? 't-idea' : 't-backup'), stateLabel(s.state));
meta.append(st);
}
meta.append(tchip(s.dur, s.durConf));
if (s.suggested && s.suggested.value !== s.dur && s.state === 'planned')
meta.append(el('span', 'sug-note', `usually ~${fmt(s.suggested.value)}`));
if (s.price) meta.append(el('span', 'pchip', `~${s.price.amount}${s.price.cur}`));
c.append(meta);
// vague placeholder: one tap to browse the area
if (s.region) {
c.append(el('div', 'region-note', 'placeholder — travel times are worst-case until refined'));
const rf = el('button', 'refine-btn', 'See options →');
rf.onclick = e => { e.stopPropagation(); openDiscover(POOL_KEY[s.slot] || s.slot, s.region); };
c.append(rf);
}
// quick actions: compare + alternatives (planned only) + more menu
const acts = el('div', 'sc-acts');
const cmp = el('button', 'btn ghost tiny', '⚖');
cmp.title = 'Add to comparison';
cmp.onclick = e => { e.stopPropagation(); toggleCompare(itemFromStop(s)); };
acts.append(cmp);
if (s.state === 'planned') {
const alts = slotAlternatives(s);
if (alts.length) {
const aBtn = el('button', 'btn ghost tiny', `⇄ alternatives (${alts.length})`);
const box = el('div', 'alts');
alts.forEach(cand => {
const row = el('div', 'altrow');
row.append(el('span', 'ar-name', cand.name));
row.append(el('span', 'pchip', `↦ ${walkMin(anchorStop(s.slot).at, cand.at)} min`));
row.append(el('span', 'pchip', cand.price ? '~' + cand.price + '€' : 'free'));
const sw = el('button', 'btn ghost tiny', 'Swap');
sw.onclick = e => {
e.stopPropagation();
const { incumbent } = promoteCand(cand, s.slot);
ai(`Swapped: <b>${cand.name}</b> is your ${s.slot} now${incumbent ? `; <b>${incumbent.name}</b> becomes the backup.` : ''} Times re-sequenced.`, 500);
};
const ac = el('button', 'btn ghost tiny', '⚖');
ac.title = 'Compare';
ac.onclick = e => { e.stopPropagation(); toggleCompare(itemFromCand(cand, s.slot)); };
row.append(sw, ac);
box.append(row);
});
aBtn.onclick = e => { e.stopPropagation(); box.classList.toggle('open'); };
acts.append(aBtn);
c.append(box);
}
} else {
// idea / backup: one-tap promote
const up = el('button', 'btn ghost tiny', s.state === 'alt' ? '⇄ Swap in' : '✓ Make planned');
up.onclick = e => { e.stopPropagation(); setState(s, 'planned'); };
acts.append(up);
}
const more = el('button', 'btn ghost tiny', '⋯');
more.title = 'More (move, remove, state)';
more.onclick = e => { e.stopPropagation(); openStateMenu(s, more); };
acts.append(more);
c.append(acts);
// drag & drop: reorder within day, or drop a candidate card onto this stop
c.draggable = true;
c.addEventListener('dragstart', e => {
if (dragCand) { e.preventDefault(); return; }
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', String(i)); c.classList.add('dragging');
});
c.addEventListener('dragend', () => c.classList.remove('dragging'));
c.addEventListener('dragover', e => {
e.preventDefault();
if (dragCand) c.classList.add('drop-target');
});
c.addEventListener('dragleave', () => c.classList.remove('drop-target'));
c.addEventListener('drop', e => {
e.preventDefault(); c.classList.remove('drop-target');
const data = e.dataTransfer.getData('text/plain');
if (data.startsWith('cand:')) {
const found = findCand(data.slice(5));
if (!found) return;
const { c: cand, slot } = found;
dragCand = null;
e.stopPropagation();
if (slot === s.slot) {
const { incumbent } = promoteCand(cand, slot);
if (incumbent) ai(`Dropped <b>${cand.name}</b> over its slot — it’s your ${slot} now; <b>${incumbent.name}</b> becomes the backup.`, 500);
else ai(`Dropped <b>${cand.name}</b> into ${dayLabel()}.`, 500);
} else {
day.stops.push(makeStop(cand, slot, 'maybe'));
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);
}
if (disc) closeDiscover();
return;
}
const from = parseInt(data, 10);
if (isNaN(from) || from === i) return;
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));
c.addEventListener('mouseleave', () => highlightStop(s.id, false));
c.addEventListener('click', e => {
if (e.target.closest('button, a')) return;
if (s.detail) openDetail(s, null);
else flyTo(s);
});
body.append(c);
if (day.legs[i]) {
const g = day.legs[i];
const lr = el('div', 'leg-row' + (s.state !== 'planned' ? ' dim' : ''));
lr.append(el('span', 'leg-ico', MODE_ICO[g.mode] || '•'));
if (g.toAirport) lr.append(document.createTextNode('to ' + ((day.departureFlight && (day.departureFlight.code || day.departureFlight.from)) || 'airport') + ' '));
if (g.sub) lr.append(el('span', 'leg-sub', g.sub.map(x => `${MODE_ICO[x.mode]} ${x.dur}′`).join(' + ')));
if (g.vague) lr.append(el('span', 'leg-sub', 'worst-case until refined'));
lr.append(document.createTextNode(' '));
lr.append(tchip(g.dur, g.conf));
const base = { color: g.mode === 'multimodal' ? '#2f6fd6' : g.mode === 'fly' ? '#8a93a6' : '#2e9e5b', weight: 5, opacity: .85, dashArray: g.mode === 'fly' ? '6 6' : undefined };
lr.addEventListener('mouseenter', () => legEls[i] && legEls[i].setStyle({ ...base, weight: 7, opacity: 1 }));
lr.addEventListener('mouseleave', () => legEls[i] && legEls[i].setStyle(base));
lr.onclick = () => openL3(i);
body.append(lr);
}
}
// state menu (promote / demote / move day / remove)
function openStateMenu(s, anchor) {
closeMenus();
const menu = el('div', 'pop');
const mk = (label, fn, danger) => { const b = el('button', danger ? 'danger' : '', label); b.onclick = e => { e.stopPropagation(); closeMenus(); fn(); }; menu.append(b); };
if (s.state === 'planned') {
mk('→ Idea', () => setState(s, 'maybe'));
mk('→ Backup', () => setState(s, 'alt'));
} else if (s.state === 'maybe') {
mk('→ In plan', () => setState(s, 'planned'));
mk('→ Backup', () => setState(s, 'alt'));
} else {
mk('⇄ Swap in', () => setState(s, 'planned'));
mk('→ Idea', () => setState(s, 'maybe'));
}
M.days.filter(d => d.id !== day.id).forEach(d => {
mk(`Move to ${d.label}`, () => moveStopToDay(s, d.id));
});
mk('Remove from day', () => {
const slot = s.slot;
day.stops.splice(day.stops.indexOf(s), 1);
let promoted = null;
if (!day.stops.some(x => x.slot === slot && x.state === 'planned')) {
promoted = day.stops.find(x => x.slot === slot && x.state === 'alt');
if (promoted) promoted.state = 'planned'; // last backup in the slot becomes the plan
}
rebuildLegs(); renderAll();
ai(`Removed <b>${s.name}</b> from ${dayLabel()}${promoted ? ` — nothing else was planned for ${slot}, so <b>${promoted.name}</b> is now your ${slot}.` : '.'}`, 500);
}, true);
anchor.parentElement.parentElement.append(menu); // card is position:relative
}
function closeMenus() { document.querySelectorAll('.pop').forEach(p => p.remove()); }
document.addEventListener('click', e => { if (!e.target.closest('.pop')) closeMenus(); });
function setState(s, state) {
if (state === 'planned' && s.state !== 'planned') {
const cur = day.stops.find(x => x.slot === s.slot && x.state === 'planned' && x !== s);
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;
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(`<b>${s.name}</b> is now “${stateLabel(state)}”.`, 400);
}
function moveStopToDay(s, targetId) {
day.stops.splice(day.stops.indexOf(s), 1);
const t = days[targetId];
t.stops.push(s);
rebuildLegs(); if (t !== day) rebuildLegs(t);
commit(`${s.name} → ${t.label}`); renderAll();
ai(`Moved <b>${s.name}</b> to ${t.label} — legs and times re-timed there.`, 500);
}
const dayLabel = () => day.label; // live label (dates) — M.days holds the original plan
// ---------------- time budget (gentle phrasing) ----------------
function renderBudget() {
const prim = day.stops.filter(s => s.state === 'planned');
const flex = day.stops.length - prim.length;
const planned = prim.reduce((a, s) => a + s.dur, 0) + day.legs.reduce((a, g) => a + g.dur, 0)
+ (prim.length ? firstLegDur() + returnDur() : 0);
const flexMin = day.stops.filter(s => s.state !== 'planned').reduce((a, s) => a + s.dur, 0);
const end = layout();
const cap = day.wakingHours * 60;
const pct = Math.min(100, planned / cap * 100);
const fill = $('#budget-fill');
fill.style.width = pct + '%';
fill.classList.toggle('warn', pct > 92);
const mood = pct > 92 ? ' — running a bit full, there’s room to trim' : pct > 80 ? '' : ' — comfortably paced';
$('#budget-text').textContent = `${(planned / 60).toFixed(1)} h of ${day.wakingHours} h · ends ~${fmt(end)}${flex ? ' · +' + (flexMin / 60).toFixed(1) + ' h of ideas' : ''}${mood}`;
}
// ---------------- nudges (issues, but gentle) ----------------
// 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 = '';
const list = activeNudges();
if (!list.length) { p.append(el('div', 'nudge-ok', '✓ this day looks good')); }
list.forEach(n => {
const d = el('div', 'nudge');
d.append(el('div', 'nt', n.title));
d.append(el('div', 'nb', n.body));
const b = el('button', 'btn ghost small', n.fix.label);
b.onclick = () => {
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);
};
d.append(b);
p.append(d);
});
const badge = $('#nudge-badge');
if (badge) {
badge.innerHTML = list.length ? `💡 ${list.length} nudge${list.length > 1 ? 's' : ''}` : '✓ looking good';
badge.classList.toggle('ok', !list.length);
}
}
// ---------------- "you might like" (context-aware suggestions in the rail) ----------------
function renderSugg() {
const p = $('#sugg-block'); if (!p) return; p.innerHTML = '';
const planned = day.stops.filter(s => s.state === 'planned');
const missing = ['dinner', 'lunch', 'visit'].find(k => !planned.some(s => s.slot === k) && M.candidates[k]?.length);
let title, note, pool, slot;
if (missing) {
slot = missing;
title = `No ${missing} planned yet`;
note = `· ${dayLabel()}`;
pool = M.candidates[slot].filter(c => !isExcluded(c));
} else {
slot = 'visit';
title = 'More like this day';
note = '';
pool = (M.candidates.visit || []).filter(c => !isExcluded(c) && !day.stops.some(x => x.id === c.id));
}
if (!pool.length) return;
const a = anchorStop(slot);
const best = [...pool].sort((x, y) => walkMin(a.at, x.at) - walkMin(a.at, y.at)).slice(0, 3);
const head = el('div', 'sugg-title', `💡 ${title} <span class="sugg-note">${note}</span>`);
const open = el('button', 'sugg-open', 'open drawer');
open.onclick = () => openDiscover(slot);
head.append(open);
p.append(head);
best.forEach(c => {
const row = el('div', 'sugg-row');
row.append(el('span', 'sr-name', c.name));
row.append(el('span', 'pchip', `↦ ${walkMin(a.at, c.at)} min`));
row.append(el('span', 'pchip', c.price ? '~' + c.price + '€' : 'free'));
const wn = webEl(c, slot); wn.classList.add('cc-web-sugg'); row.append(wn);
enrichWeb(c, slot);
const cmp = el('button', 'btn ghost tiny', '⚖');
cmp.title = 'Compare';
cmp.onclick = () => toggleCompare(itemFromCand(c, slot));
row.append(cmp);
p.append(row);
});
}
// ---------------- comparison dock (multi-scale alternatives) ----------------
function itemFromCand(c, slot) {
return { key: 'cand:' + c.id, kind: 'cand', cand: c, slot,
name: c.name, at: c.at, price: c.price, dur: c.dur, tags: c.tags, img: c.img };
}
function itemFromStop(s) {
return { key: 'stop:' + s.id, kind: 'stop', stop: s, slot: s.slot,
name: s.name, at: s.at, price: s.price ? s.price.amount : null, dur: s.dur,
tags: [s.state === 'planned' ? 'in plan' : stateLabel(s.state), s.kind], img: s.img };
}
function toggleCompare(item) {
const i = compareItems.findIndex(x => x.key === item.key);
if (i >= 0) { compareItems.splice(i, 1); }
else {
if (compareItems.length >= 4) { ai('The comparison holds up to four — remove one first (✕).', 400); return; }
compareItems.push(item);
}
renderDock();
}
function renderDock() {
const d = $('#dock');
if (!compareItems.length) { d.classList.add('hidden'); d.innerHTML = ''; return; }
d.classList.remove('hidden');
d.innerHTML = '';
const head = el('div', 'dock-head');
head.append(el('span', null, `<b>Comparing ${compareItems.length}</b> — side by side, from the anchor point; choose or hold any of them`));
const clr = el('button', 'dock-clear', 'clear all');
clr.onclick = () => { compareItems = []; renderDock(); };
head.append(clr);
d.append(head);
const grid = el('div', 'dock-grid');
compareItems.forEach(item => {
const isHotel = item.slot === 'hotel';
const a = anchorStop(isHotel ? null : item.slot);
const col = el('div', 'dock-col');
const t = el('div', 'dc-name', item.name);
if (item.img) { const th = el('div', 'dc-thumb'); th.style.background = item.img[2]; th.textContent = item.img[0]; t.prepend(th); }
col.append(t);
const chips = el('div', 'dc-chips');
if (item.at) {
chips.append(el('span', 'pchip', `↦ ${walkMin(a.at, item.at)} min from ${a.name}`));
if (a.name !== dayBase().name) chips.append(el('span', 'pchip', `→ ${hotelWalkMin(item.at)} min to hotel`));
}
if (item.dur) chips.append(el('span', 'tc t-search', '~' + fmt(item.dur)));
const price = typeof item.price === 'number' ? item.price : (item.price?.amount ?? null);
chips.append(el('span', 'pchip', price ? '~' + price + '€ / ' + (isHotel ? 'night' : 'person') : item.price === 0 ? 'free' : '—'));
col.append(chips);
if (item.tags?.length) col.append(el('div', 'dc-tags', item.tags.map(t => (t === 'in plan' ? '✓ ' : '') + t).join(' · ')));
const acts = el('div', 'dc-acts');
if (item.kind === 'cand') {
const pick = el('button', 'btn primary tiny', isHotel ? 'Make this the hotel' : 'Choose this');
pick.onclick = () => { chooseCand(item.cand, item.slot); compareItems = compareItems.filter(x => x.key !== item.key); renderDock(); };
const idea = el('button', 'btn ghost tiny', 'Idea');
idea.onclick = () => { holdIdea(item.cand, item.slot); compareItems = compareItems.filter(x => x.key !== item.key); renderDock(); };
acts.append(pick, idea);
} else {
const st = el('span', 'dc-state', item.stop.state === 'planned' ? 'in the plan' : stateLabel(item.stop.state));
if (item.stop.state !== 'planned') {
const up = el('button', 'btn primary tiny', '⇄ Swap in');
up.onclick = () => { setState(item.stop, 'planned'); compareItems = compareItems.filter(x => x.key !== item.key); renderDock(); };
acts.append(up);
} else acts.append(st);
}
const rm = el('button', 'dc-rm', '✕');
rm.title = 'remove from comparison';
rm.onclick = () => { compareItems = compareItems.filter(x => x.key !== item.key); renderDock(); };
acts.append(rm);
col.append(acts);
grid.append(col);
});
d.append(grid);
}
// ---------------- L3 route editor ----------------
function haversine(a, b) {
const R = 6371e3, toR = d => d * Math.PI / 180;
const dLat = toR(b[0] - a[0]), dLon = toR(b[1] - a[1]);
const h = Math.sin(dLat / 2) ** 2 + Math.cos(toR(a[0])) * Math.cos(toR(b[0])) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(h));
}
// Generic leg editor. Works on ANY leg described by an explicit from/to pair, so the
// airport→hotel drop-off is editable with the same machinery as a stop-to-stop leg:
// draggable waypoints, a walk/taxi toggle that re-routes THIS leg, and a live
// itinerary pane (the rail re-renders as you edit — no more stale times behind the
// card). The map is left to the editor's own preview line; renderAll syncs it on close.
function openLegEditor(leg, from, to, title, kind, idx) {
closeL3();
const a = from, b = to;
const mid = [(a[0] + b[0]) / 2 + 0.0012, (a[1] + b[1]) / 2 - 0.0018];
const layer = L.layerGroup().addTo(map);
const line = L.polyline(leg.geometry || [a, mid, b], { color: '#b5533c', weight: 5, opacity: .9, dashArray: '6 6' }).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 box = $('#l3-editor .l3-time');
let rt = 0, railT = 0; // router debounce token + rail re-render debounce
const refreshCard = () => { box.innerHTML = 'leg time: '; box.append(tchip(leg.dur, leg.conf || 3)); renderL3Mode(leg); };
const railSoon = () => { clearTimeout(railT); railT = setTimeout(() => { if (l3 && l3.leg === leg) renderRail(); }, 180); };
const upd = () => {
const p1 = m1.getLatLng(), p2 = m2.getLatLng(), p3 = m3.getLatLng();
leg._pts = [[p1.lat, p1.lng], [p2.lat, p2.lng], [p3.lat, p3.lng]];
line.setLatLngs(leg._pts);
const dist = haversine(leg._pts[0], leg._pts[1]) + haversine(leg._pts[1], leg._pts[2]);
leg.dur = Math.max(4, Math.round(dist / 1000 / (legSpeedKmh(leg.mode) / 60))); // instant straight-line estimate at the leg's mode
leg.conf = 1;
box.innerHTML = 'leg time: est. '; box.append(tchip(leg.dur, 1));
renderBudget(); railSoon();
const t = ++rt;
routePts(leg._pts, leg.mode).then(r => { const res = saneRoute(r, leg._pts, leg.mode);
if (t !== rt || !l3 || l3.leg !== leg) 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(); renderRail(); // keep the itinerary pane in sync live
});
};
[m1, m2, m3].forEach(m => m.on('drag', upd));
$('#l3-title').textContent = title;
refreshCard();
renderL3Mode(leg);
$('#l3-foot').onclick = () => setLegMode(leg, 'foot');
$('#l3-car').onclick = () => setLegMode(leg, 'car');
$('#l3-editor').classList.remove('hidden');
map.fitBounds(L.latLngBounds([a, b]).pad(0.4));
l3 = { layer, leg, from: a, to: b, kind, idx, refreshCard };
const obs = setInterval(() => {
if (!$('#l3-editor').classList.contains('hidden')) return;
clearInterval(obs);
commit('edited a leg route');
renderAll();
}, 250);
}
// Stop-to-stop leg: day.legs[i] between stops i and i+1.
function openL3(legIdx) {
const leg = day.legs[legIdx]; if (!leg) return;
openLegEditor(leg, day.stops[legIdx].at, day.stops[legIdx + 1].at,
`${day.stops[legIdx].name} → ${day.stops[legIdx + 1].name}`, 'stop', legIdx);
}
// The airport→hotel drop-off: a first-class leg with its own mode + geometry.
function openArrivalEditor() {
const t = day.transferIn; if (!t || !t.at || !t.hotelAt) return;
openLegEditor(t, t.at, t.hotelAt, 'Airport → ' + (t.hotelName || 'hotel'), 'arrival');
}
function closeL3() {
if (l3) { map.removeLayer(l3.layer); l3 = null; }
$('#l3-editor').classList.add('hidden');
}
$('#l3-close').onclick = closeL3;
// the walk/taxi toggle lives on the route card and changes the MODE OF THE LEG
// BEING EDITED (per-leg) — so the airport→hotel drop-off can be walked or taxi'd
// independently of the rest of the day. Flipping it re-routes just that leg and keeps
// the card, the itinerary pane, and the map in sync.
function renderL3Mode(leg) {
const box = $('#l3-mode'); if (!box) return;
const ground = !!leg && (leg.mode === 'foot' || leg.mode === 'car');
box.classList.toggle('hidden', !ground);
if (!ground) return;
$('#l3-foot').classList.toggle('on', leg.mode === 'foot');
$('#l3-car').classList.toggle('on', leg.mode === 'car');
}
// Change one leg's mode: re-estimate it straight-line for instant feedback, then
// re-route it for the real road time. Only this leg is touched.
function setLegMode(leg, m) {
if (!leg || !l3 || l3.leg !== leg || m === leg.mode) return;
leg.mode = m;
leg.dur = leg._pts
? Math.max(4, Math.round((haversine(leg._pts[0], leg._pts[1]) + haversine(leg._pts[1], leg._pts[2])) / 1000 / (legSpeedKmh(m) / 60)))
: estLegMin(m, l3.from, l3.to);
leg.conf = 1;
if (l3.kind === 'arrival') syncArrivalStart(day); // reflect the new (estimated) transfer right away
renderRail(); renderL3Mode(leg);
if (l3.kind === 'arrival' || l3.kind === 'first') renderMap(); // keep the leg line colour (walk=green, taxi=blue) in sync
const t = (leg._rt = (leg._rt || 0) + 1);
routePts(leg._pts || [l3.from, l3.to], m).then(r => { const res = saneRoute(r, leg._pts || [l3.from, l3.to], m);
if (leg._rt !== t || !l3 || l3.leg !== leg) return;
if (res) { leg.dur = res.durMin; leg.conf = 3; leg.geometry = res.geometry; leg._routedMode = m; }
if (l3.kind === 'arrival') syncArrivalStart(day); // and again once the real route lands
renderRail();
if (l3.kind === 'arrival' || l3.kind === 'first') renderMap();
l3.refreshCard();
});
}
// ---------------- 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
let tripDocs = {}; // name → {name,kind,chars,text,truncated,at} — attached documents
// (authoritative copy lives on the server: ~/trips/store/mock/<trip>/docs/)
let chatLog = []; // [{role:'user'|'assistant', text}] — conversation turns, persisted
// per trip on the server (~/trips/store/mock/<trip>/chat.jsonl) so a
// follow-up ("yes, do it") can see what the model said earlier,
// even after a reload
let storeReady = Promise.resolve(); // resolve when this trip's server store has loaded
let storeToken = 0; // guards against a stale load after switchTrip
function persistChatTurn(role, text) {
if (!tripId || !text || !String(text).trim()) return;
fetch('/trip-store/' + encodeURIComponent(tripId) + '/chat', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role, text: String(text) }),
}).catch(() => {}); // fire & forget — the model answer must not wait on the log
}
function loadTripStore(id) {
const token = ++storeToken;
storeReady = (async () => {
try {
const m = await (await fetch('/trip-store/' + encodeURIComponent(id))).json();
if (token !== storeToken) return; // user switched trips while loading
tripDocs = {};
// one-time migration: docs saved in localStorage by older builds get
// uploaded to the server (the extracted text becomes the stored file)
const local = (tripStore.load(id) || {}).docs || {};
const localNames = Object.keys(local);
if (localNames.length && !(m.docs || []).length) {
for (const n of localNames) {
const d = local[n] || {};
const b64 = btoa(unescape(encodeURIComponent(d.text || '')));
const fn = /\.[a-z0-9]{1,5}$/i.test(n) ? n : n + '.txt';
await fetch('/trip-store/' + encodeURIComponent(id) + '/doc', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: fn, mime: 'text/plain', b64 }),
}).catch(() => {});
}
const s = tripStore.load(id); // drop the migrated docs from localStorage
if (s && s.docs) { delete s.docs; tripStore.save(id, s); }
const m2 = await (await fetch('/trip-store/' + encodeURIComponent(id))).json();
if (token !== storeToken) return;
m.docs = m2.docs || [];
}
for (const d of m.docs || []) {
if (!d.name) continue;
tripDocs[d.name] = { name: d.name, kind: d.kind, chars: d.chars || 0, text: d.text || '', truncated: !!d.truncated, at: d.at || Date.now(), docId: d.docId, originalSize: d.originalSize };
}
const cj = await (await fetch('/trip-store/' + encodeURIComponent(id) + '/chat')).json();
if (token !== storeToken) return;
chatLog = (cj.messages || []).filter(x => x && x.text).map(x => ({ role: x.role === 'assistant' ? 'assistant' : 'user', text: String(x.text) }));
renderDocChips();
// re-render past turns — the chat panel is fresh after a reload (skip
// the upload messages: they carry the whole document, not worth showing)
const body = $('#chat-body');
if (body) {
const frag = document.createDocumentFragment();
chatLog.filter(e => !e.text.includes('<document>')).slice(-40).forEach(e =>
frag.append(el('div', 'msg ' + (e.role === 'assistant' ? 'ai' : 'user'),
e.role === 'assistant' ? esc(e.text).replace(/\n+/g, '<br>') : esc(e.text))));
if (frag.childNodes.length) body.prepend(frag);
}
} catch { /* store unreachable — fall back to in-memory only */ }
})();
return storeReady;
}
function pushChatLog(role, text) {
if (!text || !String(text).trim()) return;
chatLog.push({ role, text: String(text) });
if (chatLog.length > 24) chatLog.splice(0, chatLog.length - 24);
}
function recentHistory() {
// most recent turns that fit the context budget; upload messages carry the
// whole document — skip them, docSection() already has it
const out = [];
let budget = 12000;
for (let i = chatLog.length - 1; i >= 0 && budget > 0; i--) {
const e = chatLog[i];
if (e.role === 'user' && e.text.includes('<document>')) 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 <b>v${s.v}</b> — ${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', `<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 ----------------
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 <b>${s.name}</b> 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 ? firstLegDur(d) + (d.isDeparture ? 0 : 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', '<span class="typing"><i></i><i></i><i></i></span>'); }
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 =>
'<span class="dc" title="' + esc(n) + ' — ' + (tripDocs[n].chars || 0) + ' chars, attached ' + new Date(tripDocs[n].at || 0).toLocaleDateString() + '">📎 ' + esc(n) + ' <span class="dc-k">' + (Math.round((tripDocs[n].chars || 0) / 100) / 10) + ' KB</span></span>').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) + ' <span class="src">' + (j.kind || 'doc') + ' · ' + (j.chars || 0) + ' chars — attached' + (j.truncated ? ' (truncated)' : '') + '</span>');
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' +
'<document>\n' + (j.text || '') + '\n</document>\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 = `<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);
});
$('#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/<trip>/plan.json, via /trip-store/<id>/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 = `🧳 <span class="t-name">${M.trip.title}</span> <span class="caret">▾</span>`;
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 = `<span class="ti-name">${t.trip.title}</span><span class="ti-sub">${t.id === tripId ? 'current trip' : 'open · v' + (saved?.version || 1)}</span>`;
b.onclick = () => { menu.classList.add('hidden'); switchTrip(t.id); };
menu.append(b);
});
const more = el('button', 'trip-item');
more.innerHTML = `<span class="ti-name">+ New trip…</span><span class="ti-sub">start from a booking on the landing screen</span>`;
more.onclick = () => location.href = location.pathname;
menu.append(more);
const ex = el('button', 'trip-item');
ex.innerHTML = `<span class="ti-name">⬇ Export plan (JSON)</span><span class="ti-sub">download the server-side plan file</span>`;
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 <b>${M.trip.title}</b> — you’re at <b>v${version}</b> (“${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.firstLeg) { d.firstLeg.mode = m; d.firstLeg._routedMode = null; }
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() : l3.kind === 'return' ? openReturnEditor() : l3.kind === 'first' ? openFirstEditor() : 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 — <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(() => {
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(`<b>${s.name}</b> is in as the <b>backup</b> for your ${s.slot} — one slot, one plan. Swap them any time.`, 500);
else ai(`Added <b>${s.name}</b> — 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 <b>${s.name}</b> 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 <b>${s.name}</b> — 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 — <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);
}
// ---------------- 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 <b>${c.name}</b> into ${dayLabel()} as an idea — promote it whenever you like.`, 500);
});
})();
initMap();
// router status + leg enrichment happen in enterTrip() (per-trip extract)