trips/mock/app.js
Greg Pomerantz 04076294bd Fix Cartagena old-city points placed in the bay + misplaced CTG airport
Root cause: several Cartagena old-city landmarks in the data had latitudes
~4 km too high (≈10.46°N instead of ≈10.42°N), dropping them out into the
Caribbean bay — that's why Day 2 stops 2–5 rendered in the water. The chat
model couldn't fix them because its web search couldn't surface valid
coordinates for those specific buildings, and it (correctly) refused to move
them to arbitrary guessed locations.

Also found the CTG (Rafael Núñez) airport was misplaced ~8 km east onto the
Bocagrande area ([10.4622,-75.4385]); the real airport is [10.4425,-75.5131].
This inflated the Day 1 arrival drop-off to 313 min.

Changes:
- data.js: re-anchor 6 old-city stops (S5, S7–S10, S20) + the C2 candidate to
  verified on-land coordinates (all snap <21 m to the OSRM extract); fix the
  CTG airport in the CTG→SMR flight stations and the S22 stop.
- app.js: correct AIRPORTS.ctg (used by the arrival transfer) to the real
  airport. Add healCoordinates() so a plan saved before this fix (still holding
  the old in-the-bay points in the browser's localStorage) is re-anchored to
  the corrected source on load — stops re-sync by id, arrival/departure airports
  re-derived from the AIRPORTS table. Idempotent; user-swapped stops (candidate
  ids) are left alone. Verified with a CDP test that corrupts the saved plan
  (6 stops + airport) and confirms a reload self-heals them back.

All regression suites pass: coords 6/6, drop-off leg 17/17, smoke 0 errors,
rail structure 13/13, dates 7/7.
2026-09-09 21:02:19 -04:00

2612 lines
138 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 24 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
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)));
// ---------------- 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;
async function checkLLM() {
try { const r = await fetch('/llm-status'); const j = await r.json(); llmOn = !!j.llm; }
catch { llmOn = false; }
}
const badgeText = () => '📶 online' + (routerOn ? ' · router' : '') + (llmOn ? ' · llm' : '');
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 : ''})`);
L.push(`${dd.label} [${dd.id} · ${dd.date}]: ${stops.length ? stops.join(' · ') : '(empty)'} — day ends ~${fmt(dd.startMin + (dd.wakingHours || 11) * 60)}`);
}
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)));
}
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' };
}
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;
}
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 };
},
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 = await routePts([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 };
},
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:0015: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:0021: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' };
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-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 Sep12 Oct" | "2128 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.startMin = arrMin + arrTransfer;
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); }
const arrEnd = Math.min(ARR_DAY_END, first.startMin + (first.wakingHours || 12) * 60);
first.wakingHours = Math.max(2, Math.round((arrEnd - first.startMin) / 60));
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' +
' 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' +
' 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 (14 sentences), plain text, no markdown.\n' +
'- For any factual question about times, distances, or prices, CALL the tool — do not guess.\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 users 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 cant 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 — Im 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 r = await fetch('/llm-chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messages, max_tokens: 8192 }) });
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(', ') };
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 Googles default 6)
function decodePolyline(str, precision = 5) {
const out = [];
let p = 0, lat = 0, lng = 0;
const next = () => { // one zigzag varint component
let result = 0, shift = 0, b;
do { b = str.charCodeAt(p++) - 63; result += (b & 31) << shift; shift += 5; } while (b >= 32);
return (result & 1) ? ~(result >> 1) : (result >> 1);
};
while (p < str.length) {
lat += next();
lng += next();
out.push([lng / 10 ** precision, lat / 10 ** precision]); // [lng, lat]
}
return out;
}
// route an ordered list of [lat,lng] points; null when unroutable (out of coverage)
async function routePts(points, mode = 'foot') {
if (!routerOn) return null;
const q = points.map(p => `${p[1]},${p[0]}`).join(';');
try {
const r = await fetch(`/route?mode=${mode}&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 legToken = 0;
// background enrichment: upgrade each straight-line leg to a real route
async function enrichLegs(d = day) {
if (!routerOn) return;
const token = ++legToken;
// 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 res = await routePts([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 (l3 && l3.leg === t) l3.refreshCard && l3.refreshCard();
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 res = await routePts([a.at, b.at], d.legs[i].mode === 'multimodal' ? 'foot' : d.legs[i].mode);
if (token !== legToken) return; // the day changed mid-flight
const leg = d.legs[i];
if (res) {
const changed = leg.dur !== res.durMin || !leg.geometry;
leg.dur = res.durMin; leg.conf = 3; leg.geometry = res.geometry;
leg.src = 'local router';
if (changed) renderAll();
} else {
leg.conf = 1; leg.src = 'estimate — outside router coverage';
renderAll();
}
}
// return-to-hotel leg when a single stay anchors the day (uses the travel mode)
if (!d.isDeparture && d.stops.length && staysForDay(d).length === 1) {
const last = d.stops[d.stops.length - 1];
if (last.kind !== 'region' && last.kind !== 'transit') {
const res = await routePts([last.at, dayBase(d).at], travelMode);
if (token !== legToken) return;
if (res) { d.retRouted = res; 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 = [];
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
function returnDur(d = day) {
if (d.isDeparture) return 0; // departure day: the train is the end of the day
const last = d.stops[d.stops.length - 1];
if (!last) return 0;
// a single stay anchored the day and the router has a real walk → use it
if (d.retRouted && staysForDay(d).length === 1 && last.kind !== 'transit') return d.retRouted.durMin;
const list = staysForDay(d);
// worst case across every candidate stay for these nights (equals the real 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) {
let t = d.startMin;
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 = [];
delete d.retRouted;
for (let i = 0; i < d.stops.length - 1; i++) {
const a = d.stops[i], b = d.stops[i + 1];
// a vague (region) stop at either end → worst-case leg until refined
if (a.kind === 'region' || b.kind === 'region') {
d.legs.push({ mode: 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 = [];
// 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.retRouted && staysForDay(obj).length === 1) ? obj.retRouted.geometry : [pts[pts.length - 1], o.at];
retLine = L.polyline(g, { color: '#7a8291', weight: 4, opacity: .8, dashArray: '1 7' }).addTo(map);
otherLayers.push(retLine);
retLines.push(retLine);
});
}
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);
}
}
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);
}
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;
if (cur < day.startMin) block(cur, day.startMin - cur, 'gap', '', 'morning free');
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(' ')));
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 incumbents sequence position so the days 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 = '';
// a travel day OPENS with the arrival: airport card, then the ride in to drop bags
if (day.isArrival && day.arrivalFlight) renderArrivalHeader(body);
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 header: the flight you landed on, then the car to the hotel
function renderArrivalHeader(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);
// the ride to the hotel to drop bags — a first-class leg (walk or taxi), before the first stop.
// Click it to edit the route / toggle walk vs taxi, exactly like any stop-to-stop leg.
const t = day.transferIn || {};
const tr = el('div', 'leg-row');
tr.append(el('span', 'leg-ico', t.mode === 'foot' ? '🚶' : '🚗'));
tr.append(document.createTextNode('to ' + (t.hotelName || (dayBase() ? dayBase().name : 'hotel')) + ' — drop bags'));
tr.append(document.createTextNode(' '));
tr.append(tchip(t.dur || 30, t.conf || 2));
const ac = t.mode === 'foot' ? '#5a7d5a' : '#274b8f';
const on = { weight: 7, opacity: 1, color: ac, dashArray: null };
const off = { color: ac, weight: 5, opacity: .85, dashArray: '6 4' };
tr.addEventListener('mouseenter', () => arrLines.forEach(l => l.setStyle(on)));
tr.addEventListener('mouseleave', () => arrLines.forEach(l => l.setStyle(off)));
tr.addEventListener('click', () => openArrivalEditor());
body.append(tr);
}
// the ride back to the hotel at the end of a (non-departure) day
function renderReturnRow(body) {
const multi = staysForDay().length > 1;
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(), multi ? 1 : 3));
if (multi) ret.append(el('span', 'leg-sub', `worst-case across ${staysForDay().length} hotels`));
const on = { weight: 7, opacity: 1, color: '#4a5160', dashArray: null };
const off = { color: '#7a8291', weight: 4, opacity: .8, dashArray: '1 7' };
ret.addEventListener('mouseenter', () => retLines.forEach(l => l.setStyle(on)));
ret.addEventListener('mouseleave', () => retLines.forEach(l => l.setStyle(off)));
body.append(ret);
}
// 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);
c.addEventListener('mouseenter', () => hotelMks.forEach(m => flashMarker(m, true)));
c.addEventListener('mouseleave', () => hotelMks.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 — its 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 ? 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, theres 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 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(res => {
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;
renderRail(); renderL3Mode(leg);
if (l3.kind === 'arrival') renderMap(); // keep the drop-off 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(res => {
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; }
renderRail();
if (l3.kind === 'arrival') 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: mock/store/<trip>/docs/)
let chatLog = []; // [{role:'user'|'assistant', text}] — conversation turns, persisted
// per trip on the server (mock/store/<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 cant bring them back.`, 400);
}
function undo() { if (hIdx > 0) restore(hIdx - 1); }
function redo() { if (hIdx < history.length - 1) restore(hIdx + 1); }
function renderVersionPill() {
const elV = $('#verlabel'); if (!elV) return;
elV.textContent = 'v' + version;
$('#ver-undo').classList.toggle('off', hIdx <= 0);
$('#ver-redo').classList.toggle('off', hIdx >= history.length - 1);
}
// what changed between that version and the current plan (stop-level, per day)
function diffVsCurrent(snap) {
const parts = [];
M.days.forEach(md => {
const A = (snap.days[md.id] || {}).stops || [];
const B = (days[md.id] || {}).stops || [];
const aSet = new Set(A.map(x => x.id)), bSet = new Set(B.map(x => x.id));
const added = B.filter(x => !aSet.has(x.id)).map(x => x.name);
const removed = A.filter(x => !bSet.has(x.id)).map(x => x.name);
if (added.length || removed.length)
parts.push(`${md.label.split(' · ')[0]}${added.length ? ' +' + added.join(', +') : ''}${removed.length ? ' ' + removed.join(', ') : ''}`);
});
return parts.slice(0, 3);
}
function toggleVerList() {
const p = $('#verlist');
if (!p.classList.contains('hidden')) { p.classList.add('hidden'); return; }
p.innerHTML = '';
const rows = history.map((s, i) => ({ s, i })).slice(-14).reverse();
rows.forEach(({ s, i }) => {
const r = el('div', 'ver-row' + (i === hIdx ? ' cur' : ''));
const when = new Date(s.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const d = diffVsCurrent(s);
r.append(el('div', 'vr-main', `<b>v${s.v}</b> · ${when} · ${s.label}` + (i === hIdx ? ' <span class="vr-cur">current</span>' : '')));
if (d.length && i !== hIdx) r.append(el('div', 'vr-diff', d.join(' · ')));
r.onclick = () => { p.classList.add('hidden'); if (i !== hIdx) restore(i); };
p.append(r);
});
p.classList.remove('hidden');
}
$('#ver-undo').onclick = undo;
$('#ver-redo').onclick = redo;
$('#verlabel').onclick = toggleVerList;
document.addEventListener('click', e => { if (!e.target.closest('#verpill, #verlist')) $('#verlist')?.classList.add('hidden'); });
document.addEventListener('keydown', e => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'z') { e.preventDefault(); e.shiftKey ? redo() : undo(); }
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'y') { e.preventDefault(); redo(); }
});
// ---------------- day tabs ----------------
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];
if (!day.legs.length) rebuildLegs(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 drawers anchor is day-specific
renderAll();
}
let scope = 'day'; // 'day' (default working view) | 'trip' (L0 macro overview)
function renderAll(flashIds = []) {
if (scope === 'trip') renderTripRail();
else renderRail(flashIds);
renderMap();
}
function setScope(sc) {
scope = sc;
document.querySelectorAll('.sct').forEach(b => b.classList.toggle('on', b.dataset.sc === sc));
$('#daystrip').classList.toggle('hidden', sc === 'trip');
$('#rail-foot').classList.toggle('hidden', sc === 'trip');
renderAll();
}
$('#scope-day').onclick = () => setScope('day');
$('#scope-trip').onclick = () => setScope('trip');
// ---------------- L0 trip overview (macro scale) ----------------
function dayStats(d) {
const planned = d.stops.filter(s => s.state === 'planned');
const dur = planned.reduce((a, s) => a + s.dur, 0) + d.legs.reduce((a, g) => a + g.dur, 0)
+ (planned.length && !d.isDeparture ? returnDur(d) : 0);
return { count: planned.length, dur, end: layout(d),
price: planned.reduce((a, s) => a + (s.price?.amount || 0), 0),
flex: d.stops.filter(s => s.state !== 'planned').length, cap: d.wakingHours * 60 };
}
function renderTripRail() {
const body = $('#rail-body'); body.innerHTML = '';
const total = M.days.reduce((a, md) => a + dayStats(days[md.id]).price, 0);
const head = el('div', 'trip-head');
head.append(el('div', 'trip-title', M.trip.title));
head.append(el('div', 'trip-sub', `${M.days.length} days · ~${total} spent in-plan · bookings are hard anchors`));
body.append(head);
M.days.forEach(md => {
const d = days[md.id];
const st = dayStats(d);
const pct = Math.min(100, st.dur / st.cap * 100);
const card = el('div', 'trip-day' + (md.id === curDay ? ' cur' : ''));
const top = el('div', 'td-top');
top.append(el('span', 'td-label', d.label + (d.isDeparture ? ' · dep' : ''))); // live label (dates)
top.append(el('span', 'td-end', `ends ~${fmt(st.end)}`));
card.append(top);
const bar = el('div', 'td-bar');
const fill = el('div', 'td-fill');
fill.style.width = pct + '%';
fill.classList.toggle('warn', pct > 92);
bar.append(fill);
card.append(bar);
const meta = el('div', 'td-meta');
meta.append(el('span', null, `${st.count} stops${st.flex ? ' · ' + st.flex + ' ideas' : ''}`));
meta.append(el('span', 'pchip', `~${(st.dur / 60).toFixed(1)} h / ${md.wakingHours} h`));
const cur = (d.stops.find(x => x.price)?.price)?.cur || '';
if (st.price) meta.append(el('span', 'pchip', `~${st.price}${cur} spent`));
card.append(meta);
const names = d.stops.filter(s => s.state === 'planned').map(s => s.name);
card.append(el('div', 'td-names', names.slice(0, 4).join(' → ') + (names.length > 4 ? ' …' : '')));
card.onclick = () => { setDay(md.id); setScope('day'); };
body.append(card);
});
body.append(offlineCard());
}
// ---------------- on-trip offline bundle (simulated) ----------------
let offlineState = 'idle'; // idle | working | ready
function offlineCard() {
const c = el('div', 'offline-card');
if (offlineState === 'ready') {
c.append(el('div', 'oc-title', '📦 Offline bundle ready'));
c.append(el('div', 'oc-sub', `Itinerary v${version}, stop details (hours, tags) and the trip-area map tiles are on-device — today-view and navigation keep working without signal; re-planning needs connectivity.`));
} else if (offlineState === 'working') {
c.append(el('div', 'oc-title', '📦 Preparing offline bundle…'));
c.append(el('div', 'oc-sub oc-progress', 'itinerary ✓ → POI details…'));
} else {
c.append(el('div', 'oc-title', '📶 On-trip: prepare an offline bundle'));
c.append(el('div', 'oc-sub', 'Downloads the plan, stop details (opening hours run fully offline) and vector map tiles for the trip area — so the plan keeps working in thin-coverage spots.'));
const b = el('button', 'btn ghost small', 'Prepare for trip');
b.onclick = () => prepareOffline();
c.append(b);
}
return c;
}
function prepareOffline() {
offlineState = 'working';
renderTripRail();
const steps = [
['itinerary ✓ → POI details…', 500],
['POI details (hours, tags) ✓ → vector tiles…', 1200],
[`vector tiles · trip bbox (${120 + M.days.length * 40} tiles) ✓`, 2100],
];
steps.forEach(([txt, t]) => setTimeout(() => {
if (offlineState !== 'working') return;
const p = document.querySelector('.oc-progress');
if (p) p.textContent = txt;
}, t));
setTimeout(() => {
offlineState = 'ready';
const badge = $('#offline-badge');
if (badge) { badge.textContent = '📦 offline ready'; badge.classList.add('ok'); badge.title = 'Trip bundle cached on-device'; }
if (scope === 'trip') renderTripRail();
ai('Offline bundle prepared — itinerary, stop details and the trip-area map tiles are cached. On-trip it degrades gracefully: plan + navigation keep working, re-planning waits for signal.', 900);
}, 2500);
}
// ---------------- chat ----------------
function msg(role, html) {
const m = el('div', 'msg ' + role, html);
$('#chat-body').append(m);
m.scrollIntoView({ behavior: 'smooth', block: 'end' });
return m;
}
function typing() { return msg('ai', '<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; }
if (/compare/.test(s)) {
ai('Tap the <b>⚖</b> on any candidate, stop card, or suggestion row to line up 24 options side by side in the comparison tray at the bottom — travel from the anchor, to the hotel, price, and time, all in one glance.', 900);
return;
}
if (s.includes('later') || s.includes('morning')) {
day.startMin += 30;
commit('start day 30 min later');
renderAll(day.stops.map(x => x.id));
ai(`Pushed ${dayLabel()} back 30 min — everything re-timed. Day now ends at <b>${fmt(layout())}</b>.`);
} else if (s.includes('lunch') || s.includes('longer')) {
const n = activeNudges().find(x => x.fix?.stop && x.fix?.dur);
const st = n ? stopById(n.fix.stop) : null;
if (n && !n.fixed && st) {
n.fixed = true; st.dur = n.fix.dur;
commit(`nudge: ${n.fix.label}`);
rebuildLegs(day);
renderAll([st.id]);
ai(n.fix.msg || `Done — ${st.name} now runs ${n.fix.dur} min; what follows it slides later.`);
} else ai(`Theres nothing to loosen on ${dayLabel()} right now — the day looks good.`);
} else if (llmOn) {
askLLM(v); // free-form → LLM with the live plan as context
} else {
ai('I can shift times, swap stops, or move things between days — e.g. “start the day later”. For finding, the drawer chips on the map (lunch / afternoon / hotel) are always there; for swapping, expand a stop cards <b>alternatives</b> or drag a candidate straight onto the plan.', 900);
}
}
$('#chat-send').onclick = chatSend;
$('#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 — Im 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', 'Couldnt 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 || 'couldnt 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 ("whats my confirmation code?", "whens 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 users 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 plans 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 cant act on it yet. Ill do it as soon as its back.');
const prompt =
'Ive 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 its attached and summarize what it contains in 12 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 to localStorage under its own key, so several
// trips can be worked on at once and each resumes exactly where it stopped.
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 persistTrip(force = false) {
clearTimeout(persistT);
const hist = history.slice(0, hIdx + 1).slice(-25); // drop the redo tail, cap at 25
// docs + chat live on the server now (mock/store/<trip>/) — localStorage
// only carries the plan state. force: skip the debounce — the re-anchoring
// of the whole trip around booked flights is the one edit we must never lose
const save = () => tripStore.save(tripId, { days, stays, version, hIdx: hist.length - 1, history: hist, bookings: M.trip.bookings, title: M.trip.title });
if (force) save(); else persistT = setTimeout(save, 250);
}
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);
}
function enterTrip(id) {
tripId = id;
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
const saved = tripStore.load(id);
if (saved) {
days = deep(saved.days); stays = deep(saved.stays); version = saved.version; history = saved.history; hIdx = saved.hIdx;
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();
if (coordFixed) Object.values(days).forEach(d => rebuildLegs(d, false)); // re-estimate legs from the new points
persistTrip(coordFixed);
}
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;
legToken++; // 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()]).then(() => {
const b = $('#offline-badge');
if (b && offlineState !== 'ready') b.textContent = badgeText();
if (routerOn) enrichLegs(day);
});
if (!day.legs.length) rebuildLegs(day); else enrichLegs(day);
renderAll();
renderVersionPill();
if (!saved) {
commit('initial plan');
script1();
} else {
ai(`Welcome back to <b>${M.trip.title}</b> — youre 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.transferIn && d.transferIn.at) { d.transferIn.mode = m; d.transferIn._routedMode = null; }
rebuildLegs(d, false);
});
renderAll();
if (day) enrichLegs(day);
renderModeToggle();
if (l3) (l3.kind === 'arrival' ? openArrivalEditor() : openL3(l3.idx)); // rebind the open route card to the re-estimated leg
}
function openDetail(s, actions) {
const d = s.detail; if (!d) return;
const oldM = document.getElementById('detail-modal'); if (oldM) oldM.remove();
const modal = el('div', 'modal'); modal.id = 'detail-modal';
const card = el('div', 'modal-card wide');
const img = el('div', 'd-img');
img.style.background = s.img[2];
if (s.img[3]) { const im = document.createElement('img'); im.src = s.img[3]; im.alt = s.img[1]; img.append(im); }
else img.append(el('div', 'd-emoji', s.img[0]));
img.append(el('div', 'd-attr', s.img[1] + ' · Wikimedia Commons'));
const body = el('div', 'd-body');
const t = el('div', 'd-title');
const a = el('a', null, s.name); a.href = s.url; a.target = 'blank'; a.rel = 'noopener';
t.append(a, el('span', 'd-kind', s.kind));
body.append(t);
const facts = el('div', 'd-facts');
facts.append(
el('span', 'tc ' + CONF_CLS[s.durConf], 'planned ' + fmt(s.dur)),
el('span', 'tc t-search', 'typically ' + fmt(s.suggested.value)),
s.price ? el('span', 'pchip', '~' + s.price.amount + s.price.cur + (s.mealKind ? ' / person' : '')) : null
);
body.append(facts);
body.append(el('div', 'd-summary', d.summary));
const links = el('div', 'd-links');
d.links.forEach(l => {
const b = el('a', 'd-link', l.label); b.href = l.href; b.target = 'blank'; b.rel = 'noopener';
links.append(b);
});
body.append(links);
if (actions) {
const foot = el('div', 'd-actions');
const a2 = el('button', 'btn primary small', 'Add to day'); a2.onclick = actions.add;
const m2 = el('button', 'btn ghost small', 'Idea'); m2.onclick = actions.maybe;
const p2 = el('button', 'btn ghost small', 'Not for me'); p2.onclick = actions.pass;
foot.append(a2, m2, p2);
actions.register([a2, m2, p2]);
body.append(foot);
}
const close = el('button', 'd-close', '✕');
close.onclick = () => modal.remove();
card.append(img, body, close);
modal.append(card);
modal.addEventListener('click', e => { if (e.target === modal) modal.remove(); });
document.body.append(modal);
}
// ---------------- adding stops (planned / maybe / alt) ----------------
function addStopToDay(id, requested) {
const src = allStops(id);
const copy = deep(src);
const hasPrimary = day.stops.some(x => x.slot === copy.slot && x.state === 'planned');
let state = requested || (hasPrimary ? 'alt' : 'planned');
if (state === 'planned') {
const cur = day.stops.find(x => x.slot === copy.slot && x.state === 'planned');
if (cur) cur.state = 'alt';
}
copy.state = state;
day.stops.push(copy);
rebuildLegs();
commit(`${copy.name}${stateLabel(state)}`);
renderAll([id]);
return state;
}
function script1() {
setTimeout(() => {
scriptBusy = true;
{ const b0 = dayBase(); ai(`Anchored your stay — <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> — Ill 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)