Fix Cartagena old-city points placed in the bay + misplaced CTG airport

Root cause: several Cartagena old-city landmarks in the data had latitudes
~4 km too high (≈10.46°N instead of ≈10.42°N), dropping them out into the
Caribbean bay — that's why Day 2 stops 2–5 rendered in the water. The chat
model couldn't fix them because its web search couldn't surface valid
coordinates for those specific buildings, and it (correctly) refused to move
them to arbitrary guessed locations.

Also found the CTG (Rafael Núñez) airport was misplaced ~8 km east onto the
Bocagrande area ([10.4622,-75.4385]); the real airport is [10.4425,-75.5131].
This inflated the Day 1 arrival drop-off to 313 min.

Changes:
- data.js: re-anchor 6 old-city stops (S5, S7–S10, S20) + the C2 candidate to
  verified on-land coordinates (all snap <21 m to the OSRM extract); fix the
  CTG airport in the CTG→SMR flight stations and the S22 stop.
- app.js: correct AIRPORTS.ctg (used by the arrival transfer) to the real
  airport. Add healCoordinates() so a plan saved before this fix (still holding
  the old in-the-bay points in the browser's localStorage) is re-anchored to
  the corrected source on load — stops re-sync by id, arrival/departure airports
  re-derived from the AIRPORTS table. Idempotent; user-swapped stops (candidate
  ids) are left alone. Verified with a CDP test that corrupts the saved plan
  (6 stops + airport) and confirms a reload self-heals them back.

All regression suites pass: coords 6/6, drop-off leg 17/17, smoke 0 errors,
rail structure 13/13, dates 7/7.
This commit is contained in:
Greg Pomerantz 2026-09-09 21:02:19 -04:00
parent 76749affb6
commit 04076294bd
2 changed files with 42 additions and 11 deletions

View File

@ -124,7 +124,7 @@ function resolveStop(ref) {
// ---- flight-anchor reconfiguration: the user drops a real ticket and the
// plan re-anchors around the booked arrival/departure (times + locations) ----
const AIRPORTS = {
bog: [4.7016, -74.1469], ctg: [10.4622, -75.4385], smr: [11.1165, -74.2330],
bog: [4.7016, -74.1469], ctg: [10.4425, -75.5131], smr: [11.1165, -74.2330],
mtr: [8.7443, -75.8744], clo: [1.2804, -77.1733], elc: [6.8731, -71.6474],
mde: [6.2447, -75.5801], baq: [3.5757, -76.5716], cuc: [5.6958, -75.6504],
mia: [25.7959, -80.2870], jfk: [40.6413, -73.7781], lax: [33.9416, -118.4085],
@ -224,6 +224,32 @@ function healTravelAnchors() {
last.transferOut = { at: ap || null, hotelAt: hotelLast, dur: (ap && hotelLast) ? (driveMin({ at: ap }, hotelLast) || 60) : 60 };
}
}
// Re-anchoring for stored plans after the source coordinates are corrected.
// Original day-stops are fixed places (there is no "drag this stop" edit), so
// the source dataset is authoritative for *where* each one is: re-sync every
// stop's `at` by id, plus the per-day base, and re-derive the arrival/departure
// airport coordinates from the AIRPORTS table. Stops the user swapped in carry
// their own (candidate) ids and are left untouched. Idempotent — returns true
// only if something actually changed (so a load can re-estimate the legs).
function healCoordinates() {
let changed = false;
const sync = (o, v) => { if (o && v && o.at && (o.at[0] !== v[0] || o.at[1] !== v[1])) { o.at = [v[0], v[1]]; changed = true; } };
if (M && M.days) {
const srcStop = {}, srcBase = {};
M.days.forEach(md => { (md.stops || []).forEach(s => { if (s.id && s.at) srcStop[s.id] = s.at; }); if (md.base && md.base.at) srcBase[md.id] = md.base.at; });
Object.values(days).forEach(d => {
if (d.base && srcBase[d.id]) sync(d.base, srcBase[d.id]);
(d.stops || []).forEach(s => { if (s.id && srcStop[s.id]) sync(s, srcStop[s.id]); });
});
}
const ids = Object.keys(days).sort();
if (ids.length) {
const first = days[ids[0]], last = days[ids[ids.length - 1]];
if (first && first.arrivalFlight && first.arrivalFlight.code) { const ap = airportOf(first.arrivalFlight.code); if (ap) { sync(first.arrivalFlight, ap.at); sync(first.transferIn, ap.at); } }
if (last && last.departureFlight && last.departureFlight.code) { const ap = airportOf(last.departureFlight.code); if (ap) { sync(last.departureFlight, ap.at); sync(last.transferOut, ap.at); } }
}
return changed;
}
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)) : '';
@ -2292,7 +2318,12 @@ function enterTrip(id) {
// idempotent — a consistent plan is a no-op
redateStaysAndPlaces();
healTravelAnchors(); // rebuild the arrival/departure airport cards + transfers
persistTrip();
// re-sync coordinates against the corrected source dataset — a plan saved
// before the old-city/airport coordinate fix would otherwise still hold the
// misplaced (in-the-bay) points. idempotent: an in-sync plan is a no-op
const coordFixed = healCoordinates();
if (coordFixed) Object.values(days).forEach(d => rebuildLegs(d, false)); // re-estimate legs from the new points
persistTrip(coordFixed);
}
tripDocs = {}; // rehydrated from the server store below
// reset per-trip UI state

