trips/mock/app.js
Greg Pomerantz bf72fdf32d mock: server-side per-trip store — originals, parsed docs, conversation
Context and documents now live on the server between turns (and across
reloads), per project, as requested:

  mock/store/<trip>/docs/<docId>-<file>        ORIGINAL uploaded bytes
  mock/store/<trip>/docs/<docId>-<file>.txt    extracted text (+ header)
  mock/store/<trip>/chat.jsonl                 full LLM conversation

Endpoints: GET /trip-store/:trip (manifest incl. doc texts),
GET .../doc/:docId, GET .../chat (last 100 turns), POST .../doc,
POST .../chat. /parse-doc's extraction factored into shared finishParse()
so both paths use identical logic. The store dir is gitignored (PII).

Client: enterTrip rehydrates tripDocs + chatLog from the server
(loadTripStore, race-guarded, one-time migration of localStorage docs
from older builds), re-renders past turns in the chat panel, and
askLLM persists each turn fire-and-forget. localStorage now carries
only the plan state.

Verified end-to-end: drop 133 KB email → direct re-anchor, original +
parsed files on disk, both turns in chat.jsonl → full page reload →
doc chip, document and conversation restored from the server → question
asked without re-upload answered correctly (AK-77Q2ZD, 14C/22A).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-09 15:31:06 -04:00

