From 146c7e168c7d90d33ddea26ccc7c18f591bc84e6 Mon Sep 17 00:00:00 2001 From: Greg Pomerantz Date: Tue, 8 Sep 2026 22:54:33 -0400 Subject: [PATCH] mock: redesign around the planning workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- mock/app.js | 1031 ++++++++++++++++++++++++++++++++--------------- mock/index.html | 21 +- mock/styles.css | 284 ++++++++----- 3 files changed, 882 insertions(+), 454 deletions(-) diff --git a/mock/app.js b/mock/app.js index 1e92cf2..2b17c55 100644 --- a/mock/app.js +++ b/mock/app.js @@ -1,9 +1,25 @@ -/* Interaction demo. All data is fake (data.js). */ +/* 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. + */ 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 = {}; @@ -14,6 +30,11 @@ 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 () { @@ -28,14 +49,12 @@ let map, l3 = null, scriptBusy = false; if (window.matchMedia('(max-width: 860px)').matches) setTab('plan'); })(); -// ---------------- map ---------------- -let hotelMks = [], trainMks = []; +// ---------------- 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. -// Multi-city trips = more stays, each anchoring its own dates (per-night granularity). let stays = deep(M.trip.stays); let staySeq = 1; -const covers = (s, date) => s.checkIn <= date && date <= s.checkOut; // checkout day included +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); @@ -53,9 +72,10 @@ function renderHotelMks() { const booked = o.state === 'booked'; hotelMks.push(L.marker(o.at, { icon: L.divIcon({ className: 'hotel-ico', html: `
🏨
`, iconSize: [0, 0] }) - }).addTo(map).bindTooltip(`${o.name} · ${stayRange(o)} — ${booked ? 'booked, anchors these nights' : 'option (worst-case anchor)'}`)); + }).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); @@ -136,9 +156,11 @@ function addRegionStop(slot, region) { 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(); renderRail([stop.id]); renderMap(); - ai(`Placed a placeholder for ${slot} ${region.label} — it holds the slot and reserves time; travel times are worst-case until you pick a specific place. Say “find a ${slot} ${region.label}” or tap the card to refine.`, 900); + rebuildLegs(); renderAll([stop.id]); + ai(`Placed a placeholder for ${slot} ${region.label} — it holds the slot and reserves time; travel times are worst-case 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 = {}; @@ -179,16 +201,17 @@ function renderMap() { ? `
${i + 1}
` : `
${i + 1}
`, iconSize: [26, 26], iconAnchor: [13, 13] }) - }).addTo(map).bindTooltip(`${s.name} · ${fmt(s.start)}${s.state !== 'planned' ? ' · ' + s.state : ''}`); + }).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: [410, 70] }; + : { 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); } } @@ -214,85 +237,289 @@ function tempShow(s) { } 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 ${cat} 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 = `${catLabel}${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: `
${i + 1}
`, 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(); + 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 ${c.name} 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: ${c.name} 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 ${c.name} for ${dayLabel()}${incumbent ? ` — ${incumbent.name} 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 ${c.name} 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 ${c.name} 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 ${c.name} 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}: ${closest.name} (${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) => { - if (s.state === 'alt') body.append(el('div', 'or-row', 'or')); - 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', 'num', String(i + 1))); - if (s.kind === 'transit') { - // booked external transport: fixed, shown only on its own day - 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; - } - // drag & drop reordering within the day - c.draggable = true; - c.addEventListener('dragstart', e => { 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()); - c.addEventListener('drop', e => { - e.preventDefault(); - const from = parseInt(e.dataTransfer.getData('text/plain'), 10); - if (isNaN(from) || from === i) return; - const [moved] = day.stops.splice(from, 1); - day.stops.splice(i, 0, moved); - rebuildLegs(); - renderRail([moved.id]); renderMap(); - }); - top.append(el('span', 'grip', '⠿')); - 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'); - const stateChip = el('span', 'tc t-' + s.state + ' sc-state', s.state); - stateChip.title = 'Change state'; - stateChip.onclick = e => { e.stopPropagation(); openStateMenu(s, stateChip); }; - meta.append(stateChip); - meta.append(tchip(s.dur, s.durConf)); - if (s.suggested && s.suggested.value !== s.dur && s.state === 'planned') - meta.append(el('span', 'sug-note', `sug. ~${fmt(s.suggested.value)}`)); - if (s.price) meta.append(el('span', 'pchip', `${s.price.amount}${s.price.cur}`)); - c.append(meta); - if (s.region) { - c.append(el('div', 'region-note', 'placeholder — travel times are worst-case until refined')); - const rf = el('button', 'refine-btn', 'Find a specific place →'); - rf.onclick = e => { e.stopPropagation(); enterFocus(s.slot, s.region); }; - c.append(rf); - } - c.addEventListener('mouseenter', () => highlightStop(s.id, true)); - c.addEventListener('mouseleave', () => highlightStop(s.id, false)); - c.addEventListener('click', e => { if (!e.target.closest('.sc-state, .pop')) 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); - } - }); + 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', `🏨back to hotel `); @@ -304,23 +531,188 @@ function renderRail(flashIds = []) { ret.addEventListener('mouseleave', () => retLines.forEach(l => l.setStyle(off))); body.append(ret); } - renderBudget(); renderIssues(); + 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: ${cand.name} is your ${s.slot} now${incumbent ? `; ${incumbent.name} 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 ${cand.name} over its slot — it’s your ${slot} now; ${incumbent.name} becomes the backup.`, 500); + else ai(`Dropped ${cand.name} into ${dayLabel()}.`, 500); + } else { + day.stops.push(makeStop(cand, slot, 'maybe')); + rebuildLegs(); renderAll([cand.id]); + ai(`Dropped ${cand.name} 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 (planned ⇄ maybe ⇄ alt, move day, remove) +// 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('→ Maybe', () => setState(s, 'maybe')); - mk('→ Alternative', () => setState(s, 'alt')); + mk('→ Idea', () => setState(s, 'maybe')); + mk('→ Backup', () => setState(s, 'alt')); } else if (s.state === 'maybe') { - mk('→ Planned', () => setState(s, 'planned')); - mk('→ Alternative', () => setState(s, 'alt')); + mk('→ In plan', () => setState(s, 'planned')); + mk('→ Backup', () => setState(s, 'alt')); } else { - mk('→ Planned (swap in)', () => setState(s, 'planned')); - mk('→ Maybe', () => setState(s, 'maybe')); + 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)); @@ -331,9 +723,9 @@ function openStateMenu(s, anchor) { 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 candidate in the slot becomes the plan + if (promoted) promoted.state = 'planned'; // last backup in the slot becomes the plan } - rebuildLegs(); renderRail(); renderMap(); + rebuildLegs(); renderAll(); ai(`Removed ${s.name} from ${dayLabel()}${promoted ? ` — nothing else was planned for ${slot}, so ${promoted.name} is now your ${slot}.` : '.'}`, 500); }, true); anchor.parentElement.parentElement.append(menu); // card is position:relative @@ -344,23 +736,24 @@ document.addEventListener('click', e => { if (!e.target.closest('.pop')) closeMe 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: ${s.name} is now your ${s.slot}; ${cur.name} is the alternative.`, 600); } + if (cur) { cur.state = 'alt'; ai(`Swapped: ${s.name} is now your ${s.slot}; ${cur.name} becomes the backup.`, 600); } } s.state = state; - rebuildLegs(); renderRail([s.id]); renderMap(); + rebuildLegs(); renderAll([s.id]); if (!(state === 'planned' && day.stops.find(x => x.slot === s.slot && x !== s && x.state === 'alt'))) - ai(`${s.name} is now “${state}”.`, 400); + ai(`${s.name} 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); - renderRail(); renderMap(); + renderAll(); ai(`Moved ${s.name} to ${t.label} — legs and times re-timed there.`, 500); } -function dayLabel() { return M.days.find(d => d.id === day.id).label; } +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; @@ -373,59 +766,167 @@ function renderBudget() { const fill = $('#budget-fill'); fill.style.width = pct + '%'; fill.classList.toggle('warn', pct > 92); - $('#budget-text').textContent = `${(planned / 60).toFixed(1)} h planned${flex ? ' · +' + (flexMin / 60).toFixed(1) + ' h flex' : ''} · ${day.wakingHours} h waking (ends ${fmt(end)})`; + 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}`; } -// ---------------- issues ---------------- -const issuesByDay = { +// ---------------- nudges (issues, but gentle) ---------------- +const nudgesByDay = { D1: [ - { id: 'I1', kind: 'warn', fixed: false, - title: 'Lunch may be too short', - body: '45 min at Trattoria Mario — a meal there typically runs ~90 min (web), plus the local queue.', - fix: 'Extend to ~90 min' }, - { id: 'I2', kind: 'info', fixed: false, - title: 'Pitti: 1 h (you) vs ~2.5 h suggested', - body: 'Guides suggest 2–3 h for the full complex. Kept at your 1 h.', - fix: 'Keep as is' } + { id: 'N1', fixed: false, + title: 'Lunch may be a little tight', + body: '45 min at Trattoria Mario — a meal there usually runs ~90 min (web), 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 2–3 h for the full complex. We kept your 1 h — no need to change anything.', + fix: { label: 'Keep as is', apply() {} } } ], D2: [], D3: [ - { id: 'I3', kind: 'info', fixed: false, - title: 'Dinner is “maybe”', - body: 'Il Latte is still an option, not a plan — decide by this evening so there’s a fallback if it’s full.', - fix: 'Keep as is' } + { 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 there’s a fallback if it’s full.', + fix: { label: 'Keep as is', apply() {} } } ] }; -let showResolved = false; -function renderIssues() { - const list = issuesByDay[day.id] || []; - const p = $('#issues-panel'); p.innerHTML = ''; - const active = list.filter(i => !i.fixed); - const resolved = list.filter(i => i.fixed); - $('#issue-badge').classList.toggle('ok', !active.length); - $('#issue-badge').innerHTML = active.length ? `⚠ ${active.length} issue${active.length > 1 ? 's' : ''}` : '✓ plan checks out'; - [...(showResolved ? list : active)].forEach(it => { - const d = el('div', 'issue' + (it.fixed ? ' fixed' : '')); - d.append(el('div', 'it', it.title)); - d.append(el('div', null, it.body)); - if (!it.fixed) { - const row = el('div', 'fix'); - const b = el('button', 'btn ghost small', it.fix); - b.onclick = () => { - it.fixed = true; - if (it.id === 'I1' && stopById('S4')) { stopById('S4').dur = 90; renderRail(['S4']); renderMap(); ai('Lunch extended to 90 min — everything after it slides later; the day still fits.', 300); } - renderIssues(); - }; - row.append(b); d.append(row); - } else d.append(el('div', 'fix', '✓ resolved')); +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); }); - if (resolved.length) { - const t = el('button', 'resolved-toggle', showResolved ? 'Hide resolved' : `Show resolved (${resolved.length})`); - t.onclick = () => { showResolved = !showResolved; renderIssues(); }; - p.append(t); + 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} ${note}`); + 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, `Comparing ${compareItems.length} — 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; @@ -460,7 +961,7 @@ function openL3(legIdx) { const obs = setInterval(() => { if (!$('#l3-editor').classList.contains('hidden')) return; clearInterval(obs); - renderRail(); renderMap(); + renderAll(); }, 250); } function closeL3() { @@ -469,173 +970,6 @@ function closeL3() { } $('#l3-close').onclick = closeL3; -// ---------------- focus mode (slot search, Google-Maps-style) ---------------- -const excluded = new Set(); // the exclusion list — survives across searches -let focus = null; // {slot, results} -let candMks = {}; - -function isExcluded(c) { return [...excluded].some(x => c.name.toLowerCase().includes(x) || x.includes(c.name.toLowerCase())); } - -function enterFocus(slot, region) { - if (focus) exitFocus(); - let pool = (MOCK.candidates[slot] || []).filter(c => !isExcluded(c)); - if (region) pool = pool.filter(c => haversine(c.at, region.at) <= region.r); - const gone = (MOCK.candidates[slot] || []).length - pool.length; - focus = { slot, region: region || null, results: pool }; - $('#rail').classList.add('dimmed'); - $('#focusbar').classList.remove('hidden'); - renderFocus(); - ai(`Here are ${pool.length} ${slot === 'visit' ? 'afternoon ideas' : slot + ' options'}${region ? ` in ${region.label}` : ''} on the map${gone ? ` — ${gone} outside the area or excluded` : ''}. Click a pin to open it; keep talking to filter: “cheaper”, “views”, “no <name>”.`, 700); -} -function exitFocus(note) { - if (!focus) return; - Object.values(candMks).forEach(m => map.removeLayer(m)); candMks = {}; - focus = null; - $('#rail').classList.remove('dimmed'); - $('#focusbar').classList.add('hidden'); - if (note) ai(note, 400); -} -const WALK_KMH = 4.6; -function walkMin(from, to) { return Math.max(1, Math.round(haversine(from, to) / 1000 / (WALK_KMH / 60))); } -// where the user is when they make this choice: the stop right before the slot, else the last planned stop, else the hotel -function focusAnchor() { - const idx = day.stops.findIndex(x => x.slot === focus.slot); - if (idx > 0) return day.stops[idx - 1]; - if (idx < 0) { - 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 renderFocus() { - const label = focus.slot === 'visit' ? 'afternoon' : focus.slot; - $('#focusbar-title').innerHTML = `🔍 ${label}${focus.region ? ' · ' + focus.region.label : ''} · ${dayLabel()} — ${focus.results.length} option${focus.results.length !== 1 ? 's' : ''}${excluded.size ? ` · ${excluded.size} excluded` : ''}`; - Object.values(candMks).forEach(m => map.removeLayer(m)); candMks = {}; - const anchor = focusAnchor(); - const fromHotel = anchor.name !== dayBase().name; - const isHotel = focus.slot === 'hotel'; - focus.results.forEach(c => { - const dist = `↦ ${walkMin(anchor.at, c.at)} min walk from ${anchor.name}` - + (fromHotel ? `→ ${hotelWalkMin(c.at)} min to hotel${staysForDay().length > 1 ? ' (worst)' : ''}` : ''); - const html = `
-
${c.name}
-
${c.pitch}
-
${dist}
-
${c.dur ? `~${fmt(c.dur)}` : ''}${c.price ? '~' + c.price + '€ / ' + (isHotel ? 'night' : 'person') : 'free'}
-
- - -
-
`; - const mk = L.marker(c.at, { - icon: L.divIcon({ className: '', html: `
${c.name}
`, - iconSize: [26, 36], iconAnchor: [13, 34] }) - }).addTo(map); - mk.bindPopup(html, { maxWidth: 280, closeButton: true }); - mk.on('popupopen', e => { - const p = e.popup.getElement(); - p.querySelector('.fp-choose').onclick = () => chooseCand(c); - p.querySelector('.fp-maybe').onclick = () => maybeCand(c); - }); - candMks[c.id] = mk; - }); -} -function makeStop(c, state) { - return { id: c.id, name: c.name, kind: focus.slot === 'visit' ? 'visit' : 'meal', - mealKind: focus.slot === 'visit' ? undefined : focus.slot, slot: focus.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 }; -} -function chooseCand(c) { - if (focus.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(); - exitFocus(); renderRail(); renderMap(); - ai(`Now staying at ${c.name} 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 slot = focus.slot; - const loose = day.stops.find(x => x.slot === slot && x.kind === 'region'); - const existing = loose || day.stops.find(x => x.slot === slot && x.state === 'planned'); - const stop = makeStop(c, 'planned'); - if (existing) { - if (existing.kind !== 'region') existing.state = 'alt'; // a placeholder is replaced, not demoted - day.stops[day.stops.indexOf(existing)] = stop; - } else day.stops.push(stop); - rebuildLegs(); - exitFocus(); - renderRail([stop.id]); renderMap(); - ai(loose - ? `Refined: ${c.name} is your ${slot} now — the placeholder is gone and the leg times are real.` - : `Locked in ${c.name} for ${dayLabel()}${existing ? ` — ${existing.name} drops to alternative: one ${slot}, one plan.` : '. See it in the day.'}`, 600); -} -function maybeCand(c) { - if (focus.slot === 'hotel') { - if (stays.some(o => o.name === c.name)) { exitFocus(); 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(); - exitFocus(); renderRail(); renderMap(); - ai(`Keeping ${c.name} 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 === focus.slot && x.kind === 'region'); - if (loose) { - const stop = makeStop(c, 'maybe'); - day.stops[day.stops.indexOf(loose)] = stop; - rebuildLegs(); exitFocus(); renderRail([stop.id]); renderMap(); - ai(`Holding ${c.name} as a maybe — the placeholder became a specific option, off the time budget.`, 600); - return; - } - if (day.stops.some(x => x.id === c.id)) { exitFocus(); return; } - day.stops.push(makeStop(c, 'maybe')); - rebuildLegs(); - exitFocus(); - renderRail([c.id]); renderMap(); - ai(`Holding ${c.name} as an option — dashed, off the time budget.`, 500); -} -function focusFilter(text) { - const t = text.toLowerCase(); - let res = [...focus.results]; - const notes = []; - if (/cheaper|cheap|less|budget|under/.test(t)) { - const m = t.match(/under\s*€?(\d+)/); - const cap = m ? +m[1] : (focus.slot === '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 = focus.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}`); } - } - focus.results = res; - renderFocus(); - if (res.length) { - const a = focusAnchor(); - 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}: ${closest.name} (${walkMin(a.at, closest.at)} min walk). Keep filtering or click a pin.`, 600); - } else ai(`Nothing left${notes.length ? ' after ' + notes.join(', ') : ''} — loosen a filter, or say “reset” to start from the full list.`, 600); -} -$('#focusbar-x').onclick = () => exitFocus('Back to the plan.'); -document.addEventListener('keydown', e => { if (e.key === 'Escape') exitFocus(); }); - // ---------------- day tabs ---------------- function buildDayTabs() { const tabs = $('#daytabs'); tabs.innerHTML = ''; @@ -664,8 +998,10 @@ function setDay(id) { 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(); - renderRail(); renderMap(); + if (disc) closeDiscover(); // the drawer’s anchor is day-specific + renderAll(); } +function renderAll(flashIds = []) { renderRail(flashIds); renderMap(); } // ---------------- chat ---------------- function msg(role, html) { @@ -688,36 +1024,40 @@ function chatSend() { } function handleUser(v) { const s = v.toLowerCase(); - if (focus) { - if (/reset/.test(s)) { excluded.clear(); enterFocus(focus.slot, focus.region); return; } - if (/done|exit|never ?mind|back to/.test(s)) { exitFocus('Back to the plan.'); return; } - focusFilter(v); + 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)) { enterFocus('hotel'); 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” → refine - if (/find|looking|search|options/.test(s)) enterFocus(slotWord[1], REGION_MAP[regWord[1]]); + 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/.test(s) && slotWord) { enterFocus(slotWord[1]); return; } - if (/find|looking|want|search|options|another|different/.test(s) && /activit|afternoon|things to do/.test(s)) { enterFocus('visit'); 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 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; - renderRail(day.stops.map(x => x.id)); renderMap(); + renderAll(day.stops.map(x => x.id)); ai(`Pushed ${dayLabel()} back 30 min — everything re-timed. Day now ends at ${fmt(layout())}.`); } else if (s.includes('lunch') || s.includes('longer')) { - const it = (issuesByDay[day.id] || [])[0]; + const n = activeNudges()[0]; const lunch = day.stops.find(x => x.slot === 'lunch' && x.state === 'planned'); - if (it && !it.fixed && lunch) { - it.fixed = true; lunch.dur = 90; - renderRail([lunch.id]); renderMap(); renderIssues(); + 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(`There’s no time issue on ${dayLabel()} right now — the issues panel is clear.`); + } else ai(`There’s nothing to loosen on ${dayLabel()} right now — the day looks good.`); } else { - ai('I can shift times, swap stops, move things between days, or re-route a leg — e.g. “start the day later” or “make lunch longer”. You can also drag stops by the ⠿ grip, drop one on a day tab, or click a leg to edit its route.'); + 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 alternatives or drag a candidate straight onto the plan.', 900); } } $('#chat-send').onclick = chatSend; @@ -755,7 +1095,7 @@ function startApp() { renderBaseChip(); buildDayTabs(); if (day.stops.length > 1 && !day.legs.length) rebuildLegs(day); // legs existed in data: build before first paint - renderRail(); renderMap(); + renderAll(); script1(); } @@ -765,9 +1105,10 @@ function renderBaseChip() { const tb = $('#transit-block'); tb.innerHTML = ''; const b = dayBase(); const n = staysForDay().length; - const h = el('div', 'transit-chip', `🏨${b.name} · ${stayRange(b)} · ${n > 1 ? `${n} hotel options for these nights — worst-case anchoring` : 'anchors the days'}base`); + const h = el('div', 'transit-chip', `🏨${b.name} · ${stayRange(b)} · ${n > 1 ? `${n} hotel options for these nights — worst-case anchoring` : 'anchors the days'} · ⚖ to compare hotelsbase`); 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); } @@ -803,7 +1144,7 @@ function openDetail(s, actions) { 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', 'Maybe'); m2.onclick = actions.maybe; + 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]); @@ -830,14 +1171,14 @@ function addStopToDay(id, requested) { copy.state = state; day.stops.push(copy); rebuildLegs(); - renderRail([id]); renderMap(); + renderAll([id]); return state; } function script1() { setTimeout(() => { scriptBusy = true; - ai('Anchored your stay — Hotel Palagio, 12–15 Sep; the 15th’s train (09:12) is pinned as well. What kind of trip are you after?', 900); + ai('Anchored your stay — Hotel Palagio, 12–15 Sep; the 15th’s 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'); @@ -883,7 +1224,7 @@ function script2() { 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', 'Maybe'); + 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 }; @@ -893,7 +1234,7 @@ function script2() { card.classList.add('added'); finish(['Added ✓', '', '', 'Added ✓', '', '']); const state = addStopToDay(s.id, null); - if (state === 'alt') ai(`${s.name} is in as the alternative for your ${s.slot} — one slot, one plan. Swap them any time.`, 500); + if (state === 'alt') ai(`${s.name} is in as the backup for your ${s.slot} — one slot, one plan. Swap them any time.`, 500); else ai(`Added ${s.name} — I’ll sequence the day once the set is done.`, 500); if (!sequenced) { sequenced = true; setTimeout(script3, 1500); } }; @@ -901,7 +1242,7 @@ function script2() { card.classList.add('maybe-card'); finish(['', 'Holding', '', '', 'Holding', '']); addStopToDay(s.id, 'maybe'); - ai(`Holding ${s.name} as an option — dashed on the day, not counted against the time budget.`, 500); + ai(`Holding ${s.name} 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; @@ -936,8 +1277,8 @@ function script3() { day.stops.push(gel); } rebuildLegs(); - renderRail(day.stops.map(s => s.id)); renderMap(); - ai('Day sequenced, gelato after the gardens. ' + ((layout() - day.startMin) / 60).toFixed(1) + ' h of 11 h waking. Two flags below: lunch looks tight, and Pitti is at 1 h vs ~2.5 h usually — kept your 1 h.', 1100); + renderAll(day.stops.map(s => s.id)); + ai('Day sequenced, gelato after the gardens. ' + ((layout() - day.startMin) / 60).toFixed(1) + ' h 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); } @@ -955,4 +1296,22 @@ $('#dest-go').onclick = () => { }; $('#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 ${c.name} into ${dayLabel()} as an idea — promote it whenever you like.`, 500); + }); +})(); + initMap(); diff --git a/mock/index.html b/mock/index.html index 01702cd..643e64d 100644 --- a/mock/index.html +++ b/mock/index.html @@ -46,20 +46,22 @@
- 0 issues + ✓ looking good