diff --git a/mock/app.js b/mock/app.js index 6983713..046a41c 100644 --- a/mock/app.js +++ b/mock/app.js @@ -20,6 +20,13 @@ const fmt = m => String(Math.floor(m / 60)).padStart(2, '0') + ':' + String(Math const deep = o => JSON.parse(JSON.stringify(o)); const WALK_KMH = 4.6; const walkMin = (a, b) => Math.max(1, Math.round(haversine(a, b) / 1000 / (WALK_KMH / 60))); +// how the traveler gets between stops: on foot, or by taxi. Affects every +// on-ground leg's estimate + routing profile (the router is asked for the +// matching OSRM profile; out of coverage → straight-line estimate at the +// mode's average speed). Airport↔hotel transfers are always by car. +let travelMode = 'foot'; // 'foot' | 'car' — loaded per-app in enterTrip +const legSpeedKmh = m => (m === 'car' ? 26 : WALK_KMH); // urban taxi incl. traffic/parking +const estLegMin = (m, a, b) => Math.max(1, Math.round(haversine(a, b) / 1000 / (legSpeedKmh(m) / 60))); // ---------------- route client (local OSRM via the /route proxy) ---------- // Legs start life as straight-line estimates (conf 1) and are upgraded in the @@ -191,6 +198,32 @@ function redateStaysAndPlaces() { if (st) b.dates = stayRange(st); }); } +// re-derive the arrival/departure anchors for plans saved before the day +// objects carried them: the flights are in the bookings, so the airport card + +// ground transfer can be rebuilt on load (idempotent — fresh plans are a no-op) +function healTravelAnchors() { + const flights = (M.trip.bookings || []).filter(b => b.type === 'flight'); + const ids = Object.keys(days).sort(); + if (!ids.length || !flights.length) return; + const first = days[ids[0]], last = days[ids[ids.length - 1]]; + const hotelFirst = (first.base && first.base.at) || (stays[0] && stays[0].at); + const hotelLast = (last.base && last.base.at) || (stays[stays.length - 1] && stays[stays.length - 1].at); + const codeOf = s => (String(s || '').match(/\b([A-Z]{3})\b/) || [])[1] || ''; + const arr = flights.find(b => b.ref === 'arr-booking') || flights.find(b => b.stations && !b.stations.from && b.stations.to); + const dep = flights.find(b => b.ref === 'dep-booking') || flights.find(b => b.stations && b.stations.from && !b.stations.to); + if (arr && !first.arrivalFlight) { + const ap = arr.stations && arr.stations.to; + first.isArrival = true; + first.arrivalFlight = { code: codeOf(arr.to) || codeOf(arr.name) || arr.to || 'airport', at: ap || null, time: arr.arrive || '', flight: String(arr.name || '').split(' · ')[0], from: arr.from || '', to: arr.to || '' }; + first.transferIn = { at: ap || null, hotelAt: hotelFirst, dur: (ap && hotelFirst) ? (driveMin({ at: ap }, hotelFirst) || 60) : 60 }; + } + if (dep && !last.departureFlight) { + const ap = dep.stations && dep.stations.from; + last.isDeparture = true; + last.departureFlight = { code: codeOf(dep.from) || codeOf(dep.name) || dep.from || 'airport', at: ap || null, time: dep.depart || '', flight: String(dep.name || '').split(' · ')[0], from: dep.from || '', to: dep.to || '' }; + last.transferOut = { at: ap || null, hotelAt: hotelLast, dur: (ap && hotelLast) ? (driveMin({ at: ap }, hotelLast) || 60) : 60 }; + } +} function rangeStr(a, b) { const f = s => { const [y, mo, da] = s.split('-').map(Number); return da + ' ' + MONTHS[mo - 1]; }; return a && b ? (a === b ? f(a) : f(a) + '–' + f(b)) : ''; @@ -342,6 +375,15 @@ const TOOLS = { const arrTransfer = driveMin(arrAp, hotelFirst) != null ? driveMin(arrAp, hotelFirst) : 60; // land → hotel const depTransfer = driveMin(depAp, hotelLast) != null ? driveMin(depAp, hotelLast) : 60; // hotel → airport const removed = [], notes = []; + // remember the anchors ON the day objects so the rail can render the + // airport card + the ground transfer in the right place. The flights are + // bookings (hard anchors), not stops — the day starts/ends with them. + first.isArrival = true; + first.arrivalFlight = { code: arrAp ? arrAp.code : (arr.airport || arr.city || 'airport'), at: arrAp ? arrAp.at : null, time: arr.time, flight: arr.flight || '', from: arr.from || '', to: arr.city || '' }; + first.transferIn = { at: arrAp ? arrAp.at : null, hotelAt: hotelFirst, dur: arrTransfer }; + last.isDeparture = true; + last.departureFlight = { code: depAp ? depAp.code : (dep.airport || dep.city || 'airport'), at: depAp ? depAp.at : null, time: dep.time, flight: dep.flight || '', from: dep.city || '', to: dep.to || '' }; + last.transferOut = { at: depAp ? depAp.at : null, hotelAt: hotelLast, dur: depTransfer }; // 1) re-date across the real [arrival, departure] window — spread the plan's // days evenly between the booked first and last date (contiguous when the @@ -394,16 +436,21 @@ const TOOLS = { first.wakingHours = Math.max(2, Math.round((arrEnd - first.startMin) / 60)); trimDayTo(first, first.startMin + first.wakingHours * 60, removed); - // 4) departure day: keep only check-out + the flight; size the flight block - // so the day lands exactly on the booked departure time + // 4) departure day: keep only check-out, and REPLACE the (placeholder) + // transit stop with the user's actual departure flight; size the flight + // block so the day lands exactly on the booked departure time last.isDeparture = true; last.startMin = Math.min(last.startMin || 540, 8 * 60); - last.stops.forEach(s => { if (s.slot !== 'transit' && s.slot !== 'breakfast') removed.push(s.name); }); - last.stops = last.stops.filter(s => s.slot === 'transit' || s.slot === 'breakfast'); - if (!last.stops.some(s => s.slot === 'breakfast')) - last.stops.unshift({ id: 'CHKOUT', name: 'Breakfast & check-out', kind: 'meal', mealKind: 'breakfast', slot: 'breakfast', state: 'planned', at: hotelLast || [0, 0], dur: 45, durConf: 3 }); - if (!last.stops.some(s => s.slot === 'transit')) - last.stops.push({ id: 'FLYOUT', name: 'Flight out — ' + (dep.airport || 'airport'), kind: 'transit', slot: 'transit', state: 'planned', at: (depAp && depAp.at) || hotelLast || [0, 0], dur: 60, durConf: 3 }); + last.stops.forEach(s => { if (s.slot !== 'breakfast') removed.push(s.name); }); + last.stops = last.stops.filter(s => s.slot === 'breakfast'); + if (!last.stops.length) + last.stops.push({ id: 'CHKOUT', name: 'Breakfast & check-out', kind: 'meal', mealKind: 'breakfast', slot: 'breakfast', state: 'planned', at: hotelLast || [0, 0], dur: 45, durConf: 3 }); + last.stops.push({ + id: 'FLYOUT', name: 'Flight out — ' + (depAp ? depAp.code : (dep.airport || 'airport')), kind: 'transit', slot: 'transit', state: 'planned', + at: (depAp && depAp.at) || hotelLast || [0, 0], dur: 60, durConf: 3, + transit: { name: dep.flight || 'flight', to: dep.to || (depAp ? depAp.code : (dep.city || 'airport')), depart: dep.time, arriveBy: fmt(Math.max(0, depMin - 60)), buffer: '1 h before departure' }, + price: null, + }); rebuildLegs(last); let pre = last.startMin; for (let i = 0; i < last.stops.length; i++) { @@ -422,7 +469,7 @@ const TOOLS = { persistTrip(true); renderAll(); buildDayTabs(); - renderBaseChip(); + renderModeToggle(); renderTripMenu(); renderVersionPill(); const fs2 = dayStats(first), ls2 = dayStats(last); @@ -578,11 +625,11 @@ async function enrichLegs(d = day) { renderAll(); } } - // return-to-hotel leg when a single stay anchors the day + // return-to-hotel leg when a single stay anchors the day (uses the travel mode) if (!d.isDeparture && d.stops.length && staysForDay(d).length === 1) { const last = d.stops[d.stops.length - 1]; if (last.kind !== 'region' && last.kind !== 'transit') { - const res = await routePts([last.at, dayBase(d).at], 'foot'); + const res = await routePts([last.at, dayBase(d).at], travelMode); if (token !== legToken) return; if (res) { d.retRouted = res; renderAll(); } } @@ -687,26 +734,31 @@ function returnDur(d = day) { // a single stay anchored the day and the router has a real walk → use it if (d.retRouted && staysForDay(d).length === 1 && last.kind !== 'transit') return d.retRouted.durMin; const list = staysForDay(d); - // worst case across every candidate stay for these nights (equals the real walk when one) - return Math.max(1, Math.ceil(Math.max(...list.map(o => haversine(last.at, o.at))) / 1000 / (WALK_KMH / 60))); + // worst case across every candidate stay for these nights (equals the real leg when one) + return Math.max(1, Math.ceil(Math.max(...list.map(o => haversine(last.at, o.at))) / 1000 / (legSpeedKmh(travelMode) / 60))); } function layout(d = day) { let t = d.startMin; d.stops.forEach((s, i) => { s.start = t; t += s.dur; if (d.legs[i]) t += d.legs[i].dur; }); return t + returnDur(d); } -function rebuildLegs(d = day) { +function rebuildLegs(d = day, enrich = true) { d.legs = []; delete d.retRouted; for (let i = 0; i < d.stops.length - 1; i++) { const a = d.stops[i], b = d.stops[i + 1]; // a vague (region) stop at either end → worst-case leg until refined if (a.kind === 'region' || b.kind === 'region') { - d.legs.push({ mode: 'foot', dur: 17, conf: 1, vague: true }); + d.legs.push({ mode: travelMode, dur: travelMode === 'car' ? 9 : 17, conf: 1, vague: true }); } else { // legs touching a transit stop: fixed duration (flight + taxi/bus), // optionally broken down via M.multimodal entries if (a.kind === 'transit' || b.kind === 'transit') { + // ground transfer INTO the departure flight (hotel → airport) — always by car + if (b.kind === 'transit' && d.isDeparture && d.transferOut) { + d.legs.push({ mode: 'car', dur: d.transferOut.dur, conf: 2, toAirport: true }); + continue; + } const mm = (M.multimodal || []).find(x => x.into === b.id) || (M.multimodal || []).find(x => x.into === a.id); d.legs.push(mm @@ -718,14 +770,13 @@ function rebuildLegs(d = day) { if (mm) { d.legs.push({ mode: 'multimodal', dur: mm.dur, conf: 3, sub: mm.sub }); } else { - let h = 0; const key = a.name + '→' + b.name; - for (const c of key) h = (h * 31 + c.charCodeAt(0)) % 997; - // straight-line estimate until the router upgrades it (or proves out of coverage) - d.legs.push({ mode: 'foot', dur: 8 + h % 15, conf: 1 }); + // straight-line estimate at the current travel mode's speed, until the + // router upgrades it (or proves out of coverage) + d.legs.push({ mode: travelMode, dur: estLegMin(travelMode, a.at, b.at), conf: 1 }); } } } - enrichLegs(d); + if (enrich) enrichLegs(d); } // ---------------- vague / region placeholder stops ---------------- @@ -1036,7 +1087,7 @@ function chooseCand(c, slot) { 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(); + renderHotelMks(); renderModeToggle(); closeDiscover(); commit(`hotel → ${c.name}`); 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; @@ -1059,7 +1110,7 @@ function holdIdea(c, slot) { 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(); + renderHotelMks(); renderModeToggle(); closeDiscover(); commit(`hotel option: ${c.name}`); 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; @@ -1119,20 +1170,77 @@ document.addEventListener('keydown', e => { if (e.key === 'Escape') { if (disc) function renderRail(flashIds = []) { layout(); // assign start times before the cards render them const body = $('#rail-body'); body.innerHTML = ''; + // a travel day OPENS with the arrival: airport card, then the ride in to drop bags + if (day.isArrival && day.arrivalFlight) renderArrivalHeader(body); day.stops.forEach((s, i) => renderStopCard(body, s, i, flashIds)); + // every day but the departure closes back at the hotel — the ride back, then + // the hotel card for the night, in the timeline where it belongs if (day.stops.length && !day.isDeparture) { - const multi = staysForDay().length > 1; - const ret = el('div', 'leg-row', `🏨back to hotel `); - 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); + renderReturnRow(body); + renderHotelCard(body); } renderStrip(); renderBudget(); renderNudges(); renderSugg(); } + +// arrival-day header: the flight you landed on, then the car to the hotel +function renderArrivalHeader(body) { + const f = day.arrivalFlight; if (!f) return; + const c = el('div', 'stop-card struct-card arr-card'); + const top = el('div', 'sc-top'); + top.append(el('span', 'leg-ico', '✈️')); + top.append(el('span', 'sc-name', 'Landed — ' + (f.code || f.to || 'airport'))); + top.append(el('span', 'sc-times', 'arr ' + f.time)); + c.append(top); + const meta = el('div', 'sc-meta'); + meta.append(el('span', 'tc t-sched', 'booked')); + if (f.flight) meta.append(el('span', 'pchip', f.flight)); + if (f.from) meta.append(el('span', 'leg-sub', 'from ' + f.from)); + c.append(meta); + if (f.at) c.addEventListener('click', () => focusPoint(f.at)); + body.append(c); + // the ride to the hotel to drop bags — always by car, before the first stop + const t = day.transferIn || {}; + const tr = el('div', 'leg-row struct-leg'); + tr.append(el('span', 'leg-ico', '🚗')); + tr.append(document.createTextNode('to ' + (dayBase() ? dayBase().name : 'hotel') + ' — drop bags')); + tr.append(document.createTextNode(' ')); + tr.append(tchip(t.dur || 30, 2)); + body.append(tr); +} + +// the ride back to the hotel at the end of a (non-departure) day +function renderReturnRow(body) { + const multi = staysForDay().length > 1; + const ret = el('div', 'leg-row', `${MODE_ICO[travelMode] || '•'}back to ${dayBase() ? dayBase().name : 'hotel'} `); + ret.append(tchip(returnDur(), multi ? 1 : 3)); + if (multi) ret.append(el('span', 'leg-sub', `worst-case across ${staysForDay().length} hotels`)); + const on = { weight: 7, opacity: 1, color: '#4a5160', dashArray: null }; + const off = { color: '#7a8291', weight: 4, opacity: .8, dashArray: '1 7' }; + ret.addEventListener('mouseenter', () => retLines.forEach(l => l.setStyle(on))); + ret.addEventListener('mouseleave', () => retLines.forEach(l => l.setStyle(off))); + body.append(ret); +} + +// the hotel for tonight — in the timeline, after the ride back +function renderHotelCard(body) { + const b = dayBase(); if (!b) return; + const n = staysForDay().length; + const c = el('div', 'stop-card struct-card hotel-card'); + const top = el('div', 'sc-top'); + top.append(el('span', 'leg-ico', '🏨')); + top.append(el('span', 'sc-name', b.name)); + top.append(el('span', 'sc-times', 'tonight')); + c.append(top); + const meta = el('div', 'sc-meta'); + meta.append(el('span', 'tc t-sched', b.state === 'booked' ? 'booked' : 'base')); + meta.append(el('span', 'leg-sub', stayRange(b))); + if (n > 1) meta.append(el('span', 'leg-sub', `${n} options — worst-case anchor`)); + c.append(meta); + c.addEventListener('mouseenter', () => hotelMks.forEach(m => flashMarker(m, true))); + c.addEventListener('mouseleave', () => hotelMks.forEach(m => flashMarker(m, false))); + c.addEventListener('click', () => openDiscover('hotel')); + body.append(c); +} const POOL_KEY = { lunch: 'lunch', dinner: 'dinner', visit: 'visit' }; // inline alternatives for a planned stop: same-slot candidates, not already in the day function slotAlternatives(s, n = 3) { @@ -1154,7 +1262,7 @@ function renderStopCard(body, s, i, flashIds) { 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}`)); + if (s.price) meta.append(el('span', 'pchip', `${s.price.amount}${s.price.cur}`)); c.append(meta); c.append(el('div', 'sc-sub', `arrive by ${s.transit.arriveBy} — ${s.transit.buffer}`)); c.addEventListener('mouseenter', () => flashMarker(trainMks[0], true)); @@ -1288,6 +1396,7 @@ function renderStopCard(body, s, i, flashIds) { const g = day.legs[i]; const lr = el('div', 'leg-row' + (s.state !== 'planned' ? ' dim' : '')); lr.append(el('span', 'leg-ico', MODE_ICO[g.mode] || '•')); + if (g.toAirport) lr.append(document.createTextNode('to ' + ((day.departureFlight && (day.departureFlight.code || day.departureFlight.from)) || 'airport') + ' ')); if (g.sub) lr.append(el('span', 'leg-sub', g.sub.map(x => `${MODE_ICO[x.mode]} ${x.dur}′`).join(' + '))); if (g.vague) lr.append(el('span', 'leg-sub', 'worst-case until refined')); lr.append(document.createTextNode(' ')); @@ -1680,7 +1789,7 @@ function restore(idx, silent) { redateStaysAndPlaces(); // old-era snapshots may hold inconsistent pairs closeDiscover(); closeL3(); rebuildLegs(day); // snapshots may predate route enrichment — re-upgrade legs - renderHotelMks(); renderBaseChip(); + renderHotelMks(); renderModeToggle(); renderAll(); renderVersionPill(); if (!silent) ai(`↩ Reverted to v${s.v} — ${s.label}. Later edits are gone from the branch; redo can’t bring them back.`, 400); @@ -2084,6 +2193,8 @@ function enterTrip(id) { M = window.TRIPS[id]; days = {}; M.days.forEach(d => days[d.id] = deep(d)); curDay = 'D1'; day = days[curDay]; + // travel mode (on foot vs taxi) is an app-level preference, remembered + try { const tm = localStorage.getItem('trips.travemode'); if (tm === 'car' || tm === 'foot') travelMode = tm; } catch {} stays = deep(M.trip.stays); staySeq = stays.reduce((m, o) => Math.max(m, +String(o.id).replace(/\D/g, '') || 0), 1); // resume this trip's saved state (edits + version history), if any @@ -2096,6 +2207,7 @@ function enterTrip(id) { // but not the stays (hotel nights then no longer "covered" any day); // idempotent — a consistent plan is a no-op redateStaysAndPlaces(); + healTravelAnchors(); // rebuild the arrival/departure airport cards + transfers persistTrip(); } tripDocs = {}; // rehydrated from the server store below @@ -2115,7 +2227,7 @@ function enterTrip(id) { map.setView(M.trip.places[0].center, 14); renderTripMenu(); buildDayTabs(); - renderHotelMks(); renderBaseChip(); + renderHotelMks(); renderModeToggle(); renderDocChips(); // this trip may live on a different OSRM extract — re-check before routing Promise.all([checkRouter(), checkLLM()]).then(() => { @@ -2152,16 +2264,31 @@ function startApp() { } // ---------------- 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', `🏨${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); +// the travel-mode toggle: on foot vs taxi between stops (the hotel itself now +// lives in the day timeline as the "tonight" card, not in this block) +function renderModeToggle() { + const tb = $('#transit-block'); if (!tb) return; tb.innerHTML = ''; + const wrap = el('div', 'mode-toggle'); + wrap.append(el('span', 'mode-label', 'getting around')); + [['foot', '🚶 Walk'], ['car', '🚗 Taxi']].forEach(([m, lab]) => { + const b = el('button', 'mode-btn' + (travelMode === m ? ' on' : ''), lab); + b.title = m === 'foot' ? 'On foot between stops' : 'By taxi between stops'; + b.onclick = () => setTravelMode(m); + wrap.append(b); + }); + tb.append(wrap); +} +function setTravelMode(m) { + if (m === travelMode) return; + travelMode = m; + try { localStorage.setItem('trips.travemode', m); } catch {} + // re-estimate every day's on-ground legs at the new speed. Enrichment is not + // queued here — one fetch per day would cancel the token out — only the day + // you're looking at is re-routed in the background + M.days.forEach(md => { const d = days[md.id]; if (d) rebuildLegs(d, false); }); + renderModeToggle(); + renderAll(); + if (day) enrichLegs(day); } function openDetail(s, actions) { diff --git a/mock/styles.css b/mock/styles.css index 23986ba..a736a77 100644 --- a/mock/styles.css +++ b/mock/styles.css @@ -177,6 +177,26 @@ button { font: inherit; } .leg-ico { font-size: 13px; } .leg-sub { font-size: 11px; color: var(--ink2); opacity: .8; } .leg-row.dim { opacity: .55; } +.leg-row.struct-leg { cursor: default; } +.leg-row.struct-leg:hover { background: transparent; } + +/* travel-mode toggle (on foot vs taxi) — sits where the old base-chip did */ +.mode-toggle { display: flex; align-items: center; gap: 6px; } +.mode-label { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--ink2); + margin-right: 2px; white-space: nowrap; } +.mode-btn { flex: 1; border: 1px solid var(--line); background: var(--card); color: var(--ink2); + border-radius: 9px; padding: 6px 8px; font-size: 12.5px; font-weight: 600; cursor: pointer; + transition: background .15s, border-color .15s, color .15s; } +.mode-btn:hover { border-color: #c9cfda; } +.mode-btn.on { background: var(--ink); border-color: var(--ink); color: #fff; } + +/* structural timeline cards: the airport you landed on + the hotel for the + night — rendered in the day timeline, not as editable stops */ +.struct-card { border-left: 3px solid #c3cad6; } +.struct-card .sc-name { font-size: 13px; } +.arr-card { border-left-color: #274b8f; background: #f6f9ff; } +.hotel-card { border-left-color: #7a8291; background: #f7f8fa; } +.hotel-card .sc-times { font-weight: 600; } /* vague / region placeholder stops */ .region-note { font-size: 11px; color: var(--ink2); margin-top: 5px; }