From 16c35b0bce0932b561660f843af0771ee28464bf Mon Sep 17 00:00:00 2001 From: Greg Pomerantz Date: Wed, 9 Sep 2026 16:31:26 -0400 Subject: [PATCH] Re-date the whole plan on flight upload: stays, tabs, chips, title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The re-anchoring only re-dated days[], leaving every other date-bearing piece stale (day tabs read the original M.days, hotel stays/places kept September checkIns so the base-chip and covers() logic went wrong, crumb title and version pill never re-rendered). Now apply_flight_anchors also: - re-dates stays.checkIn/Out, places.dates and hotel booking strings from the day segments (redateStaysAndPlaces) - re-renders day tabs, base chip, crumb and version pill, and reads tab/trip-rail labels from the live days - persists immediately (force) — the one edit we must never lose - stayRange derives the month instead of hardcoding 'Sep' Plus the .eml fix: mail metadata headers embed field names in their values (X-Arc: ...content-type:from:subject:...), which the old unanchored case-insensitive match grabbed — so single-part MIME bodies were never decoded and raw headers + QP escapes leaked into the model's context. Header fields are now parsed line-anchored, last occurrence wins. The user's three real .emls now extract to clean text. Prompt: when a document shows only one flight leg, the model is told to search the other attached documents for the missing leg before asking. E2E: drop the real AA e-mail -> model finds the return leg in the other attached doc, applies the anchors, and title/tabs/chip/stays all show 7–15 Nov, surviving a hard reload. --- mock/app.js | 66 +++++++++++++++++++++++++++++++++++++++++--------- mock/server.js | 18 +++++++++++--- 2 files changed, 69 insertions(+), 15 deletions(-) diff --git a/mock/app.js b/mock/app.js index accb1c1..f705763 100644 --- a/mock/app.js +++ b/mock/app.js @@ -163,6 +163,34 @@ function relabelDay(d, idx, dateStr) { d.date = dateStr; d.label = 'Day ' + idx + ' · ' + DOWS[dt.getUTCDay()] + ' ' + da; } +const dayList = () => Object.keys(days).sort().map(k => days[k]); +const ptEq = (a, b) => !!a && !!b && Math.abs(a[0] - b[0]) < 1e-6 && Math.abs(a[1] - b[1]) < 1e-6; +// keep hotel stays, city place-dates and hotel booking records in sync after +// a re-date (e.g. flight re-anchoring) — otherwise stays no longer "cover" +// the new day dates and the base-hotel logic silently falls back +function redateStaysAndPlaces() { + const all = dayList(); + const segDates = seg => [seg.reduce((m, d) => d.date < m ? d.date : m, seg[0].date), + seg.reduce((m, d) => d.date > m ? d.date : m, seg[0].date)]; + stays.forEach(st => { + const seg = all.filter(d => (d.base && d.base.at && ptEq(d.base.at, st.at)) || covers(st, d.date)); + if (!seg.length) return; + const [a, b] = segDates(seg); + st.checkIn = a; st.checkOut = b; + }); + (M.trip.places || []).forEach(pl => { + const st = stays.find(s => (s.place || '').toLowerCase().includes(pl.name.toLowerCase()) + || pl.name.toLowerCase().includes((s.place || '').toLowerCase())); + const seg = st ? all.filter(d => covers(st, d.date)) : []; + if (!seg.length) return; + if (pl.dates) pl.dates = segDates(seg); + }); + (M.trip.bookings || []).forEach(b => { + if (b.type !== 'hotel' || !b.dates) return; + const st = stays.find(s => s.name === b.name); + if (st) b.dates = stayRange(st); + }); +} 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)) : ''; @@ -385,10 +413,20 @@ const TOOLS = { } last.wakingHours = Math.max(1, Math.round((depMin - last.startMin) / 60)); - // 5) commit + // 5) sync the date-carrying neighbours (stays / places / hotel bookings) + redateStaysAndPlaces(); + + // 6) commit (persisted immediately — this is the edit we must never lose) + // + re-render every date-bearing UI piece (renderAll alone only covers + // rail + map, which left tabs, chips and title showing old dates) ids.forEach(id => rebuildLegs(days[id])); commit('[flight] re-anchored around booked arrival + departure'); + persistTrip(true); renderAll(); + buildDayTabs(); + renderBaseChip(); + renderTripMenu(); + renderVersionPill(); const fs2 = dayStats(first), ls2 = dayStats(last); return { ok: true, @@ -594,7 +632,11 @@ 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`; +const stayRange = s => { + const [y1, m1, d1] = String(s.checkIn).split('-').map(Number); + const [y2, m2, d2] = String(s.checkOut).split('-').map(Number); + return m1 === m2 ? `${d1}–${d2} ${MONTHS[m1 - 1]}` : `${d1} ${MONTHS[m1 - 1]}–${d2} ${MONTHS[m2 - 1]}`; +}; function renderHotelMks() { hotelMks.forEach(m => map.removeLayer(m)); hotelMks = []; @@ -1312,7 +1354,7 @@ function moveStopToDay(s, targetId) { commit(`${s.name} → ${t.label}`); renderAll(); ai(`Moved ${s.name} to ${t.label} — legs and times re-timed there.`, 500); } -const dayLabel = () => M.days.find(d => d.id === day.id).label; +const dayLabel = () => day.label; // live label (dates) — M.days holds the original plan // ---------------- time budget (gentle phrasing) ---------------- function renderBudget() { @@ -1689,7 +1731,7 @@ document.addEventListener('keydown', e => { // ---------------- day tabs ---------------- function buildDayTabs() { const tabs = $('#daytabs'); tabs.innerHTML = ''; - M.days.forEach(d => { + dayList().forEach(d => { // live days — M.days holds the pre-edit originals 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'); }); @@ -1711,7 +1753,7 @@ function buildDayTabs() { 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)); + document.querySelectorAll('.daytab').forEach((t, i) => t.classList.toggle('sel', dayList()[i] && dayList()[i].id === id)); closeL3(); // a selected route from another day is stale tempClear(); if (disc) closeDiscover(); // the drawer’s anchor is day-specific @@ -1755,7 +1797,7 @@ function renderTripRail() { const pct = Math.min(100, st.dur / st.cap * 100); const card = el('div', 'trip-day' + (md.id === curDay ? ' cur' : '')); const top = el('div', 'td-top'); - top.append(el('span', 'td-label', md.label + (d.isDeparture ? ' · dep' : ''))); + top.append(el('span', 'td-label', d.label + (d.isDeparture ? ' · dep' : ''))); // live label (dates) top.append(el('span', 'td-end', `ends ~${fmt(st.end)}`)); card.append(top); const bar = el('div', 'td-bar'); @@ -1919,7 +1961,7 @@ function docSection() { // capped at 60k chars server-side — well within the 131k-token window) const ds = Object.values(tripDocs); if (!ds.length) return ''; - return '\n\nAttached documents (the user’s own files, kept for reference on any question). For questions about their real bookings (confirmation codes, seats, prices, flight times), answer from these documents, not from the plan’s placeholder bookings:\n' + + return '\n\nAttached documents (the user’s own files, kept for reference on any question). These are ALL the documents on file for this trip — when one is missing a detail (e.g. an e-mail that only covers the outbound flight), look for it in the OTHERS before asking the user. For questions about their real bookings (confirmation codes, seats, prices, flight times), answer from these documents, not from the plan’s placeholder bookings:\n' + ds.map(d => 'DOCUMENT “' + d.name + '”' + (d.truncated ? ' (extract truncated)' : '') + '\n' + (d.text || '')).join('\n\n'); } function renderDocChips() { @@ -1942,7 +1984,7 @@ function reconfigureFromDoc(j) { 'DOCUMENT “' + (j.name || 'document') + '”\n' + '\n' + (j.text || '') + '\n\n\n' + 'If this is a flight booking (or contains actual flight times), reconfigure the trip around it:\n' + - '1. Read the ARRIVAL (date, local time, airport, city) and the DEPARTURE (date, local time, airport, city). With connections, the arrival is where the trip starts (first city) and the departure where it ends (last city).\n' + + '1. Read the ARRIVAL (date, local time, airport, city) and the DEPARTURE (date, local time, airport, city). With connections, the arrival is where the trip starts (first city) and the departure where it ends (last city). If this document only shows one leg, search the OTHER attached documents in your context for the missing leg before asking the user.\n' + '2. Call apply_flight_anchors exactly once with those two anchors — apply it now, do not ask first (the edit is undo-able), even if the dates differ from the current plan.\n' + '3. Confirm what changed — the new first and last day with their start/end times, any stops that had to go, and the date shift — in a couple of sentences.\n' + 'If it is NOT a flight booking, do not change the plan — just confirm it’s attached and summarize what it contains in 1–2 sentences.'; @@ -2007,12 +2049,14 @@ const tripStore = { load(id) { try { const s = localStorage.getItem(this.key(id)); return s ? JSON.parse(s) : null; } catch { return null; } }, }; let persistT = 0; -function persistTrip() { +function persistTrip(force = false) { clearTimeout(persistT); const hist = history.slice(0, hIdx + 1).slice(-25); // drop the redo tail, cap at 25 // docs + chat live on the server now (mock/store//) — localStorage - // only carries the plan state - persistT = setTimeout(() => tripStore.save(tripId, { days, stays, version, hIdx: hist.length - 1, history: hist, bookings: M.trip.bookings, title: M.trip.title }), 250); + // only carries the plan state. force: skip the debounce — the re-anchoring + // of the whole trip around booked flights is the one edit we must never lose + const save = () => tripStore.save(tripId, { days, stays, version, hIdx: hist.length - 1, history: hist, bookings: M.trip.bookings, title: M.trip.title }); + if (force) save(); else persistT = setTimeout(save, 250); } function renderTripMenu() { $('#crumb').innerHTML = `🧳 ${M.trip.title} `; diff --git a/mock/server.js b/mock/server.js index 44f6b42..386ecd7 100644 --- a/mock/server.js +++ b/mock/server.js @@ -181,19 +181,29 @@ function looksLikeMime(t) { return /MIME-Version:\s*1\.0/im.test(h) || /Content-Transfer-Encoding:\s*(base64|quoted-printable)/im.test(h); } const decodeQP = (s) => String(s).replace(/=\r?\n/g, '').replace(/=([0-9a-fA-F]{2})/g, (_, hx) => String.fromCharCode(parseInt(hx, 16))); +// top-level header field (starts at column 0 — mail metadata headers like +// "X-Arc: ...content-type:from:subject:..." embed field names in their +// values, so an unanchored case-insensitive match grabs the wrong one); +// last occurrence wins (real headers come after the metadata lines) +const headerField = (h, name) => { + const re = new RegExp('^' + name + ':\\s*([^\\r\\n]+)', 'gim'); + let m, last = null; + while ((m = re.exec(h))) last = m[1]; + return last ? last.trim().toLowerCase() : ''; +}; function decodeMime(raw) { // best text part (html preferred, then longest); handles one level of // nested multipart; returns null if nothing decodable was found const head = raw.slice(0, 20000); - const bm = head.match(/boundary="?([^";\r\n]+)"?/i); + const bm = (head.match(/^boundary="?([^";\r\n]+)"?/im) || head.match(/boundary="?([^";\r\n]+)"?/i)); const parts = []; if (bm) { for (const chunk of raw.split(bm[1])) { const nm = chunk.match(/\r?\n([\s\S]*?)\r?\n\r?\n([\s\S]*)/); if (!nm) continue; const h = nm[1]; - const ct = ((h.match(/Content-Type:\s*([^\r\n;]+)/i) || [])[1] || '').trim().toLowerCase(); - const ce = ((h.match(/Content-Transfer-Encoding:\s*([^\r\n]+)/i) || [])[1] || '').trim().toLowerCase(); + const ct = headerField(h, 'Content-Type').split(';')[0]; + const ce = headerField(h, 'Content-Transfer-Encoding'); const body = nm[2].replace(/\n--\s*$/, ''); if (body.trim()) parts.push({ ct, ce, body }); } @@ -201,7 +211,7 @@ function decodeMime(raw) { const nm = raw.match(/\r?\n([\s\S]*?)\r?\n\r?\n([\s\S]*)/); if (nm) { const h = nm[1]; - parts.push({ ct: ((h.match(/Content-Type:\s*([^\r\n;]+)/i) || [])[1] || '').trim().toLowerCase(), ce: ((h.match(/Content-Transfer-Encoding:\s*([^\r\n]+)/i) || [])[1] || '').trim().toLowerCase(), body: nm[2] }); + parts.push({ ct: headerField(h, 'Content-Type').split(';')[0], ce: headerField(h, 'Content-Transfer-Encoding'), body: nm[2] }); } } const dec = [];