2317 lines
120 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)));
// ---------------- 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.4622, -75.4385], 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;
}
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 = [];
// 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 + the 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 !== 'transit' && s.slot !== 'breakfast') removed.push(s.name); });
last.stops = last.stops.filter(s => s.slot === 'transit' || s.slot === 'breakfast');
if (!last.stops.some(s => s.slot === 'breakfast'))
last.stops.unshift({ id: 'CHKOUT', name: 'Breakfast & check-out', kind: 'meal', mealKind: 'breakfast', slot: 'breakfast', state: 'planned', at: hotelLast || [0, 0], dur: 45, durConf: 3 });
if (!last.stops.some(s => s.slot === 'transit'))
last.stops.push({ id: 'FLYOUT', name: 'Flight out — ' + (dep.airport || 'airport'), kind: 'transit', slot: 'transit', state: 'planned', at: (depAp && depAp.at) || hotelLast || [0, 0], dur: 60, durConf: 3 });
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
ids.forEach(id => rebuildLegs(days[id]));
commit('[flight] re-anchored around booked arrival + departure');
renderAll();
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;
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
if (!d.isDeparture && d.stops.length && staysForDay(d).length === 1) {
const last = d.stops[d.stops.length - 1];
if (last.kind !== 'region' && last.kind !== 'transit') {
const res = await routePts([last.at, dayBase(d).at], 'foot');
if (token !== legToken) return;
if (res) { d.retRouted = res; renderAll(); }
}
}
}
// ---------------- state ----------------
// (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 = [];
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 => `${s.checkIn.slice(8)}${s.checkOut.slice(8)} Sep`;
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 walk when one)
return Math.max(1, Math.ceil(Math.max(...list.map(o => haversine(last.at, o.at))) / 1000 / (WALK_KMH / 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) {
d.legs = [];
delete d.retRouted;
for (let i = 0; i < d.stops.length - 1; i++) {
const a = d.stops[i], b = d.stops[i + 1];
// a vague (region) stop at either end → worst-case leg until refined
if (a.kind === 'region' || b.kind === 'region') {
d.legs.push({ mode: 'foot', dur: 17, conf: 1, vague: true });
} else {
// legs touching a transit stop: fixed duration (flight + taxi/bus),
// optionally broken down via M.multimodal entries
if (a.kind === 'transit' || b.kind === 'transit') {
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 {
let h = 0; const key = a.name + '→' + b.name;
for (const c of key) h = (h * 31 + c.charCodeAt(0)) % 997;
// straight-line estimate until the router upgrades it (or proves out of coverage)
d.legs.push({ mode: 'foot', dur: 8 + h % 15, conf: 1 });
}
}
}
enrichLegs(d);
}
// ---------------- vague / region placeholder stops ----------------
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 = [];
// 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);
});
}
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(); renderBaseChip();
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(); renderBaseChip();
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 = '';
day.stops.forEach((s, i) => renderStopCard(body, s, i, flashIds));
if (day.stops.length && !day.isDeparture) {
const multi = staysForDay().length > 1;
const ret = el('div', 'leg-row', `<span class="leg-ico">🏨</span><span>back to 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);
}
renderStrip(); renderBudget(); renderNudges(); renderSugg();
}
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'));
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.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 = () => M.days.find(d => d.id === day.id).label;
// ---------------- 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));
}
function openL3(legIdx) {
closeL3();
const a = day.stops[legIdx].at, b = day.stops[legIdx + 1].at;
const leg = day.legs[legIdx];
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([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; // debounce token for the router round-trip
const upd = () => {
const p1 = m1.getLatLng(), p2 = m2.getLatLng(), p3 = m3.getLatLng();
line.setLatLngs([[p1.lat, p1.lng], [p2.lat, p2.lng], [p3.lat, p3.lng]]);
const dist = haversine([p1.lat, p1.lng], [p2.lat, p2.lng]) + haversine([p2.lat, p2.lng], [p3.lat, p3.lng]);
leg.dur = Math.max(4, Math.round(dist / 4800 * 60)); // instant straight-line estimate
leg.conf = 1;
box.innerHTML = 'leg time: est. '; box.append(tchip(leg.dur, 1));
renderBudget();
const t = ++rt;
routePts([[p1.lat, p1.lng], [p2.lat, p2.lng], [p3.lat, p3.lng]], 'foot').then(res => {
if (t !== rt || !l3) return;
if (res) { leg.dur = res.durMin; leg.conf = 3; leg.geometry = res.geometry; line.setLatLngs(res.geometry); }
box.innerHTML = 'leg time: ' + (res ? '' : 'est. ');
box.append(tchip(leg.dur, leg.conf));
renderBudget();
});
};
[m1, m2, m3].forEach(m => m.on('drag', upd));
$('#l3-title').textContent = `${day.stops[legIdx].name}${day.stops[legIdx + 1].name}`;
box.innerHTML = 'leg time: '; box.append(tchip(leg.dur, 3));
$('#l3-editor').classList.remove('hidden');
map.fitBounds(L.latLngBounds([a, b]).pad(0.4));
l3 = { layer };
const obs = setInterval(() => {
if (!$('#l3-editor').classList.contains('hidden')) return;
clearInterval(obs);
commit('edited a leg route');
renderAll();
}, 250);
}
function closeL3() {
if (l3) { map.removeLayer(l3.layer); l3 = null; }
$('#l3-editor').classList.add('hidden');
}
$('#l3-close').onclick = closeL3;
// ---------------- version history (every edit = a version) ----------------
// Both editors (user + chat) funnel through commit(): the snapshot captures
// days + stays, so undo/redo/revert is a single code path.
let version = 0;
let history = []; // [{v, label, at, days, stays}]
let hIdx = -1; // pointer into history
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) => {
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;
closeDiscover(); closeL3();
rebuildLegs(day); // snapshots may predate route enrichment — re-upgrade legs
renderHotelMks(); renderBaseChip();
renderAll();
renderVersionPill();
if (!silent) ai(`↩ Reverted to <b>v${s.v}</b> — ${s.label}. Later edits are gone from the branch; redo cant bring them back.`, 400);
}
function undo() { if (hIdx > 0) restore(hIdx - 1); }
function redo() { if (hIdx < history.length - 1) restore(hIdx + 1); }
function renderVersionPill() {
const elV = $('#verlabel'); if (!elV) return;
elV.textContent = 'v' + version;
$('#ver-undo').classList.toggle('off', hIdx <= 0);
$('#ver-redo').classList.toggle('off', hIdx >= history.length - 1);
}
// what changed between that version and the current plan (stop-level, per day)
function diffVsCurrent(snap) {
const parts = [];
M.days.forEach(md => {
const A = (snap.days[md.id] || {}).stops || [];
const B = (days[md.id] || {}).stops || [];
const aSet = new Set(A.map(x => x.id)), bSet = new Set(B.map(x => x.id));
const added = B.filter(x => !aSet.has(x.id)).map(x => x.name);
const removed = A.filter(x => !bSet.has(x.id)).map(x => x.name);
if (added.length || removed.length)
parts.push(`${md.label.split(' · ')[0]}${added.length ? ' +' + added.join(', +') : ''}${removed.length ? ' ' + removed.join(', ') : ''}`);
});
return parts.slice(0, 3);
}
function toggleVerList() {
const p = $('#verlist');
if (!p.classList.contains('hidden')) { p.classList.add('hidden'); return; }
p.innerHTML = '';
const rows = history.map((s, i) => ({ s, i })).slice(-14).reverse();
rows.forEach(({ s, i }) => {
const r = el('div', 'ver-row' + (i === hIdx ? ' cur' : ''));
const when = new Date(s.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const d = diffVsCurrent(s);
r.append(el('div', 'vr-main', `<b>v${s.v}</b> · ${when} · ${s.label}` + (i === hIdx ? ' <span class="vr-cur">current</span>' : '')));
if (d.length && i !== hIdx) r.append(el('div', 'vr-diff', d.join(' · ')));
r.onclick = () => { p.classList.add('hidden'); if (i !== hIdx) restore(i); };
p.append(r);
});
p.classList.remove('hidden');
}
$('#ver-undo').onclick = undo;
$('#ver-redo').onclick = redo;
$('#verlabel').onclick = toggleVerList;
document.addEventListener('click', e => { if (!e.target.closest('#verpill, #verlist')) $('#verlist')?.classList.add('hidden'); });
document.addEventListener('keydown', e => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'z') { e.preventDefault(); e.shiftKey ? redo() : undo(); }
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'y') { e.preventDefault(); redo(); }
});
// ---------------- day tabs ----------------
function buildDayTabs() {
const tabs = $('#daytabs'); tabs.innerHTML = '';
M.days.forEach(d => {
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', M.days[i] && M.days[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', md.label + (d.isDeparture ? ' · dep' : '')));
top.append(el('span', 'td-end', `ends ~${fmt(st.end)}`));
card.append(top);
const bar = el('div', 'td-bar');
const fill = el('div', 'td-fill');
fill.style.width = pct + '%';
fill.classList.toggle('warn', pct > 92);
bar.append(fill);
card.append(bar);
const meta = el('div', 'td-meta');
meta.append(el('span', null, `${st.count} stops${st.flex ? ' · ' + st.flex + ' ideas' : ''}`));
meta.append(el('span', 'pchip', `~${(st.dur / 60).toFixed(1)} h / ${md.wakingHours} h`));
const cur = (d.stops.find(x => x.price)?.price)?.cur || '';
if (st.price) meta.append(el('span', 'pchip', `~${st.price}${cur} spent`));
card.append(meta);
const names = d.stops.filter(s => s.state === 'planned').map(s => s.name);
card.append(el('div', 'td-names', names.slice(0, 4).join(' → ') + (names.length > 4 ? ' …' : '')));
card.onclick = () => { setDay(md.id); setScope('day'); };
body.append(card);
});
body.append(offlineCard());
}
// ---------------- on-trip offline bundle (simulated) ----------------
let offlineState = 'idle'; // idle | working | ready
function offlineCard() {
const c = el('div', 'offline-card');
if (offlineState === 'ready') {
c.append(el('div', 'oc-title', '📦 Offline bundle ready'));
c.append(el('div', 'oc-sub', `Itinerary v${version}, stop details (hours, tags) and the trip-area map tiles are on-device — today-view and navigation keep working without signal; re-planning needs connectivity.`));
} else if (offlineState === 'working') {
c.append(el('div', 'oc-title', '📦 Preparing offline bundle…'));
c.append(el('div', 'oc-sub oc-progress', 'itinerary ✓ → POI details…'));
} else {
c.append(el('div', 'oc-title', '📶 On-trip: prepare an offline bundle'));
c.append(el('div', 'oc-sub', 'Downloads the plan, stop details (opening hours run fully offline) and vector map tiles for the trip area — so the plan keeps working in thin-coverage spots.'));
const b = el('button', 'btn ghost small', 'Prepare for trip');
b.onclick = () => prepareOffline();
c.append(b);
}
return c;
}
function prepareOffline() {
offlineState = 'working';
renderTripRail();
const steps = [
['itinerary ✓ → POI details…', 500],
['POI details (hours, tags) ✓ → vector tiles…', 1200],
[`vector tiles · trip bbox (${120 + M.days.length * 40} tiles) ✓`, 2100],
];
steps.forEach(([txt, t]) => setTimeout(() => {
if (offlineState !== 'working') return;
const p = document.querySelector('.oc-progress');
if (p) p.textContent = txt;
}, t));
setTimeout(() => {
offlineState = 'ready';
const badge = $('#offline-badge');
if (badge) { badge.textContent = '📦 offline ready'; badge.classList.add('ok'); badge.title = 'Trip bundle cached on-device'; }
if (scope === 'trip') renderTripRail();
ai('Offline bundle prepared — itinerary, stop details and the trip-area map tiles are cached. On-trip it degrades gracefully: plan + navigation keep working, re-planning waits for signal.', 900);
}, 2500);
}
// ---------------- chat ----------------
function msg(role, html) {
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). 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).\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() {
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
persistT = setTimeout(() => tripStore.save(tripId, { days, stays, version, hIdx: hist.length - 1, history: hist, bookings: M.trip.bookings, title: M.trip.title }), 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];
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;
}
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(); renderBaseChip();
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 hotel chip: reflects the (possibly multiple) open base options
function renderBaseChip() {
const tb = $('#transit-block'); tb.innerHTML = '';
const b = dayBase();
const n = staysForDay().length;
const h = el('div', 'transit-chip', `<span>🏨</span><span><span class="tt">${b.name}</span> · ${stayRange(b)} <span class="ts">· ${n > 1 ? `${n} hotel options for these nights — worst-case anchoring` : 'anchors the days'} · ⚖ to compare hotels</span></span><span class="tc t-sched">base</span>`);
h.addEventListener('mouseenter', () => hotelMks.forEach(m => flashMarker(m, true)));
h.addEventListener('mouseleave', () => hotelMks.forEach(m => flashMarker(m, false)));
h.onclick = () => openDiscover('hotel');
tb.append(h);
}
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)