View File

@ -553,7 +553,7 @@ const COLOMBIA = {
{ type: 'flight', name: 'AV 1351', ref: 'AV-88213',
from: 'Rafael Núñez (CTG)', to: 'Simón Bolívar (SMR)', date: '2026-09-25',
depart: '08:40', arrive: '09:25', status: 'booked', source: 'user_booking',
stations: { from: [10.4622, -75.4385], to: [11.1165, -74.2330] } },
stations: { from: [10.4425, -75.5131], to: [11.1165, -74.2330] } },
{ type: 'flight', name: 'AV 1352', ref: 'AV-88214',
from: 'Simón Bolívar (SMR)', to: 'Bogotá (BOG)', date: '2026-09-28',
depart: '15:10', arrive: '15:55', status: 'booked', source: 'user_booking',
@ -598,7 +598,7 @@ const COLOMBIA = {
links: [ { label: 'Cartagena tourism', href: 'https://www.cartagenatourism.com' } ] } },
{ id: 'S5', name: 'Plaza de la Aduana & Cathedral', kind: 'visit', slot: 'visit', state: 'planned',
url: 'https://en.wikipedia.org/wiki/Cartagena_Cathedral',
at: [10.4615, -75.5550], dur: 60, durConf: 3,
at: [10.4230, -75.5530], dur: 60, durConf: 3,
suggested: { value: 60, conf: 2, src: 'cartagenatourism.com' },
price: null,
img: ['⛪', 'Catedral de Cartagena', 'linear-gradient(135deg,#8f3c5c,#5c1f3a)'],
@ -618,7 +618,7 @@ const COLOMBIA = {
suggested: { value: 30, conf: 2, src: 'tripadvisor.com' } },
{ id: 'S7', name: 'Casa de la Moneda', kind: 'visit', slot: 'visit', state: 'planned',
url: 'https://en.wikipedia.org/wiki/Casa_de_la_Moneda_(Cartagena)',
at: [10.4619, -75.5544], dur: 45, durConf: 3,
at: [10.4255, -75.5482], dur: 45, durConf: 3,
suggested: { value: 45, conf: 2, src: 'cartagenatourism.com' },
price: { amount: 5, cur: '$', conf: 2, src: 'cartagenatourism.com' },
img: ['🪙', 'Casa de la Moneda', 'linear-gradient(135deg,#8f8a3c,#5c571f)'],
@ -627,7 +627,7 @@ const COLOMBIA = {
links: [ { label: 'Casa de la Moneda', href: 'https://en.wikipedia.org/wiki/Casa_de_la_Moneda_(Cartagena)' } ] } },
{ id: 'S8', name: 'Palacio de la Inquisición', kind: 'visit', slot: 'visit', state: 'planned',
url: 'https://en.wikipedia.org/wiki/Palacio_de_la_Inquisici%C3%B3n_(Cartagena)',
at: [10.4604, -75.5561], dur: 60, durConf: 3,
at: [10.4231, -75.5516], dur: 60, durConf: 3,
suggested: { value: 60, conf: 2, src: 'cartagenatourism.com' },
price: { amount: 7, cur: '$', conf: 2, src: 'cartagenatourism.com' },
img: ['🏛️', 'Palacio de la Inquisición', 'linear-gradient(135deg,#3c5c8f,#1f3a5c)'],
@ -636,7 +636,7 @@ const COLOMBIA = {
links: [ { label: 'Palacio de la Inquisición', href: 'https://en.wikipedia.org/wiki/Palacio_de_la_Inquisici%C3%B3n_(Cartagena)' } ] } },
{ id: 'S9', name: 'San Pedro Claver', kind: 'visit', slot: 'visit', state: 'planned',
url: 'https://en.wikipedia.org/wiki/San_Pedro_Claver_Church',
at: [10.4645, -75.5557], dur: 45, durConf: 3,
at: [10.4232, -75.5528], dur: 45, durConf: 3,
suggested: { value: 45, conf: 2, src: null },
price: null,
img: ['🕊️', 'San Pedro Claver', 'linear-gradient(135deg,#7a7a8f,#4f4f61)'],
@ -645,7 +645,7 @@ const COLOMBIA = {
links: [ { label: 'San Pedro Claver', href: 'https://en.wikipedia.org/wiki/San_Pedro_Claver_Church' } ] } },
{ id: 'S10', name: 'Lunch — La Loma de San Diego', kind: 'meal', mealKind: 'lunch',
slot: 'lunch', state: 'planned', url: 'https://www.tripadvisor.com',
at: [10.4590, -75.5530], dur: 75, durConf: 3,
at: [10.4230, -75.5525], dur: 75, durConf: 3,
suggested: { value: 75, conf: 2, src: 'tripadvisor.com' },
price: { amount: 25, cur: '$', conf: 2, src: 'tripadvisor.com' },
img: ['🥘', 'La Loma de San Diego', 'linear-gradient(135deg,#c93c3c,#8a1f1f)'],
@ -740,7 +740,7 @@ const COLOMBIA = {
summary: 'Whatever the boat landed this morning — fried whole, with coconut rice and yuca. Point to a table, the price is on the chalkboard, and the sea is the view.',
links: [ { label: 'TripAdvisor', href: 'https://www.tripadvisor.com' } ] } },
{ id: 'S20', name: 'Plaza del Chopo — farewell stroll', kind: 'visit', slot: 'visit', state: 'planned',
at: [10.4638, -75.5560], dur: 60, durConf: 3,
at: [10.4232, -75.5530], dur: 60, durConf: 3,
suggested: { value: 45, conf: 2, src: null },
price: null,
img: ['🌇', 'Plaza del Chopo', 'linear-gradient(135deg,#8f3c5c,#5c241f)'],
@ -759,7 +759,7 @@ const COLOMBIA = {
at: [10.3952, -75.5522], dur: 45, durConf: 3,
suggested: { value: 45, conf: 2, src: 'tripadvisor.com' } },
{ id: 'S22', name: 'Vuelo CTG → SMR', kind: 'transit', slot: 'transit', state: 'planned',
at: [10.4622, -75.4385], dur: 45, durConf: 4,
at: [10.4425, -75.5131], dur: 45, durConf: 4,
transit: { name: 'AV 1351', ref: 'AV-88213',
from: 'Rafael Núñez (CTG)', to: 'Simón Bolívar (SMR)',
depart: '08:40', arrive: '09:25', arriveBy: '07:40',
@ -904,7 +904,7 @@ const COLOMBIA = {
{ id: 'C1', name: 'La Casona de San Diego', at: [10.3940, -75.5545], price: 18, dur: 75,
tags: ['caribbean', 'getsemaní', 'cash only'], pitch: 'Sancocho and mango con arepa in a whitewashed casona — the neighbourhoods own lunch.',
url: 'https://www.tripadvisor.com', img: ['🍲', 'La Casona', 'linear-gradient(135deg,#3c8f6b,#1f5c44)'] },
{ id: 'C2', name: 'La Loma de San Diego', at: [10.4590, -75.5530], price: 25, dur: 75,
{ id: 'C2', name: 'La Loma de San Diego', at: [10.4230, -75.5525], price: 25, dur: 75,
tags: ['old town', 'arroz con coco', 'institution'], pitch: 'The old-town classic — big patio table, kitchen as theatre, go at 12:15.',
url: 'https://www.tripadvisor.com', img: ['🥘', 'La Loma', 'linear-gradient(135deg,#c93c3c,#8a1f1f)'] },
{ id: 'C3', name: 'Club El Boat', at: [10.4355, -75.5375], price: 20, dur: 90,