trips/mock/app.js
Greg Pomerantz 146c7e168c mock: redesign around the planning workflow
- one unified canvas: discovery is a drawer over the map (chips: lunch /
  dinner / afternoon / hotel), the plan rail stays visible — no more
  modal focus-mode / dimmed rail
- smoother discovery: 'you might like' suggestion block in the rail
  (unfilled slots / more-like-this-day), region placeholders open the
  drawer, chat still filters an open drawer
- frictionless replacement: every planned stop card expands to inline
  alternatives with one-click swap (incumbent demotes to backup, new
  stop takes its sequence position); candidate cards are draggable and
  drop onto a same-slot stop (swap) or the plan (hold as idea)
- multi-scale comparison: persistent bottom dock lines up 2-4
  candidates/stops side by side (walk from anchor, walk to hotel,
  duration, price, tags) at both hotel and stop scale, with choose /
  idea actions
- gentler load: states read as in plan / idea / backup, issues panel
  becomes 'nudges' (one-tap fixes, 'this day looks good' when clear),
  budget phrasing ('comfortably paced' / 'running a bit full')
- new day strip: horizontal L1 overview of the whole day (stops, legs,
  buffer, return-to-hotel) with hour axis; click a block to fly
- mobile: drawer becomes a bottom sheet, dock sits above the tab bar
2026-09-08 22:54:33 -04:00

