The real-email case exposed two gaps:
1. Confirmation friction: when the booked dates didn't match the plan,
the model asked to confirm instead of anchoring. The guideline now
says to apply directly (the user's ticket is the truth, and the edit
is undo-able) and report the date shift — verified: a dropped booking
now re-anchors in one round, and a redundant "yes" afterwards is
handled gracefully ("already done").
2. Amnesia on the follow-up: the model has no conversation memory, so a
confirming "yes" round had to re-derive the dates from the document
in context — and that copy was clipped at 20k chars. Now the recent
turns ride in the model's context (chatLog, ~12k char budget, upload
messages excluded since docSection carries the document), and the doc
is no longer clipped in-context at all (60k server cap ≪ 131k-token
window). A new guideline tells the model to apply a confirmed change
from its own earlier message rather than claiming it can't see info
it already quoted.
Extraction hardening: broader HTML detection (20k-char scan + tag
density), and a brute-force base64-block fallback in decodeMime for
mail clients with quirky MIME structure. The server now logs an
extraction preview and saves the exact model-facing text to
/tmp/parse-doc-last.txt for debugging real files.
Co-Authored-By: Claude <noreply@anthropic.com>
2248 lines
116 KiB
JavaScript
2248 lines
116 KiB
JavaScript
/* Interaction demo (redesigned). All data is fake (data.js).
|
||
*
|
||
* Design pillars (see recommendations):
|
||
* 1. ONE unified canvas — no modal "modes". Discovery is a drawer, not a
|
||
* mode-switch: the plan rail and map stay visible while browsing.
|
||
* 2. Smoother, proactive discovery — drawer chips + a "you might like"
|
||
* suggestion block in the rail, not just chat prompts.
|
||
* 3. Frictionless replacement — every stop card expands to inline
|
||
* alternatives with one-click swap, and any candidate can be dragged
|
||
* straight onto the plan (drop on a same-slot stop = swap).
|
||
* 4. Multi-scale comparison — a persistent bottom "compare dock" lines up
|
||
* any 2–4 candidates/stops side by side (hotel scale and stop scale).
|
||
* 5. Lower cognitive load — states read as "in plan / idea / backup",
|
||
* strict validators are rephrased as gentle nudges with one-tap fixes.
|
||
*/
|
||
let M = window.MOCK; // active trip dataset (reassigned on trip switch)
|
||
const $ = s => document.querySelector(s);
|
||
const el = (tag, cls, html) => { const e = document.createElement(tag); if (cls) e.className = cls; if (html != null) e.innerHTML = html; return e; };
|
||
const fmt = m => String(Math.floor(m / 60)).padStart(2, '0') + ':' + String(Math.round(m % 60)).padStart(2, '0');
|
||
const deep = o => JSON.parse(JSON.stringify(o));
|
||
const WALK_KMH = 4.6;
|
||
const walkMin = (a, b) => Math.max(1, Math.round(haversine(a, b) / 1000 / (WALK_KMH / 60)));
|
||
|
||
// ---------------- 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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:00–15:00 window`);
|
||
if (s.slot === 'dinner' && (s.start < 17 * 60 || s.start > 21 * 60)) issues.push(`${d.label}: dinner at ${s.name} starts ${fmt(s.start)} — outside a normal 17:00–21:00 window`);
|
||
});
|
||
const seen = {};
|
||
prim.forEach(s => { const k = s.name.toLowerCase(); if (seen[k] && !s.name.includes('—')) issues.push(`${d.label}: "${s.name}" appears more than once in the plan`); seen[k] = (seen[k] || 0) + 1; });
|
||
if (!prim.length) issues.push(`${d.label}: has no planned stops`);
|
||
}
|
||
return { issues: issues.length ? issues : ['no problems found'] };
|
||
},
|
||
|
||
// ---- fix-ops (mutations; each commits → undo-able) ----
|
||
add_stop: (a) => {
|
||
const SLOTS = ['lunch', 'dinner', 'visit'];
|
||
const d = days[a.dayId]; if (!d) return { error: `unknown day "${a.dayId}"; days are ${Object.keys(days).join(', ')}` };
|
||
const c = candById(a.candidateId); if (!c) return { error: `no candidate "${a.candidateId}" — call search_places first` };
|
||
const slot = SLOTS.includes(String(a.slot || c.cat).toLowerCase()) ? String(a.slot || c.cat).toLowerCase() : c.cat;
|
||
if (slot === 'hotel') return { error: 'hotels are stays, not day stops — point the user to the hotel drawer' };
|
||
const stop = makeStop(c, slot, a.state && ['planned', 'maybe', 'alt'].includes(a.state) ? a.state : 'planned');
|
||
const inc = d.stops.find(x => x.slot === slot && x.state === 'planned' && x.id !== stop.id);
|
||
if (inc && stop.state === 'planned') inc.state = 'alt'; // one slot, one plan
|
||
d.stops.push(stop);
|
||
rebuildLegs(d); commit(`[llm] ${stop.name} → ${stateLabel(stop.state)} on ${d.label}`); renderAll([stop.id]);
|
||
return { ok: true, action: 'add_stop', stop: stop.name, slot, state: stop.state, day: d.label, demoted: inc ? inc.name : null };
|
||
},
|
||
|
||
remove_stop: (a) => {
|
||
const stop = resolveStop(a.stopId); if (!stop) return { error: `no stop "${a.stopId}"` };
|
||
const d = days[Object.keys(days).find(k => days[k].stops.includes(stop))];
|
||
d.stops.splice(d.stops.indexOf(stop), 1);
|
||
rebuildLegs(d); commit(`[llm] removed ${stop.name} from ${d.label}`); renderAll();
|
||
return { ok: true, action: 'remove_stop', removed: stop.name, day: d.label };
|
||
},
|
||
|
||
move_stop: (a) => {
|
||
const stop = resolveStop(a.stopId); if (!stop) return { error: `no stop "${a.stopId}"` };
|
||
const to = days[a.toDayId]; if (!to) return { error: `unknown day "${a.toDayId}"` };
|
||
const fromId = Object.keys(days).find(k => days[k].stops.includes(stop));
|
||
days[fromId].stops.splice(days[fromId].stops.indexOf(stop), 1);
|
||
to.stops.push(stop);
|
||
rebuildLegs(days[fromId]); rebuildLegs(to);
|
||
commit(`[llm] moved ${stop.name} → ${to.label}`); renderAll();
|
||
return { ok: true, action: 'move_stop', stop: stop.name, from: days[fromId].label, to: to.label };
|
||
},
|
||
|
||
set_duration: (a) => {
|
||
const stop = resolveStop(a.stopId); if (!stop) return { error: `no stop "${a.stopId}"` };
|
||
const mins = Math.round(+a.minutes || 0); if (!mins || mins < 5) return { error: 'minutes must be a number ≥ 5' };
|
||
const d = days[Object.keys(days).find(k => days[k].stops.includes(stop))];
|
||
stop.dur = mins; stop.suggested = { value: mins, conf: 4, src: 'assistant' };
|
||
rebuildLegs(d); layout(d); commit(`[llm] ${stop.name} → ${mins} min`); renderAll([stop.id]);
|
||
return { ok: true, action: 'set_duration', stop: stop.name, minutes: mins, day: d.label, day_ends: fmt(layout(d)) };
|
||
},
|
||
|
||
set_start: (a) => {
|
||
const d = days[a.dayId]; if (!d) return { error: `unknown day "${a.dayId}"` };
|
||
const m = /^(\d{1,2}):(\d{2})$/.exec(String(a.time || '').trim()); if (!m) return { error: 'time must be HH:MM' };
|
||
const h = +m[1], mn = +m[2]; if (h > 23 || mn > 59) return { error: 'time must be HH:MM' };
|
||
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 Sep–12 Oct" | "21–28 Sep" | "21 Sep")
|
||
const RE = /(\d{1,2}\s+[A-Za-z]{3}\s*[\u2013\u2014-]\s*\d{1,2}\s+[A-Za-z]{3}|\d{1,2}\s*[\u2013\u2014-]\s*\d{1,2}\s+[A-Za-z]{3}|\d{1,2}\s+[A-Za-z]{3})\s*$/;
|
||
const t = M.trip.title.replace(RE, rng);
|
||
if (t !== M.trip.title) M.trip.title = t;
|
||
}
|
||
|
||
// 3) arrival day: start after landing + transfer; drop the meaningless
|
||
// breakfast; shorten the waking window; trim what no longer fits
|
||
first.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 (1–4 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 user’s ticket is the truth and the edit is undo-able — then report what changed, including the date shift. Only ask a clarifying question if the document is genuinely ambiguous (e.g. two candidate departure flights).\n' +
|
||
'- Stay consistent with your earlier turns: if I am confirming a change you already proposed, apply it using the details from your own earlier message — never claim you can’t see information you quoted yourself.\n\n' +
|
||
'Current plan:\n' + llmContext() + docSection());
|
||
}
|
||
|
||
// Parse a <tool>{...}</tool> block from the model output, if present.
|
||
function parseToolBlock(text) {
|
||
const m = /<tool>\s*([\s\S]*?)\s*<\/tool>/.exec(text);
|
||
if (!m) return null;
|
||
try { return JSON.parse(m[1]); } catch { return null; }
|
||
}
|
||
// A subtle activity line so the user sees the model working its tools.
|
||
function toolLine(name, args) {
|
||
const m = el('div', 'msg tool', '⚙ ' + esc(name) + (args && Object.keys(args).length ? ' <span class="ta">' + esc(JSON.stringify(args)) + '</span>' : ''));
|
||
$('#chat-body').append(m); m.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||
return m;
|
||
}
|
||
|
||
async function askLLM(v) {
|
||
if (scriptBusy) return msg('ai', 'Hold on — I’m still working on the last request.');
|
||
scriptBusy = true;
|
||
pushChatLog('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); 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 Google’s default 6)
|
||
function decodePolyline(str, precision = 5) {
|
||
const out = [];
|
||
let p = 0, lat = 0, lng = 0;
|
||
const next = () => { // one zigzag varint component
|
||
let result = 0, shift = 0, b;
|
||
do { b = str.charCodeAt(p++) - 63; result += (b & 31) << shift; shift += 5; } while (b >= 32);
|
||
return (result & 1) ? ~(result >> 1) : (result >> 1);
|
||
};
|
||
while (p < str.length) {
|
||
lat += next();
|
||
lng += next();
|
||
out.push([lng / 10 ** precision, lat / 10 ** precision]); // [lng, lat]
|
||
}
|
||
return out;
|
||
}
|
||
// route an ordered list of [lat,lng] points; null when unroutable (out of coverage)
|
||
async function routePts(points, mode = 'foot') {
|
||
if (!routerOn) return null;
|
||
const q = points.map(p => `${p[1]},${p[0]}`).join(';');
|
||
try {
|
||
const r = await fetch(`/route?mode=${mode}&router=${routerKey()}&points=${encodeURIComponent(q)}`);
|
||
const j = await r.json();
|
||
const route = j?.routes?.[0];
|
||
if (!route) return null; // NoTable / out of coverage
|
||
if (!route.distance && !route.duration) return null;
|
||
// distance 0 = both points snapped to the same node → adjacent stops
|
||
return {
|
||
durMin: Math.max(1, Math.round(route.duration / 60)),
|
||
distance: route.distance,
|
||
geometry: route.geometry ? decodePolyline(route.geometry).map(p => [p[1], p[0]]) : [], // → [lat, lng]
|
||
};
|
||
} catch { return null; }
|
||
}
|
||
let 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 incumbent’s sequence position so the day’s order stays sensible
|
||
const at = incumbent ? day.stops.indexOf(incumbent) : -1;
|
||
if (at >= 0) day.stops.splice(at, 0, stop); else day.stops.push(stop);
|
||
}
|
||
rebuildLegs();
|
||
commit(`${stop.name} → ${stateLabel(state)}${incumbent ? ' (swapped for ' + incumbent.name + ')' : ''}`);
|
||
renderAll(flash ? [stop.id] : []);
|
||
return { stop, incumbent: stop.slot === slot && stop.state === 'planned' ? day.stops.find(x => x.slot === slot && x.state === 'alt' && x !== stop) : null };
|
||
}
|
||
function chooseCand(c, slot) {
|
||
slot = slot || disc?.cat;
|
||
if (slot === 'hotel') {
|
||
const cur = dayBase();
|
||
const inRange = s => s.checkIn <= cur.checkOut && s.checkOut >= cur.checkIn; // same nights
|
||
stays = stays.filter(s => !inRange(s));
|
||
stays.push({ id: 'ST' + (++staySeq), name: c.name, at: c.at, place: cur.place,
|
||
checkIn: cur.checkIn, checkOut: cur.checkOut, state: 'booked', price: c.price });
|
||
renderHotelMks(); 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 — it’s your ${slot} now; <b>${incumbent.name}</b> becomes the backup.`, 500);
|
||
else ai(`Dropped <b>${cand.name}</b> into ${dayLabel()}.`, 500);
|
||
} else {
|
||
day.stops.push(makeStop(cand, slot, 'maybe'));
|
||
rebuildLegs(); commit(`idea: ${cand.name}`); renderAll([cand.id]);
|
||
ai(`Dropped <b>${cand.name}</b> into ${dayLabel()} as an idea (different slot) — promote it whenever you like.`, 500);
|
||
}
|
||
if (disc) closeDiscover();
|
||
return;
|
||
}
|
||
const from = parseInt(data, 10);
|
||
if (isNaN(from) || from === i) return;
|
||
const [moved] = day.stops.splice(from, 1);
|
||
day.stops.splice(i, 0, moved);
|
||
rebuildLegs();
|
||
commit(`moved ${moved.name}`);
|
||
renderAll([moved.id]);
|
||
});
|
||
c.addEventListener('mouseenter', () => highlightStop(s.id, true));
|
||
c.addEventListener('mouseleave', () => highlightStop(s.id, false));
|
||
c.addEventListener('click', e => {
|
||
if (e.target.closest('button, a')) return;
|
||
if (s.detail) openDetail(s, null);
|
||
else flyTo(s);
|
||
});
|
||
body.append(c);
|
||
if (day.legs[i]) {
|
||
const g = day.legs[i];
|
||
const lr = el('div', 'leg-row' + (s.state !== 'planned' ? ' dim' : ''));
|
||
lr.append(el('span', 'leg-ico', MODE_ICO[g.mode] || '•'));
|
||
if (g.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, there’s room to trim' : pct > 80 ? '' : ' — comfortably paced';
|
||
$('#budget-text').textContent = `${(planned / 60).toFixed(1)} h of ${day.wakingHours} h · ends ~${fmt(end)}${flex ? ' · +' + (flexMin / 60).toFixed(1) + ' h of ideas' : ''}${mood}`;
|
||
}
|
||
|
||
// ---------------- nudges (issues, but gentle) ----------------
|
||
// Data-driven (M.nudges); a runtime copy carries the per-day `fixed` flags.
|
||
const nudgesByDay = deep(M.nudges || {});
|
||
Object.values(nudgesByDay).forEach(list => list.forEach(n => n.fixed = false));
|
||
function activeNudges() { return (nudgesByDay[day.id] || []).filter(n => !n.fixed); }
|
||
function renderNudges() {
|
||
const p = $('#nudges'); if (!p) return; p.innerHTML = '';
|
||
const list = activeNudges();
|
||
if (!list.length) { p.append(el('div', 'nudge-ok', '✓ this day looks good')); }
|
||
list.forEach(n => {
|
||
const d = el('div', 'nudge');
|
||
d.append(el('div', 'nt', n.title));
|
||
d.append(el('div', 'nb', n.body));
|
||
const b = el('button', 'btn ghost small', n.fix.label);
|
||
b.onclick = () => {
|
||
n.fixed = true;
|
||
if (n.fix.stop && n.fix.dur) { const st = stopById(n.fix.stop); if (st) st.dur = n.fix.dur; }
|
||
commit(`nudge: ${n.fix.label}`);
|
||
renderAll();
|
||
if (n.fix.msg) ai(n.fix.msg, 300);
|
||
};
|
||
d.append(b);
|
||
p.append(d);
|
||
});
|
||
const badge = $('#nudge-badge');
|
||
if (badge) {
|
||
badge.innerHTML = list.length ? `💡 ${list.length} nudge${list.length > 1 ? 's' : ''}` : '✓ looking good';
|
||
badge.classList.toggle('ok', !list.length);
|
||
}
|
||
}
|
||
|
||
// ---------------- "you might like" (context-aware suggestions in the rail) ----------------
|
||
function renderSugg() {
|
||
const p = $('#sugg-block'); if (!p) return; p.innerHTML = '';
|
||
const planned = day.stops.filter(s => s.state === 'planned');
|
||
const missing = ['dinner', 'lunch', 'visit'].find(k => !planned.some(s => s.slot === k) && M.candidates[k]?.length);
|
||
let title, note, pool, slot;
|
||
if (missing) {
|
||
slot = missing;
|
||
title = `No ${missing} planned yet`;
|
||
note = `· ${dayLabel()}`;
|
||
pool = M.candidates[slot].filter(c => !isExcluded(c));
|
||
} else {
|
||
slot = 'visit';
|
||
title = 'More like this day';
|
||
note = '';
|
||
pool = (M.candidates.visit || []).filter(c => !isExcluded(c) && !day.stops.some(x => x.id === c.id));
|
||
}
|
||
if (!pool.length) return;
|
||
const a = anchorStop(slot);
|
||
const best = [...pool].sort((x, y) => walkMin(a.at, x.at) - walkMin(a.at, y.at)).slice(0, 3);
|
||
const head = el('div', 'sugg-title', `💡 ${title} <span class="sugg-note">${note}</span>`);
|
||
const open = el('button', 'sugg-open', 'open drawer');
|
||
open.onclick = () => openDiscover(slot);
|
||
head.append(open);
|
||
p.append(head);
|
||
best.forEach(c => {
|
||
const row = el('div', 'sugg-row');
|
||
row.append(el('span', 'sr-name', c.name));
|
||
row.append(el('span', 'pchip', `↦ ${walkMin(a.at, c.at)} min`));
|
||
row.append(el('span', 'pchip', c.price ? '~' + c.price + '€' : 'free'));
|
||
const 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, persisted per trip
|
||
let chatLog = []; // [{role:'user'|'assistant', text}] — recent turns, so a follow-up
|
||
// ("yes, do it") can see what the model itself said earlier
|
||
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 can’t bring them back.`, 400);
|
||
}
|
||
function undo() { if (hIdx > 0) restore(hIdx - 1); }
|
||
function redo() { if (hIdx < history.length - 1) restore(hIdx + 1); }
|
||
function renderVersionPill() {
|
||
const elV = $('#verlabel'); if (!elV) return;
|
||
elV.textContent = 'v' + version;
|
||
$('#ver-undo').classList.toggle('off', hIdx <= 0);
|
||
$('#ver-redo').classList.toggle('off', hIdx >= history.length - 1);
|
||
}
|
||
// what changed between that version and the current plan (stop-level, per day)
|
||
function diffVsCurrent(snap) {
|
||
const parts = [];
|
||
M.days.forEach(md => {
|
||
const A = (snap.days[md.id] || {}).stops || [];
|
||
const B = (days[md.id] || {}).stops || [];
|
||
const aSet = new Set(A.map(x => x.id)), bSet = new Set(B.map(x => x.id));
|
||
const added = B.filter(x => !aSet.has(x.id)).map(x => x.name);
|
||
const removed = A.filter(x => !bSet.has(x.id)).map(x => x.name);
|
||
if (added.length || removed.length)
|
||
parts.push(`${md.label.split(' · ')[0]}${added.length ? ' +' + added.join(', +') : ''}${removed.length ? ' −' + removed.join(', −') : ''}`);
|
||
});
|
||
return parts.slice(0, 3);
|
||
}
|
||
function toggleVerList() {
|
||
const p = $('#verlist');
|
||
if (!p.classList.contains('hidden')) { p.classList.add('hidden'); return; }
|
||
p.innerHTML = '';
|
||
const rows = history.map((s, i) => ({ s, i })).slice(-14).reverse();
|
||
rows.forEach(({ s, i }) => {
|
||
const r = el('div', 'ver-row' + (i === hIdx ? ' cur' : ''));
|
||
const when = new Date(s.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||
const d = diffVsCurrent(s);
|
||
r.append(el('div', 'vr-main', `<b>v${s.v}</b> · ${when} · ${s.label}` + (i === hIdx ? ' <span class="vr-cur">current</span>' : '')));
|
||
if (d.length && i !== hIdx) r.append(el('div', 'vr-diff', d.join(' · ')));
|
||
r.onclick = () => { p.classList.add('hidden'); if (i !== hIdx) restore(i); };
|
||
p.append(r);
|
||
});
|
||
p.classList.remove('hidden');
|
||
}
|
||
$('#ver-undo').onclick = undo;
|
||
$('#ver-redo').onclick = redo;
|
||
$('#verlabel').onclick = toggleVerList;
|
||
document.addEventListener('click', e => { if (!e.target.closest('#verpill, #verlist')) $('#verlist')?.classList.add('hidden'); });
|
||
document.addEventListener('keydown', e => {
|
||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'z') { e.preventDefault(); e.shiftKey ? redo() : undo(); }
|
||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'y') { e.preventDefault(); redo(); }
|
||
});
|
||
|
||
// ---------------- day tabs ----------------
|
||
function buildDayTabs() {
|
||
const tabs = $('#daytabs'); tabs.innerHTML = '';
|
||
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 drawer’s anchor is day-specific
|
||
renderAll();
|
||
}
|
||
let scope = 'day'; // 'day' (default working view) | 'trip' (L0 macro overview)
|
||
function renderAll(flashIds = []) {
|
||
if (scope === 'trip') renderTripRail();
|
||
else renderRail(flashIds);
|
||
renderMap();
|
||
}
|
||
function setScope(sc) {
|
||
scope = sc;
|
||
document.querySelectorAll('.sct').forEach(b => b.classList.toggle('on', b.dataset.sc === sc));
|
||
$('#daystrip').classList.toggle('hidden', sc === 'trip');
|
||
$('#rail-foot').classList.toggle('hidden', sc === 'trip');
|
||
renderAll();
|
||
}
|
||
$('#scope-day').onclick = () => setScope('day');
|
||
$('#scope-trip').onclick = () => setScope('trip');
|
||
|
||
// ---------------- L0 trip overview (macro scale) ----------------
|
||
function dayStats(d) {
|
||
const planned = d.stops.filter(s => s.state === 'planned');
|
||
const dur = planned.reduce((a, s) => a + s.dur, 0) + d.legs.reduce((a, g) => a + g.dur, 0)
|
||
+ (planned.length && !d.isDeparture ? returnDur(d) : 0);
|
||
return { count: planned.length, dur, end: layout(d),
|
||
price: planned.reduce((a, s) => a + (s.price?.amount || 0), 0),
|
||
flex: d.stops.filter(s => s.state !== 'planned').length, cap: d.wakingHours * 60 };
|
||
}
|
||
function renderTripRail() {
|
||
const body = $('#rail-body'); body.innerHTML = '';
|
||
const total = M.days.reduce((a, md) => a + dayStats(days[md.id]).price, 0);
|
||
const head = el('div', 'trip-head');
|
||
head.append(el('div', 'trip-title', M.trip.title));
|
||
head.append(el('div', 'trip-sub', `${M.days.length} days · ~${total} spent in-plan · bookings are hard anchors`));
|
||
body.append(head);
|
||
M.days.forEach(md => {
|
||
const d = days[md.id];
|
||
const st = dayStats(d);
|
||
const pct = Math.min(100, st.dur / st.cap * 100);
|
||
const card = el('div', 'trip-day' + (md.id === curDay ? ' cur' : ''));
|
||
const top = el('div', 'td-top');
|
||
top.append(el('span', 'td-label', 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 2–4 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(`There’s 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 card’s <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 — I’m still working on the last request.'); return; }
|
||
const note = msg('user', '📎 ' + esc(file.name) + ' · ' + Math.max(1, Math.round(file.size / 1024)) + ' KB — reading…');
|
||
let b64;
|
||
try { b64 = await fileToB64(file); }
|
||
catch { note.remove(); return msg('ai', 'Couldn’t read that file.'); }
|
||
try {
|
||
const r = await fetch('/parse-doc', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: file.name, mime: file.type || '', b64 }) });
|
||
const j = await r.json();
|
||
if (!j.ok) { note.remove(); return msg('ai', '⚠️ ' + esc(j.error || 'couldn’t read that file')); }
|
||
note.remove();
|
||
reconfigureFromDoc(j);
|
||
} catch { note.remove(); msg('ai', 'The document reader is unreachable right now.'); }
|
||
}
|
||
// attached documents stay in the model's context on EVERY turn, so follow-up
|
||
// questions ("what’s my confirmation code?", "when’s the flight out?") work
|
||
// without a re-upload
|
||
function docSection() {
|
||
// the full extracted text rides in the context every turn (it's already
|
||
// capped at 60k chars server-side — well within the 131k-token window)
|
||
const ds = Object.values(tripDocs);
|
||
if (!ds.length) return '';
|
||
return '\n\nAttached documents (the user’s own files, kept for reference on any question). For questions about their real bookings (confirmation codes, seats, prices, flight times), answer from these documents, not from the plan’s placeholder bookings:\n' +
|
||
ds.map(d => 'DOCUMENT “' + d.name + '”' + (d.truncated ? ' (extract truncated)' : '') + '\n' + (d.text || '')).join('\n\n');
|
||
}
|
||
function renderDocChips() {
|
||
const el = $('#doc-chips'); if (!el) return;
|
||
const names = Object.keys(tripDocs);
|
||
if (!names.length) { el.classList.add('hidden'); el.innerHTML = ''; return; }
|
||
el.classList.remove('hidden');
|
||
el.innerHTML = names.map(n =>
|
||
'<span class="dc" title="' + esc(n) + ' — ' + (tripDocs[n].chars || 0) + ' chars, attached ' + new Date(tripDocs[n].at || 0).toLocaleDateString() + '">📎 ' + esc(n) + ' <span class="dc-k">' + (Math.round((tripDocs[n].chars || 0) / 100) / 10) + ' KB</span></span>').join(' ');
|
||
}
|
||
function reconfigureFromDoc(j) {
|
||
// keep the document on file: it persists with the trip and rides in the
|
||
// model's context on every turn
|
||
tripDocs[j.name] = { name: j.name, kind: j.kind, chars: j.chars, text: j.text, truncated: !!j.truncated, at: Date.now() };
|
||
persistTrip(); renderDocChips();
|
||
msg('user', '📎 ' + esc(j.name) + ' <span class="src">' + (j.kind || 'doc') + ' · ' + (j.chars || 0) + ' chars — attached' + (j.truncated ? ' (truncated)' : '') + '</span>');
|
||
if (!llmOn) return msg('ai', 'I read the document (' + (j.chars || 0) + ' chars) and kept it attached, but the model is offline so I can’t act on it yet. I’ll do it as soon as it’s back.');
|
||
const prompt =
|
||
'I’ve attached a document for this trip (extracted text below). It stays on file for later questions, so no need to re-list its contents in your reply.' + (j.truncated ? ' NOTE: the extraction was truncated at the end — if a needed detail is missing, say so and ask me to paste it.' : '') + '\n\n' +
|
||
'DOCUMENT “' + (j.name || 'document') + '”\n' +
|
||
'<document>\n' + (j.text || '') + '\n</document>\n\n' +
|
||
'If this is a flight booking (or contains actual flight times), reconfigure the trip around it:\n' +
|
||
'1. Read the ARRIVAL (date, local time, airport, city) and the DEPARTURE (date, local time, airport, city). With connections, the arrival is where the trip starts (first city) and the departure where it ends (last city).\n' +
|
||
'2. Call apply_flight_anchors exactly once with those two anchors — apply it now, do not ask first (the edit is undo-able), even if the dates differ from the current plan.\n' +
|
||
'3. Confirm what changed — the new first and last day with their start/end times, any stops that had to go, and the date shift — in a couple of sentences.\n' +
|
||
'If it is NOT a flight booking, do not change the plan — just confirm it’s attached and summarize what it contains in 1–2 sentences.';
|
||
askLLM(prompt);
|
||
}
|
||
// drag & drop a file anywhere on the chat panel
|
||
(function () {
|
||
const chat = $('#chat'); if (!chat) return;
|
||
const hasFiles = e => e.dataTransfer && Array.prototype.slice.call(e.dataTransfer.types || []).indexOf('Files') >= 0;
|
||
chat.addEventListener('dragover', e => { if (hasFiles(e)) { e.preventDefault(); chat.classList.add('dropping'); } });
|
||
chat.addEventListener('dragenter', e => { if (hasFiles(e)) { e.preventDefault(); chat.classList.add('dropping'); } });
|
||
chat.addEventListener('dragleave', e => { if (!chat.contains(e.relatedTarget)) chat.classList.remove('dropping'); });
|
||
chat.addEventListener('drop', e => { e.preventDefault(); chat.classList.remove('dropping'); const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]; if (f) ingestDoc(f); });
|
||
})();
|
||
// 📎 attach button (same path as drag & drop)
|
||
const docFile = $('#doc-file');
|
||
const docAttach = $('#doc-attach');
|
||
if (docAttach && docFile) {
|
||
docAttach.onclick = () => docFile.click();
|
||
docFile.addEventListener('change', e => { const f = e.target.files && e.target.files[0]; if (f) ingestDoc(f); e.target.value = ''; });
|
||
}
|
||
|
||
// ---------------- landing → parse → plan ----------------
|
||
// parsed-booking preview, derived from the active dataset (city-aware)
|
||
function sampleRows() {
|
||
const hb = M.trip.bookings.find(b => b.type === 'hotel');
|
||
const tb = M.trip.bookings.find(b => b.type === 'train');
|
||
const rows = [
|
||
['type', 'Hotel reservation', 'lodging — anchors mornings and evenings', 'hi'],
|
||
['name', `${hb.name}, ${M.trip.places[0].name}`, 'geocoded', 'hi'],
|
||
['dates', hb.dates + ' ' + (M.trip.places[0].dates[0] || '').slice(0, 4), 'booked stay', 'hi'],
|
||
['window', 'check-in 15:00 · check-out 11:00', 'parsed from e-mail body', 'mid'],
|
||
['ref', `Confirmation ${hb.ref}`, 'read from footer image — worth a double-check', 'mid'],
|
||
];
|
||
if (tb) rows.push(['train', tb.name, `${tb.from} → ${tb.to} · dep ${tb.depart}`, 'hi']);
|
||
return rows;
|
||
}
|
||
function showParse() {
|
||
const t = $('#parse-table'); t.innerHTML = '';
|
||
sampleRows().forEach(([k, v, note, c]) => {
|
||
const tr = document.createElement('tr');
|
||
tr.innerHTML = `<td>${k}</td><td><b>${v}</b><span class="conf ${c}">${c === 'hi' ? 'confirmed' : 'verify'}</span><div style="font-size:11px;color:var(--ink2)">${note}</div></td>`;
|
||
t.append(tr);
|
||
});
|
||
$('#parse-modal').classList.remove('hidden');
|
||
}
|
||
$('#parse-cancel').onclick = () => $('#parse-modal').classList.add('hidden');
|
||
$('#parse-confirm').onclick = startApp;
|
||
|
||
// ?autostart=plan|map|chat — skips the landing screen (dev/testing)
|
||
const auto = new URLSearchParams(location.search).get('autostart');
|
||
if (auto) setTimeout(() => { startApp(); if (auto !== 'plan' && matchMedia('(max-width: 860px)').matches) document.querySelector(`#mobiletabs button[data-t=${auto}]`)?.click(); }, 120);
|
||
|
||
// ---------------- multi-trip: registry, per-trip persistence, switching --------
|
||
// All trips live in window.TRIPS. Each trip's working state (days/stays +
|
||
// version history) is persisted 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
|
||
persistT = setTimeout(() => tripStore.save(tripId, { days, stays, version, hIdx: hist.length - 1, history: hist, bookings: M.trip.bookings, title: M.trip.title, docs: tripDocs }), 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 = saved.docs ? deep(saved.docs) : {};
|
||
} else tripDocs = {};
|
||
// reset per-trip UI state
|
||
chatLog = []; compareItems = [];
|
||
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> — you’re at <b>v${version}</b> (“${history[hIdx].label}”). Your earlier edits are kept; switch trips any time from the menu, top-left.`, 600);
|
||
}
|
||
}
|
||
function switchTrip(id) {
|
||
if (id === tripId) return;
|
||
persistTrip();
|
||
$('#trip-menu').classList.add('hidden');
|
||
enterTrip(id);
|
||
setTimeout(() => map.invalidateSize(), 80);
|
||
}
|
||
$('#crumb').onclick = e => { e.stopPropagation(); $('#trip-menu').classList.toggle('hidden'); };
|
||
document.addEventListener('click', e => { if (!e.target.closest('#trip-picker')) $('#trip-menu').classList.add('hidden'); });
|
||
|
||
function startApp() {
|
||
$('#parse-modal').classList.add('hidden');
|
||
$('#landing').classList.add('hidden');
|
||
$('#topbar').classList.remove('hidden');
|
||
$('#app').classList.remove('hidden');
|
||
enterTrip(tripId);
|
||
}
|
||
|
||
// ---------------- detail sheet ----------------
|
||
// the 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> — I’ll sequence the day once the set is done.`, 500);
|
||
if (!sequenced) { sequenced = true; setTimeout(script3, 1500); }
|
||
};
|
||
const doMaybe = () => { if (st.done) return; st.done = true;
|
||
card.classList.add('maybe-card');
|
||
finish(['', 'Holding', '', '', 'Holding', '']);
|
||
addStopToDay(s.id, 'maybe');
|
||
ai(`Holding <b>${s.name}</b> as an idea — dashed on the day, not counted against the time budget.`, 500);
|
||
if (!sequenced) { sequenced = true; setTimeout(script3, 1500); }
|
||
};
|
||
const doPass = () => { if (st.done) return; st.done = true;
|
||
card.classList.add('added', 'dropped');
|
||
finish(['', '', 'Ruled out', '', '', 'Ruled out']);
|
||
cards.appendChild(card);
|
||
const dm = document.getElementById('detail-modal'); if (dm) dm.remove();
|
||
excluded.add(s.name.split('—').pop().trim()); // feeds future slot searches
|
||
ai(`Ruled out <b>${s.name}</b> — noted as a preference.`, 500);
|
||
};
|
||
add.onclick = doAdd; maybeB.onclick = doMaybe; pass.onclick = doPass;
|
||
card.addEventListener('click', e => { if (!e.target.closest('button, a')) { flyTo(s); openDetail(s, { add: doAdd, maybe: doMaybe, pass: doPass, register: b => sheetBtns.push(...b) }); } });
|
||
card.addEventListener('mouseenter', () => tempShow(s));
|
||
card.addEventListener('mouseleave', tempClear);
|
||
foot.append(add, maybeB, pass, pc);
|
||
body.append(foot);
|
||
const en = el('div', 'scard-enrich', sg.enrich);
|
||
body.append(en);
|
||
card.append(imgBox, body);
|
||
cards.append(card);
|
||
setTimeout(() => en.classList.add('show'), 2600 + i * 500);
|
||
});
|
||
m.scrollIntoView({ behavior: 'smooth' });
|
||
}, 1900);
|
||
}
|
||
function script3() {
|
||
setTimeout(() => {
|
||
rebuildLegs(day);
|
||
renderAll(day.stops.map(s => s.id));
|
||
const st = dayStats(day);
|
||
ai(`Day sequenced — <b>${(st.dur / 60).toFixed(1)} h</b> of ${day.wakingHours} h waking, ending ~${fmt(st.end)}. Anything that looks tight shows up as a nudge in the rail.`, 1100);
|
||
}, 1400);
|
||
}
|
||
|
||
// ---------------- landing wiring ----------------
|
||
const dz = $('#dropzone');
|
||
['dragenter', 'dragover'].forEach(ev => dz.addEventListener(ev, e => { e.preventDefault(); dz.classList.add('over'); }));
|
||
['dragleave', 'drop'].forEach(ev => dz.addEventListener(ev, e => { e.preventDefault(); dz.classList.remove('over'); }));
|
||
dz.addEventListener('drop', e => { if (e.dataTransfer?.files?.length) showParse(); });
|
||
$('#dz-sample').onclick = showParse;
|
||
$('#dest-go').onclick = () => { showParse(); };
|
||
$('#dest').addEventListener('keydown', e => e.key === 'Enter' && $('#dest-go').click());
|
||
// ?city= preselects the demo city on the landing screen; the demo chips switch dataset
|
||
(function () {
|
||
const c = new URLSearchParams(location.search).get('city');
|
||
if (c && M.trip.places[0]) $('#dest').value = M.trip.places[0].name;
|
||
document.querySelectorAll('#land-demo .chip').forEach(b => b.onclick = () => {
|
||
location.href = location.pathname + '?city=' + b.dataset.city;
|
||
});
|
||
})();
|
||
|
||
// drop a candidate onto the plan panel itself → hold it as an idea
|
||
(function () {
|
||
const body = $('#rail-body'); if (!body) return;
|
||
body.addEventListener('dragover', e => { if (dragCand) e.preventDefault(); });
|
||
body.addEventListener('drop', e => {
|
||
const data = e.dataTransfer.getData('text/plain');
|
||
if (!data.startsWith('cand:')) return;
|
||
e.preventDefault();
|
||
const found = findCand(data.slice(5));
|
||
if (!found) return;
|
||
const { c, slot } = found; dragCand = null;
|
||
day.stops.push(makeStop(c, slot, 'maybe'));
|
||
rebuildLegs(); commit(`idea: ${c.name}`); renderAll([c.id]);
|
||
if (disc) closeDiscover();
|
||
ai(`Dropped <b>${c.name}</b> into ${dayLabel()} as an idea — promote it whenever you like.`, 500);
|
||
});
|
||
})();
|
||
|
||
initMap();
|
||
// router status + leg enrichment happen in enterTrip() (per-trip extract)
|