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:
parent
bf72fdf32d
commit
16c35b0bce
66
mock/app.js
66
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 <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) ----------------
|
||||
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' +
|
||||
'<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' +
|
||||
'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/<trip>/) — 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 = `🧳 <span class="t-name">${M.trip.title}</span> <span class="caret">▾</span>`;
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user