Re-date the whole plan on flight upload: stays, tabs, chips, title

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.
This commit is contained in:
Greg Pomerantz 2026-09-09 16:31:26 -04:00
parent bf72fdf32d
commit 16c35b0bce
2 changed files with 69 additions and 15 deletions

View File

@ -163,6 +163,34 @@ function relabelDay(d, idx, dateStr) {
d.date = dateStr; d.date = dateStr;
d.label = 'Day ' + idx + ' · ' + DOWS[dt.getUTCDay()] + ' ' + da; 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) { function rangeStr(a, b) {
const f = s => { const [y, mo, da] = s.split('-').map(Number); return da + ' ' + MONTHS[mo - 1]; }; 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)) : ''; 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)); 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])); ids.forEach(id => rebuildLegs(days[id]));
commit('[flight] re-anchored around booked arrival + departure'); commit('[flight] re-anchored around booked arrival + departure');
persistTrip(true);
renderAll(); renderAll();
buildDayTabs();
renderBaseChip();
renderTripMenu();
renderVersionPill();
const fs2 = dayStats(first), ls2 = dayStats(last); const fs2 = dayStats(first), ls2 = dayStats(last);
return { return {
ok: true, ok: true,
@ -594,7 +632,11 @@ const hotelWalkMin = (at, d = day) => {
const list = staysForDay(d); const list = staysForDay(d);
return list.length ? Math.max(1, ...list.map(o => Math.round(haversine(at, o.at) / 1000 / (WALK_KMH / 60)))) : 1; 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() { function renderHotelMks() {
hotelMks.forEach(m => map.removeLayer(m)); hotelMks = []; hotelMks.forEach(m => map.removeLayer(m)); hotelMks = [];
@ -1312,7 +1354,7 @@ function moveStopToDay(s, targetId) {
commit(`${s.name}${t.label}`); renderAll(); commit(`${s.name}${t.label}`); renderAll();
ai(`Moved <b>${s.name}</b> to ${t.label} — legs and times re-timed there.`, 500); ai(`Moved <b>${s.name}</b> to ${t.label} — legs and times re-timed there.`, 500);
} }
const dayLabel = () => M.days.find(d => d.id === day.id).label; const dayLabel = () => day.label; // live label (dates) — M.days holds the original plan
// ---------------- time budget (gentle phrasing) ---------------- // ---------------- time budget (gentle phrasing) ----------------
function renderBudget() { function renderBudget() {
@ -1689,7 +1731,7 @@ document.addEventListener('keydown', e => {
// ---------------- day tabs ---------------- // ---------------- day tabs ----------------
function buildDayTabs() { function buildDayTabs() {
const tabs = $('#daytabs'); tabs.innerHTML = ''; 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); const t = el('button', 'daytab' + (d.id === curDay ? ' sel' : ''), d.label);
t.onclick = () => setDay(d.id); t.onclick = () => setDay(d.id);
t.addEventListener('dragover', e => { e.preventDefault(); t.classList.add('over'); }); t.addEventListener('dragover', e => { e.preventDefault(); t.classList.add('over'); });
@ -1711,7 +1753,7 @@ function buildDayTabs() {
function setDay(id) { function setDay(id) {
curDay = id; day = days[id]; curDay = id; day = days[id];
if (!day.legs.length) rebuildLegs(day); 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 closeL3(); // a selected route from another day is stale
tempClear(); tempClear();
if (disc) closeDiscover(); // the drawers anchor is day-specific if (disc) closeDiscover(); // the drawers anchor is day-specific
@ -1755,7 +1797,7 @@ function renderTripRail() {
const pct = Math.min(100, st.dur / st.cap * 100); const pct = Math.min(100, st.dur / st.cap * 100);
const card = el('div', 'trip-day' + (md.id === curDay ? ' cur' : '')); const card = el('div', 'trip-day' + (md.id === curDay ? ' cur' : ''));
const top = el('div', 'td-top'); 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)}`)); top.append(el('span', 'td-end', `ends ~${fmt(st.end)}`));
card.append(top); card.append(top);
const bar = el('div', 'td-bar'); const bar = el('div', 'td-bar');
@ -1919,7 +1961,7 @@ function docSection() {
// capped at 60k chars server-side — well within the 131k-token window) // capped at 60k chars server-side — well within the 131k-token window)
const ds = Object.values(tripDocs); const ds = Object.values(tripDocs);
if (!ds.length) return ''; if (!ds.length) return '';
return '\n\nAttached documents (the users 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 plans placeholder bookings:\n' + return '\n\nAttached documents (the users 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 plans placeholder bookings:\n' +
ds.map(d => 'DOCUMENT “' + d.name + '”' + (d.truncated ? ' (extract truncated)' : '') + '\n' + (d.text || '')).join('\n\n'); ds.map(d => 'DOCUMENT “' + d.name + '”' + (d.truncated ? ' (extract truncated)' : '') + '\n' + (d.text || '')).join('\n\n');
} }
function renderDocChips() { function renderDocChips() {
@ -1942,7 +1984,7 @@ function reconfigureFromDoc(j) {
'DOCUMENT “' + (j.name || 'document') + '”\n' + 'DOCUMENT “' + (j.name || 'document') + '”\n' +
'<document>\n' + (j.text || '') + '\n</document>\n\n' + '<document>\n' + (j.text || '') + '\n</document>\n\n' +
'If this is a flight booking (or contains actual flight times), reconfigure the trip around it:\n' + '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' + '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' + '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 its attached and summarize what it contains in 12 sentences.'; 'If it is NOT a flight booking, do not change the plan — just confirm its attached and summarize what it contains in 12 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; } }, load(id) { try { const s = localStorage.getItem(this.key(id)); return s ? JSON.parse(s) : null; } catch { return null; } },
}; };
let persistT = 0; let persistT = 0;
function persistTrip() { function persistTrip(force = false) {
clearTimeout(persistT); clearTimeout(persistT);
const hist = history.slice(0, hIdx + 1).slice(-25); // drop the redo tail, cap at 25 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/<trip>/) — localStorage // docs + chat live on the server now (mock/store/<trip>/) — localStorage
// only carries the plan state // only carries the plan state. force: skip the debounce — the re-anchoring
persistT = setTimeout(() => tripStore.save(tripId, { days, stays, version, hIdx: hist.length - 1, history: hist, bookings: M.trip.bookings, title: M.trip.title }), 250); // 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() { function renderTripMenu() {
$('#crumb').innerHTML = `🧳 <span class="t-name">${M.trip.title}</span> <span class="caret">▾</span>`; $('#crumb').innerHTML = `🧳 <span class="t-name">${M.trip.title}</span> <span class="caret">▾</span>`;

View File

@ -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); 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))); 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) { function decodeMime(raw) {
// best text part (html preferred, then longest); handles one level of // best text part (html preferred, then longest); handles one level of
// nested multipart; returns null if nothing decodable was found // nested multipart; returns null if nothing decodable was found
const head = raw.slice(0, 20000); 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 = []; const parts = [];
if (bm) { if (bm) {
for (const chunk of raw.split(bm[1])) { for (const chunk of raw.split(bm[1])) {
const nm = chunk.match(/\r?\n([\s\S]*?)\r?\n\r?\n([\s\S]*)/); const nm = chunk.match(/\r?\n([\s\S]*?)\r?\n\r?\n([\s\S]*)/);
if (!nm) continue; if (!nm) continue;
const h = nm[1]; const h = nm[1];
const ct = ((h.match(/Content-Type:\s*([^\r\n;]+)/i) || [])[1] || '').trim().toLowerCase(); const ct = headerField(h, 'Content-Type').split(';')[0];
const ce = ((h.match(/Content-Transfer-Encoding:\s*([^\r\n]+)/i) || [])[1] || '').trim().toLowerCase(); const ce = headerField(h, 'Content-Transfer-Encoding');
const body = nm[2].replace(/\n--\s*$/, ''); const body = nm[2].replace(/\n--\s*$/, '');
if (body.trim()) parts.push({ ct, ce, body }); 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]*)/); const nm = raw.match(/\r?\n([\s\S]*?)\r?\n\r?\n([\s\S]*)/);
if (nm) { if (nm) {
const h = nm[1]; 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 = []; const dec = [];