1318 lines
63 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/* Interaction demo (redesigned). All data is fake (data.js).
*
* Design pillars (see recommendations):
* 1. ONE unified canvas — no modal "modes". Discovery is a drawer, not a
* mode-switch: the plan rail and map stay visible while browsing.
* 2. Smoother, proactive discovery — drawer chips + a "you might like"
* suggestion block in the rail, not just chat prompts.
* 3. Frictionless replacement — every stop card expands to inline
* alternatives with one-click swap, and any candidate can be dragged
* straight onto the plan (drop on a same-slot stop = swap).
* 4. Multi-scale comparison — a persistent bottom "compare dock" lines up
* any 24 candidates/stops side by side (hotel scale and stop scale).
* 5. Lower cognitive load — states read as "in plan / idea / backup",
* strict validators are rephrased as gentle nudges with one-tap fixes.
*/
const M = window.MOCK;
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)));
// ---------------- state ----------------
const days = {};
M.days.forEach(d => days[d.id] = deep(d));
let curDay = 'D1';
let day = days[curDay];
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 = deep(M.trip.stays);
let staySeq = 1;
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() {
map = L.map('map', { zoomControl: false }).setView([43.769, 11.252], 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: '🚌' };
// ---------------- 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;
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 = [];
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 if (b.id === 'S5') {
d.legs.push({ mode: 'multimodal', dur: 18, conf: 3, sub: [{ mode: 'foot', dur: 6 }, { mode: 'tram', dur: 12 }] });
} else {
let h = 0; const key = a.name + '→' + b.name;
for (const c of key) h = (h * 31 + c.charCodeAt(0)) % 997;
d.legs.push({ mode: 'foot', dur: 8 + h % 15, conf: 3 });
}
}
}
// ---------------- 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++)
legEls[i] = L.polyline([pts[i], pts[i + 1]], {
color: obj.legs[i] && obj.legs[i].mode === 'multimodal' ? '#2f6fd6' : '#2e9e5b',
weight: 5, opacity: .85
}).addTo(map);
if (pts.length && !obj.isDeparture) { // return leg to the hotel (hoverable via its rail row)
staysForDay(obj).forEach(o => {
retLine = L.polyline([pts[pts.length - 1], o.at], { 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];
if (cur.stops.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] };
map.fitBounds(L.latLngBounds([dayBase().at, ...stays.map(o => o.at), ...cur.stops.map(s => s.at)]), pad);
}
}
function highlightStop(id, on) {
const m = markers[id]; if (!m) return;
m.getElement()?.style?.setProperty('transform', on ? 'scale(1.25)' : '');
if (on) m.openTooltip(); else m.closeTooltip();
}
// pulse in place; only reposition the map if the point is out of view
function focusPoint(latlng, zoom = 16) {
if (!map.getBounds().contains(latlng)) map.flyTo(latlng, Math.max(map.getZoom(), zoom), { duration: .5 });
const ring = L.circleMarker(latlng, { radius: 16, color: '#b5533c', weight: 3, fill: false, opacity: .9 }).addTo(map);
setTimeout(() => map.removeLayer(ring), 1500);
}
const flyTo = s => focusPoint(s.at);
function tempShow(s) {
if (stopById(s.id)) return;
const m = L.circleMarker(s.at, { radius: 8, color: '#5b6472', weight: 2, fillColor: '#8b93a3', fillOpacity: .6 })
.addTo(map).bindTooltip(s.name);
tempMarkers.push(m);
m.openTooltip();
if (!map.getBounds().contains(s.at)) map.flyTo(s.at, Math.max(map.getZoom(), 15), { duration: .4 });
}
function tempClear() { tempMarkers.forEach(m => map.removeLayer(m)); tempMarkers = []; }
// ---------------- day strip (multi-scale L1 overview) ----------------
function renderStrip() {
const p = $('#daystrip'); if (!p) return;
p.innerHTML = '';
layout();
const t0 = Math.floor(day.startMin / 60) * 60;
const t1 = Math.max(layout() + 15, day.startMin + 360);
const span = t1 - t0;
const pos = t => (t - t0) / span * 100;
const track = el('div', 'strip-track');
const block = (t, dur, cls, label, tip, onClick) => {
const b = el('div', 'sb ' + cls, label);
b.style.left = pos(t) + '%';
b.style.width = Math.max(1.4, dur / span * 100) + '%';
if (tip) b.title = tip;
if (onClick) b.onclick = onClick;
track.append(b);
};
let cur = t0;
if (cur < day.startMin) block(cur, day.startMin - cur, 'gap', '', 'morning free');
day.stops.forEach((s, i) => {
if (cur < s.start) block(cur, s.start - cur, 'gap', '', 'free time');
const kindCls = s.kind === 'meal' ? 'meal' : s.kind === 'transit' ? 'transit' : s.kind === 'region' ? 'region' : 'visit';
block(s.start, s.dur, kindCls + (s.state !== 'planned' ? ' dimmed' : ''), String(i + 1),
`${s.name} · ${fmt(s.start)}${fmt(s.start + s.dur)}${s.state !== 'planned' ? ' · ' + stateLabel(s.state) : ''}`,
() => flyTo(s));
cur = s.start + s.dur;
if (day.legs[i]) { block(cur, day.legs[i].dur, 'leg', '', `leg · ${day.legs[i].dur} min`); cur += day.legs[i].dur; }
});
const ret = returnDur();
if (ret) block(cur, ret, 'hotel', '🏨', 'back to the hotel');
p.append(track);
const axis = el('div', 'strip-axis');
for (let t = t0 + 60; t < t1; t += 60) {
const h = el('span', 'tick', String(Math.floor(t / 60)).padStart(2, '0'));
h.style.left = pos(t) + '%';
axis.append(h);
}
p.append(axis);
}
// ---------------- discovery (a drawer, not a mode) ----------------
const CATS = [
{ id: 'lunch', label: 'Lunch' },
{ id: 'dinner', label: 'Dinner' },
{ id: 'visit', label: 'Afternoon' },
{ id: 'hotel', label: 'Hotel' },
];
const excluded = new Set(); // the exclusion list — survives across searches
let disc = null; // {cat, region, results}
let candMks = {};
const isExcluded = c => [...excluded].some(x => c.name.toLowerCase().includes(x) || x.includes(c.name.toLowerCase()));
// where the user is when they make a choice: the stop right before the slot,
// else the last planned stop, else the hotel
function anchorStop(slot) {
const idx = slot ? day.stops.findIndex(x => x.slot === slot) : -1;
if (idx > 0) return day.stops[idx - 1];
if (idx === 0) return { name: dayBase().name, at: dayBase().at };
const planned = day.stops.filter(x => x.state === 'planned');
if (planned.length) return planned[planned.length - 1];
return { name: dayBase().name, at: dayBase().at };
}
function poolFor(cat, region) {
let pool = (M.candidates[cat] || []).filter(c => !isExcluded(c));
if (region) pool = pool.filter(c => haversine(c.at, region.at) <= region.r);
return pool;
}
function openDiscover(cat, region = null) {
if (!M.candidates[cat]?.length) { ai(`Nothing to browse for <b>${cat}</b> here yet — try “lunch”, “afternoon” or “hotel”.`, 500); return; }
disc = { cat, region: region || null, results: poolFor(cat, region) };
$('#discover').classList.remove('hidden');
renderDiscover();
renderMap();
}
function closeDiscover(note) {
if (!disc) return;
Object.values(candMks).forEach(m => map.removeLayer(m)); candMks = {};
disc = null;
$('#discover').classList.add('hidden');
renderMap();
if (note) ai(note, 400);
}
function renderDiscover() {
const chipRow = $('#disc-chips'); chipRow.innerHTML = '';
CATS.forEach(c => {
const b = el('button', 'dchip' + (disc.cat === c.id ? ' on' : ''), c.label);
b.onclick = () => { disc = { cat: c.id, region: disc.region, results: poolFor(c.id, disc.region) }; renderDiscover(); renderMap(); };
chipRow.append(b);
});
const catLabel = CATS.find(c => c.id === disc.cat).label;
$('#disc-title').innerHTML = `<b>${catLabel}</b>${disc.region ? ' · ' + disc.region.label : ' · ' + dayLabel()}${disc.results.length} option${disc.results.length !== 1 ? 's' : ''}`;
// numbered pins on the map
Object.values(candMks).forEach(m => map.removeLayer(m)); candMks = {};
const anchor = anchorStop(disc.cat);
const isHotel = disc.cat === 'hotel';
const list = $('#disc-list'); list.innerHTML = '';
disc.results.forEach((c, i) => {
const card = candCard(c, anchor, isHotel);
list.append(card);
const mk = L.marker(c.at, {
icon: L.divIcon({ className: '', html: `<div class="candpin">${i + 1}</div>`, iconSize: [24, 32], iconAnchor: [12, 30] })
}).addTo(map).bindTooltip(c.name);
mk.on('click', () => {
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
card.classList.add('flash'); setTimeout(() => card.classList.remove('flash'), 1200);
focusPoint(c.at);
});
candMks[c.id] = mk;
});
if (!disc.results.length)
list.append(el('div', 'disc-empty', M.candidates[disc.cat]?.length
? 'Nothing left — loosen a filter in chat, or “reset”.'
: 'No options in this pool yet — try another category.'));
}
function findCand(id) {
for (const [slot, pool] of Object.entries(M.candidates)) {
const c = pool.find(x => x.id === id);
if (c) return { c, slot };
}
return null;
}
function candCard(c, anchor, isHotel) {
const card = el('div', 'candcard');
card.draggable = true;
card.addEventListener('dragstart', e => { dragCand = c; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', 'cand:' + c.id); });
card.addEventListener('dragend', () => { dragCand = null; document.querySelectorAll('.candcard.dragging, .drop-target').forEach(x => x.classList.remove('dragging', 'drop-target')); });
const head = el('div', 'cc-head');
if (c.img) {
const im = el('div', 'cc-img'); im.style.background = c.img[2];
if (c.img[3]) { const i = document.createElement('img'); i.src = c.img[3]; i.alt = c.img[1]; im.append(i); }
else im.append(el('span', 'cc-emoji', c.img[0]));
head.append(im);
}
head.append(el('div', 'cc-name', c.name));
card.append(head);
if (c.pitch) card.append(el('div', 'cc-pitch', c.pitch));
const chips = el('div', 'cc-chips');
chips.append(el('span', 'pchip', `${walkMin(anchor.at, c.at)} min from ${anchor.name}`));
if (anchor.name !== dayBase().name) chips.append(el('span', 'pchip', `${hotelWalkMin(c.at)} min to hotel${staysForDay().length > 1 ? ' (worst)' : ''}`));
if (c.dur) chips.append(el('span', 'tc t-search', '~' + fmt(c.dur)));
chips.append(el('span', 'pchip', c.price ? '~' + c.price + '€ / ' + (isHotel ? 'night' : 'person') : 'free'));
card.append(chips);
if (c.tags?.length) card.append(el('div', 'cc-tags', c.tags.map(t => '#' + t).join(' ')));
const acts = el('div', 'cc-acts');
const pick = el('button', 'btn primary small', isHotel ? 'Make this the hotel' : 'Choose this');
pick.onclick = () => chooseCand(c);
const idea = el('button', 'btn ghost small', 'Idea');
idea.onclick = () => holdIdea(c);
const cmp = el('button', 'btn ghost small', '⚖ Compare');
cmp.onclick = () => toggleCompare(itemFromCand(c, isHotel ? 'hotel' : disc.cat));
const no = el('button', 'cc-no', 'Not for me');
no.title = 'remember as a preference';
no.onclick = () => {
excluded.add(c.name.toLowerCase().includes('—') ? c.name.split('—').pop().trim() : c.name);
disc.results.splice(disc.results.indexOf(c), 1);
renderDiscover(); renderMap();
};
acts.append(pick, idea, cmp, no);
card.append(acts);
return card;
}
function makeStop(c, slot, state) {
return { id: c.id, name: c.name, kind: slot === 'visit' ? 'visit' : 'meal',
mealKind: slot === 'visit' ? undefined : slot, slot, state,
at: c.at, dur: c.dur, durConf: 3,
suggested: { value: c.dur, conf: 2, src: 'tripadvisor.com' },
price: c.price ? { amount: c.price, cur: '€', conf: 2, src: 'tripadvisor.com' } : null,
img: c.img, url: c.url };
}
// one slot, one plan: promote the candidate, demote the incumbent to backup
function promoteCand(c, slot, state = 'planned', flash = true) {
const existing = day.stops.find(x => x.id === c.id);
const stop = existing || makeStop(c, slot, state);
let incumbent = null;
if (state === 'planned') {
incumbent = day.stops.find(x => x.slot === slot && x.state === 'planned' && x !== stop) || null;
if (incumbent) incumbent.state = 'alt';
}
stop.state = state;
if (!existing) {
// take the incumbents sequence position so the days order stays sensible
const at = incumbent ? day.stops.indexOf(incumbent) : -1;
if (at >= 0) day.stops.splice(at, 0, stop); else day.stops.push(stop);
}
rebuildLegs();
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(); 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(); renderAll();
ai(`Keeping <b>${c.name}</b> open for ${stayRange(cur)} — “back to hotel” now runs worst-case across ${staysForDay().length} candidate hotels until you commit (“make this the hotel” when you decide).`, 800);
return;
}
const loose = day.stops.find(x => x.slot === slot && x.kind === 'region');
if (loose) {
const stop = makeStop(c, slot, 'maybe');
day.stops[day.stops.indexOf(loose)] = stop;
rebuildLegs(); closeDiscover(); renderAll([stop.id]);
ai(`Holding <b>${c.name}</b> as an idea — the placeholder became a specific option, off the time budget.`, 600);
return;
}
if (day.stops.some(x => x.id === c.id)) { closeDiscover(); return; }
day.stops.push(makeStop(c, slot, 'maybe'));
rebuildLegs();
closeDiscover();
renderAll([c.id]);
ai(`Holding <b>${c.name}</b> as an idea — dashed on the day, not counted against the time budget.`, 500);
}
// chat still filters an open drawer: "cheaper", "near Boboli", "no Mario"
function drawerFilter(text) {
const t = text.toLowerCase();
let res = [...disc.results];
const notes = [];
if (/cheaper|cheap|less|budget|under/.test(t)) {
const m = t.match(/under\s*€?(\d+)/);
const cap = m ? +m[1] : (disc.cat === 'hotel' ? 220 : 25); // “cheaper” means different things per slot
res = res.filter(c => c.price <= cap);
notes.push(`≤ €${cap}`);
}
if (/boboli/.test(t)) { res = res.filter(c => haversine(c.at, [43.7579, 11.2463]) < 1200); notes.push('near Boboli'); }
if (/pitti|accademia/.test(t)) { res = res.filter(c => haversine(c.at, [43.7639, 11.2518]) < 900); notes.push('near the sights you have planned'); }
if (/views|terrace|rooftop/.test(t)) { res = res.filter(c => c.tags.some(x => /view|terrace/.test(x))); notes.push('views'); }
if (/veg/.test(t)) { res = res.filter(c => c.tags.some(x => /veg|garden/.test(x))); notes.push('vegetarian-friendly'); }
if (/quiet|calm|cozy|cellar/.test(t)) { res = res.filter(c => c.tags.some(x => /quiet|calm|cozy|cellar/.test(x))); notes.push('quieter'); }
if (/free/.test(t)) { res = res.filter(c => !c.price); notes.push('free'); }
if (/fast|quick|casual/.test(t)) { res = res.filter(c => c.tags.some(x => /fast|casual/.test(x))); notes.push('fast & casual'); }
const noM = text.match(/\b(?:no|not|without|skip)\s+([a-zàé'. ]{3,30})/i);
if (noM) {
const nm = noM[1].trim();
const hit = disc.results.find(c => c.name.toLowerCase().includes(nm));
if (hit) { excluded.add(hit.name); res = res.filter(c => c !== hit); notes.push(`excluded ${hit.name}`); }
}
disc.results = res;
renderDiscover();
if (res.length) {
const a = anchorStop(disc.cat);
const closest = res.reduce((m, c) => walkMin(a.at, c.at) < walkMin(a.at, m.at) ? c : m, res[0]);
ai(`${res.length} left${notes.length ? ' — ' + notes.join(', ') : ''}. Closest to ${a.name}: <b>${closest.name}</b> (${walkMin(a.at, closest.at)} min walk). Keep filtering or tap a card.`, 600);
} else ai(`Nothing left${notes.length ? ' after ' + notes.join(', ') : ''} — loosen a filter, or say “reset” to start from the full list.`, 600);
}
$('#disc-x').onclick = () => closeDiscover('Back to the plan.');
document.addEventListener('keydown', e => { if (e.key === 'Escape') { if (disc) closeDiscover(); else closeL3(); } });
// ---------------- rail ----------------
function renderRail(flashIds = []) {
layout(); // assign start times before the cards render them
const body = $('#rail-body'); body.innerHTML = '';
day.stops.forEach((s, i) => renderStopCard(body, s, i, flashIds));
if (day.stops.length && !day.isDeparture) {
const multi = staysForDay().length > 1;
const ret = el('div', 'leg-row', `<span class="leg-ico">🏨</span><span>back to hotel</span> `);
ret.append(tchip(returnDur(), multi ? 1 : 3));
if (multi) ret.append(el('span', 'leg-sub', `worst-case across ${staysForDay().length} hotels`));
const on = { weight: 7, opacity: 1, color: '#4a5160', dashArray: null };
const off = { color: '#7a8291', weight: 4, opacity: .8, dashArray: '1 7' };
ret.addEventListener('mouseenter', () => retLines.forEach(l => l.setStyle(on)));
ret.addEventListener('mouseleave', () => retLines.forEach(l => l.setStyle(off)));
body.append(ret);
}
renderStrip(); renderBudget(); renderNudges(); renderSugg();
}
const POOL_KEY = { lunch: 'lunch', dinner: 'dinner', visit: 'visit' };
// inline alternatives for a planned stop: same-slot candidates, not already in the day
function slotAlternatives(s, n = 3) {
const key = POOL_KEY[s.slot]; if (!key) return [];
const a = anchorStop(s.slot);
return [...(M.candidates[key] || [])]
.filter(c => c.id !== s.id && !isExcluded(c) && !day.stops.some(x => x.id === c.id))
.sort((x, y) => walkMin(a.at, x.at) - walkMin(a.at, y.at)).slice(0, n);
}
function renderStopCard(body, s, i, flashIds) {
if (s.kind === 'transit') {
// booked external transport: fixed, shown only on its own day
const c = el('div', 'stop-card transit-card');
if (flashIds.includes(s.id)) c.classList.add('flash');
const top = el('div', 'sc-top');
top.append(el('span', 'num', String(i + 1)));
top.append(el('span', 'sc-name', `${s.transit.name}${s.transit.to}`));
top.append(el('span', 'sc-times', `${s.transit.arriveBy}${s.transit.depart}`));
c.append(top);
const meta = el('div', 'sc-meta');
meta.append(el('span', 'tc t-sched', 'booked'));
meta.append(el('span', 'pchip', `${s.price.amount}${s.price.cur}`));
c.append(meta);
c.append(el('div', 'sc-sub', `arrive by ${s.transit.arriveBy}${s.transit.buffer}`));
c.addEventListener('mouseenter', () => flashMarker(trainMks[0], true));
c.addEventListener('mouseleave', () => flashMarker(trainMks[0], false));
c.addEventListener('click', () => focusPoint(s.at));
body.append(c);
return;
}
const c = el('div', 'stop-card st-' + s.state);
if (flashIds.includes(s.id)) c.classList.add('flash');
const top = el('div', 'sc-top');
top.append(el('span', 'grip', '⠿'));
top.append(el('span', 'num', String(i + 1)));
top.append(el('span', 'sc-name', s.name));
top.append(el('span', 'sc-times', `${fmt(s.start)}${fmt(s.start + s.dur)}`));
c.append(top);
const meta = el('div', 'sc-meta');
if (s.state !== 'planned') {
const st = el('span', 'sc-state ' + (s.state === 'maybe' ? 't-idea' : 't-backup'), stateLabel(s.state));
meta.append(st);
}
meta.append(tchip(s.dur, s.durConf));
if (s.suggested && s.suggested.value !== s.dur && s.state === 'planned')
meta.append(el('span', 'sug-note', `usually ~${fmt(s.suggested.value)}`));
if (s.price) meta.append(el('span', 'pchip', `~${s.price.amount}${s.price.cur}`));
c.append(meta);
// vague placeholder: one tap to browse the area
if (s.region) {
c.append(el('div', 'region-note', 'placeholder — travel times are worst-case until refined'));
const rf = el('button', 'refine-btn', 'See options →');
rf.onclick = e => { e.stopPropagation(); openDiscover(POOL_KEY[s.slot] || s.slot, s.region); };
c.append(rf);
}
// quick actions: compare + alternatives (planned only) + more menu
const acts = el('div', 'sc-acts');
const cmp = el('button', 'btn ghost tiny', '⚖');
cmp.title = 'Add to comparison';
cmp.onclick = e => { e.stopPropagation(); toggleCompare(itemFromStop(s)); };
acts.append(cmp);
if (s.state === 'planned') {
const alts = slotAlternatives(s);
if (alts.length) {
const aBtn = el('button', 'btn ghost tiny', `⇄ alternatives (${alts.length})`);
const box = el('div', 'alts');
alts.forEach(cand => {
const row = el('div', 'altrow');
row.append(el('span', 'ar-name', cand.name));
row.append(el('span', 'pchip', `${walkMin(anchorStop(s.slot).at, cand.at)} min`));
row.append(el('span', 'pchip', cand.price ? '~' + cand.price + '€' : 'free'));
const sw = el('button', 'btn ghost tiny', 'Swap');
sw.onclick = e => {
e.stopPropagation();
const { incumbent } = promoteCand(cand, s.slot);
ai(`Swapped: <b>${cand.name}</b> is your ${s.slot} now${incumbent ? `; <b>${incumbent.name}</b> becomes the backup.` : ''} Times re-sequenced.`, 500);
};
const ac = el('button', 'btn ghost tiny', '⚖');
ac.title = 'Compare';
ac.onclick = e => { e.stopPropagation(); toggleCompare(itemFromCand(cand, s.slot)); };
row.append(sw, ac);
box.append(row);
});
aBtn.onclick = e => { e.stopPropagation(); box.classList.toggle('open'); };
acts.append(aBtn);
c.append(box);
}
} else {
// idea / backup: one-tap promote
const up = el('button', 'btn ghost tiny', s.state === 'alt' ? '⇄ Swap in' : '✓ Make planned');
up.onclick = e => { e.stopPropagation(); setState(s, 'planned'); };
acts.append(up);
}
const more = el('button', 'btn ghost tiny', '⋯');
more.title = 'More (move, remove, state)';
more.onclick = e => { e.stopPropagation(); openStateMenu(s, more); };
acts.append(more);
c.append(acts);
// drag & drop: reorder within day, or drop a candidate card onto this stop
c.draggable = true;
c.addEventListener('dragstart', e => {
if (dragCand) { e.preventDefault(); return; }
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', String(i)); c.classList.add('dragging');
});
c.addEventListener('dragend', () => c.classList.remove('dragging'));
c.addEventListener('dragover', e => {
e.preventDefault();
if (dragCand) c.classList.add('drop-target');
});
c.addEventListener('dragleave', () => c.classList.remove('drop-target'));
c.addEventListener('drop', e => {
e.preventDefault(); c.classList.remove('drop-target');
const data = e.dataTransfer.getData('text/plain');
if (data.startsWith('cand:')) {
const found = findCand(data.slice(5));
if (!found) return;
const { c: cand, slot } = found;
dragCand = null;
e.stopPropagation();
if (slot === s.slot) {
const { incumbent } = promoteCand(cand, slot);
if (incumbent) ai(`Dropped <b>${cand.name}</b> over its slot — its your ${slot} now; <b>${incumbent.name}</b> becomes the backup.`, 500);
else ai(`Dropped <b>${cand.name}</b> into ${dayLabel()}.`, 500);
} else {
day.stops.push(makeStop(cand, slot, 'maybe'));
rebuildLegs(); 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();
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' : '#2e9e5b', weight: 5, opacity: .85 };
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(); 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);
renderAll();
ai(`Moved <b>${s.name}</b> to ${t.label} — legs and times re-timed there.`, 500);
}
const dayLabel = () => M.days.find(d => d.id === day.id).label;
// ---------------- time budget (gentle phrasing) ----------------
function renderBudget() {
const prim = day.stops.filter(s => s.state === 'planned');
const flex = day.stops.length - prim.length;
const planned = prim.reduce((a, s) => a + s.dur, 0) + day.legs.reduce((a, g) => a + g.dur, 0)
+ (prim.length ? returnDur() : 0);
const flexMin = day.stops.filter(s => s.state !== 'planned').reduce((a, s) => a + s.dur, 0);
const end = layout();
const cap = day.wakingHours * 60;
const pct = Math.min(100, planned / cap * 100);
const fill = $('#budget-fill');
fill.style.width = pct + '%';
fill.classList.toggle('warn', pct > 92);
const mood = pct > 92 ? ' — running a bit full, theres room to trim' : pct > 80 ? '' : ' — comfortably paced';
$('#budget-text').textContent = `${(planned / 60).toFixed(1)} h of ${day.wakingHours} h · ends ~${fmt(end)}${flex ? ' · +' + (flexMin / 60).toFixed(1) + ' h of ideas' : ''}${mood}`;
}
// ---------------- nudges (issues, but gentle) ----------------
const nudgesByDay = {
D1: [
{ id: 'N1', fixed: false,
title: 'Lunch may be a little tight',
body: '45 min at Trattoria Mario — a meal there usually runs <b>~90 min</b> <i>(web)</i>, and the local queue adds to that.',
fix: { label: 'Extend to ~90 min',
apply() { const s = stopById('S4'); if (s) s.dur = 90; },
msg: 'Lunch extended to 90 min — everything after it slides later; the day still fits.' } },
{ id: 'N2', fixed: false,
title: 'Pitti: 1 h (you) vs ~2.5 h usually',
body: 'Guides suggest 23 h for the full complex. We kept your 1 h — no need to change anything.',
fix: { label: 'Keep as is', apply() {} } }
],
D2: [], D3: [
{ id: 'N3', fixed: false,
title: 'Dinner is still an idea',
body: 'Il Latte is an option, not a plan — worth deciding by this evening so theres a fallback if its full.',
fix: { label: 'Keep as is', apply() {} } }
]
};
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; n.fix.apply();
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');
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)); // 4.8 km/h walking
box.innerHTML = 'leg time: '; box.append(tchip(leg.dur, 3));
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);
renderAll();
}, 250);
}
function closeL3() {
if (l3) { map.removeLayer(l3.layer); l3 = null; }
$('#l3-editor').classList.add('hidden');
}
$('#l3-close').onclick = closeL3;
// ---------------- day tabs ----------------
function buildDayTabs() {
const tabs = $('#daytabs'); tabs.innerHTML = '';
M.days.forEach(d => {
const t = el('button', 'daytab' + (d.id === curDay ? ' sel' : ''), d.label);
t.onclick = () => setDay(d.id);
t.addEventListener('dragover', e => { e.preventDefault(); t.classList.add('over'); });
t.addEventListener('dragleave', () => t.classList.remove('over'));
t.addEventListener('drop', e => {
e.preventDefault(); t.classList.remove('over');
const i = parseInt(e.dataTransfer.getData('text/plain'), 10);
const s = day.stops[i]; if (!s) return;
day.stops.splice(i, 1);
const tDay = days[d.id];
tDay.stops.push(s);
rebuildLegs(); if (tDay !== day) rebuildLegs(tDay);
setDay(curDay);
ai(`Moved <b>${s.name}</b> to ${tDay.label}.`, 400);
});
tabs.append(t);
});
}
function setDay(id) {
curDay = id; day = days[id];
if (!day.legs.length) rebuildLegs(day);
document.querySelectorAll('.daytab').forEach((t, i) => t.classList.toggle('sel', M.days[i] && M.days[i].id === id));
closeL3(); // a selected route from another day is stale
tempClear();
if (disc) closeDiscover(); // the drawers anchor is day-specific
renderAll();
}
function renderAll(flashIds = []) { renderRail(flashIds); renderMap(); }
// ---------------- chat ----------------
function msg(role, html) {
const m = el('div', 'msg ' + role, html);
$('#chat-body').append(m);
m.scrollIntoView({ behavior: 'smooth', block: 'end' });
return m;
}
function typing() { return msg('ai', '<span class="typing"><i></i><i></i><i></i></span>'); }
function ai(html, delay = 700) {
const t = typing();
setTimeout(() => { t.remove(); msg('ai', html); scriptBusy = false; }, delay);
}
function chatSend() {
const v = $('#chat-input').value.trim();
if (!v) return;
$('#chat-input').value = '';
msg('user', v);
handleUser(v);
}
function handleUser(v) {
const s = v.toLowerCase();
if (disc) {
if (/reset/.test(s)) { disc.results = poolFor(disc.cat, disc.region); renderDiscover(); renderMap(); ai('Back to the full list.', 400); return; }
if (/done|exit|never ?mind|back to/.test(s)) { closeDiscover('Back to the plan.'); return; }
drawerFilter(v);
return;
}
if (/hotel|where.*sleep|stay/.test(s) && /find|alternative|option|another|compare|look/.test(s)) { openDiscover('hotel'); return; }
const slotWord = s.match(/(lunch|dinner|breakfast|snack)/);
const regWord = s.match(/\b(oltrarno|smn|duomo|city centre)\b/);
if (slotWord && regWord) { // “lunch near Oltrarno” → placeholder · “find a lunch in Oltrarno” → browse
if (/find|looking|search|options/.test(s)) openDiscover(slotWord[1], REGION_MAP[regWord[1]]);
else addRegionStop(slotWord[1], REGION_MAP[regWord[1]]);
return;
}
if (/find|looking|want|search|options|another|different|discover/.test(s) && slotWord) { openDiscover(slotWord[1]); return; }
if (/find|looking|want|search|options|another|different|discover/.test(s) && /activit|afternoon|things to do/.test(s)) { openDiscover('visit'); return; }
if (/compare/.test(s)) {
ai('Tap the <b>⚖</b> on any candidate, stop card, or suggestion row to line up 24 options side by side in the comparison tray at the bottom — travel from the anchor, to the hotel, price, and time, all in one glance.', 900);
return;
}
if (s.includes('later') || s.includes('morning')) {
day.startMin += 30;
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()[0];
const lunch = day.stops.find(x => x.slot === 'lunch' && x.state === 'planned');
if (n && !n.fixed && lunch) {
n.fixed = true; lunch.dur = 90;
renderAll([lunch.id]);
ai(`Done — lunch now runs 90 min. What follows it slides to ${fmt(day.stops[day.stops.indexOf(lunch) + 1]?.start ?? 0)}.`);
} else ai(`Theres nothing to loosen on ${dayLabel()} right now — the day looks good.`);
} else {
ai('I can shift times, swap stops, or move things between days — e.g. “start the day later”. For finding, the drawer chips on the map (lunch / afternoon / hotel) are always there; for swapping, expand a stop cards <b>alternatives</b> or drag a candidate straight onto the plan.', 900);
}
}
$('#chat-send').onclick = chatSend;
$('#chat-input').addEventListener('keydown', e => e.key === 'Enter' && chatSend());
// ---------------- landing → parse → plan ----------------
const SAMPLE = [
['type', 'Hotel reservation', 'lodging — anchors mornings and evenings', 'hi'],
['name', 'Hotel Palagio, Florence', 'geocoded → 20 m from SMN station', 'hi'],
['dates', '12 15 Sep 2026', '3 nights', 'hi'],
['window', 'check-in 15:00 · check-out 11:00', 'parsed from e-mail body', 'mid'],
['ref', 'Confirmation H-77821', 'read from footer image — worth a double-check', 'mid']
];
function showParse() {
const t = $('#parse-table'); t.innerHTML = '';
SAMPLE.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);
function startApp() {
$('#parse-modal').classList.add('hidden');
$('#landing').classList.add('hidden');
$('#topbar').classList.remove('hidden');
$('#app').classList.remove('hidden');
renderBaseChip();
buildDayTabs();
if (day.stops.length > 1 && !day.legs.length) rebuildLegs(day); // legs existed in data: build before first paint
renderAll();
script1();
}
// ---------------- 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();
renderAll([id]);
return state;
}
function script1() {
setTimeout(() => {
scriptBusy = true;
ai('Anchored your stay — <b>Hotel Palagio, 1215 Sep</b>; the 15ths train (09:12) is pinned as well. 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(); };
chips.append(c);
});
m.append(chips);
}, 1800);
}, 300);
}
function script2() {
scriptBusy = true;
ai('Noted — relaxed, food-first. A few ideas for your first day:', 900);
setTimeout(() => {
const m = msg('ai', '');
const cards = el('div', 'cards');
m.append(cards);
let sequenced = false;
M.suggestions.forEach((sg, i) => {
const s = M.days.flatMap(d => d.stops).find(x => x.id === sg.stop);
const card = el('div', 'scard');
const imgBox = el('div', 'scard-img');
imgBox.style.background = s.img[2];
const skel = el('div', 'skel');
const ready = () => { if (skel.isConnected) skel.remove(); imgBox.classList.add('done'); };
imgBox.append(skel, el('div', 'attr', 'Wikimedia Commons'));
if (s.img[3]) {
const im = document.createElement('img');
im.src = s.img[3]; im.alt = s.img[1];
im.onload = ready; im.onerror = ready;
imgBox.append(im);
} else {
imgBox.append(el('div', 'ph', s.img[0]));
setTimeout(ready, 1400 + i * 500);
}
const body = el('div', 'scard-body');
const title = el('div', 'scard-name');
const tl = el('a', null, s.name); tl.href = s.url; tl.target = '_blank'; tl.rel = 'noopener';
title.append(tl);
body.append(title);
body.append(el('div', 'scard-pitch', sg.pitch));
const foot = el('div', 'scard-foot');
const add = el('button', 'btn primary small', 'Add to day');
const maybeB = el('button', 'btn ghost small', 'Idea');
const pass = el('button', 'btn ghost small', 'Not for me');
const pc = el('span', 'pchip sp', s.price ? `~${s.price.amount}${s.price.cur}` : '');
const st = { done: false };
const sheetBtns = [];
const finish = labels => [add, maybeB, pass, ...sheetBtns].forEach((b, n) => { b.disabled = true; if (labels[n]) b.textContent = labels[n]; });
const doAdd = () => { if (st.done) return; st.done = true;
card.classList.add('added');
finish(['Added ✓', '', '', 'Added ✓', '', '']);
const state = addStopToDay(s.id, null);
if (state === 'alt') ai(`<b>${s.name}</b> is in as the <b>backup</b> for your ${s.slot} — one slot, one plan. Swap them any time.`, 500);
else ai(`Added <b>${s.name}</b> — Ill sequence the day once the set is done.`, 500);
if (!sequenced) { sequenced = true; setTimeout(script3, 1500); }
};
const doMaybe = () => { if (st.done) return; st.done = true;
card.classList.add('maybe-card');
finish(['', 'Holding', '', '', 'Holding', '']);
addStopToDay(s.id, 'maybe');
ai(`Holding <b>${s.name}</b> as an idea — dashed on the day, not counted against the time budget.`, 500);
if (!sequenced) { sequenced = true; setTimeout(script3, 1500); }
};
const doPass = () => { if (st.done) return; st.done = true;
card.classList.add('added', 'dropped');
finish(['', '', 'Ruled out', '', '', 'Ruled out']);
cards.appendChild(card);
const dm = document.getElementById('detail-modal'); if (dm) dm.remove();
excluded.add(s.name.split('—').pop().trim()); // feeds future slot searches
ai(`Ruled out <b>${s.name}</b> — noted as a preference.`, 500);
};
add.onclick = doAdd; maybeB.onclick = doMaybe; pass.onclick = doPass;
card.addEventListener('click', e => { if (!e.target.closest('button, a')) { flyTo(s); openDetail(s, { add: doAdd, maybe: doMaybe, pass: doPass, register: b => sheetBtns.push(...b) }); } });
card.addEventListener('mouseenter', () => tempShow(s));
card.addEventListener('mouseleave', tempClear);
foot.append(add, maybeB, pass, pc);
body.append(foot);
const en = el('div', 'scard-enrich', sg.enrich);
body.append(en);
card.append(imgBox, body);
cards.append(card);
setTimeout(() => en.classList.add('show'), 2600 + i * 500);
});
m.scrollIntoView({ behavior: 'smooth' });
}, 1900);
}
function script3() {
setTimeout(() => {
const rank = { S1: 0, S2: 1, S4: 2, S7: 2.5, S5: 3, S6: 3.5 };
day.stops.sort((a, b) => (rank[a.id] ?? 99) - (rank[b.id] ?? 99));
if (!stopById('S6')) {
const gel = deep(allStops('S6'));
day.stops.push(gel);
}
rebuildLegs();
renderAll(day.stops.map(s => s.id));
ai('Day sequenced, gelato after the gardens. <b>' + ((layout() - day.startMin) / 60).toFixed(1) + ' h</b> of 11 h waking — comfortably paced. Two gentle nudges below: lunch looks tight, and Pitti is at 1 h vs ~2.5 h usually — kept your 1 h.', 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 = () => {
const v = $('#dest').value.trim();
if (!v) { showParse(); return; }
SAMPLE[1] = ['name', v, 'geocoded', 'hi'];
showParse();
};
$('#dest').addEventListener('keydown', e => e.key === 'Enter' && $('#dest-go').click());
// 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(); renderAll([c.id]);
if (disc) closeDiscover();
ai(`Dropped <b>${c.name}</b> into ${dayLabel()} as an idea — promote it whenever you like.`, 500);
});
})();
initMap();