ADE 2026 Planner — Local Edition (complete bundle)

One Google Apps Script project, two files, zero cost, no API keys required. Works for visitors (hotel anchor, flight logistics) and NL locals (home-station anchor, 🟢🟠🔴 last-train-home planner) — you pick once on first open, and can change it later.

This file contains everything: setup steps + the full contents of both files to paste.


Setup — pick your path

The only feature needing extra setup is the optional NS live-departures key — skip it and everything else works.

🟢 Never set this up before (~5 minutes)

  1. Make a Sheet — go to sheets.new, name it anything (e.g. ADE 2026).
  2. Open Apps Script — in the Sheet: Extensions → Apps Script.
  3. Paste both files:
    • Select everything in the default Code.gs and paste over it with 📄 File 1: Code.gs (below).
    • Click + → HTML, name it exactly Index and paste in 📄 File 2: Index.html (below).
    • In the editor, pick the function dropdown → select setup▶ Run once, and approve the permission prompt (this creates the tabs).
  4. DeployDeploy → New deployment → ⚙ Web appExecute as: Me, Who has access: Anyone with a Google account (or just yourself) → Deploy. Open the web app link — that's your app; add it to your phone's home screen.
  5. First open: choose 🇳🇱 “I live in the Netherlands” and type your home station (e.g. Utrecht Centraal, Nijmegen). Done — you get the local starter template and the last-train planner. (Visitors: pick ✈ instead — that's the whole difference.)

🔵 Already running the app (upgrade, ~2 minutes)

Your sets, bookings, places, and map pins are untouched — the upgrade only adds features.

  1. Open script.google.com → your ADE project.
  2. Replace both files' contents with the new versions below (select all → paste over Code.gs, then over Index).
  3. Deploy → Manage deployments → ✏️ → Version: New version → Deploy. Same link as before.
  4. Re-open the app and approve the permission prompt if asked (same free Google routing service — new scope).
  5. You'll see the one-time mode chooser: pick 🇳🇱 NL-based and enter your home station — or pick ✈ visiting and nothing changes. Change your mind anytime under ⚙ Mode & home base on the Overview tab.

⚙️ Optional add-ons (skip any you don't want)

Included in the code but clearly labeled — the app is complete without them:

Feature Setup effort Skip it and…
Live NS departures Register a free key at apiportal.ns.nl, paste it into ⚙ Mode & home base You still get routed last-train estimates (no live delays)
Morning digest email Tap the 📧 card on Overview, re-authorize once No 8:00 email; everything else works
Friend-group sharing Share the app link + the Sheet with friends It stays a solo/private planner

Before ADE: set the Sheet's timezone to Amsterdam (File → Settings → Time zone) so last-train and digest times are correct. And double-check the Nachtnet corridor table (top of Index.html, clearly commented) against ns.nl — night-train corridors drift a little year to year.

Everyone lands in the same build. The only fork is the one-time mode choice, and the only extra-setup feature is the optional NS key.


📄 File 1: Code.gs

Paste this over the entire contents of Code.gs in the Apps Script editor.

// ===== ADE 2026 Planner — backend =====

// Put the two Google accounts allowed to use this app here.
// Leave as [] to allow anyone with the link + a Google account.
const ALLOWED_EMAILS = [
  // "you@gmail.com",
  // "wife@gmail.com",
];

// Paste your Sheet's ID here — the long string in its URL between /d/ and /edit.
// (Required if you created the script standalone; safe to set either way.)
const SHEET_ID = "PASTE_YOUR_SHEET_ID_HERE";

// NOTE on column order: new columns are always appended at the END of each list.
// ensureHeaders_ adds any missing column to the right edge of the sheet, and saveRecord
// writes by this canonical order — so keeping new fields last keeps old data aligned.
// lat/lng are filled automatically by geocodeMissing() (keyless, via the built-in Maps
// service) and cached in the sheet so each address/venue is only looked up once.
const TABS = {
  Artists:  ["id","name","genre","day","venue","startTime","endTime","priority","ticketStatus","notes","lat","lng"],
  Timeline: ["id","title","date","time","details","done","flightNumber","kind","address","lat","lng"],
  // mapsUrl/sourceList arrive via the Takeout import; durationMin overrides the category
  // default from Settings; hearts holds the emails of whoever ❤'d the idea (💞 = both).
  Places:   ["id","name","category","area","address","priority","notes","lat","lng","mapsUrl","sourceList","durationMin","hearts"],
  Stays:    ["id","name","address","checkInDate","checkInTime","checkOutDate","checkOutTime","confirmation","notes","lat","lng"],
  Todos:    ["id","text","category","done"],
  // Local-edition cost tracking: NS fares, parking/P+R, OV-fiets, late-night food.
  // splitWith is a comma list of names/emails; the app assumes even shares with the payer.
  Costs:    ["id","date","what","amount","paidBy","splitWith","notes"],
  // "Tickets" is the sheet/tab name under the hood; it's shown as "Bookings" in the app and
  // now holds reservations too. type = ticket | reservation. placeIds links a booking to a
  // Place (museum, restaurant…); eventTime is the start time for timed entries / reservations.
  Tickets:  ["id","event","status","artistIds","orderUrl","orderNumber","vendor","holder","quantity","price","availableDate","eventDate","venue","notes","type","placeIds","eventTime"],
  // Key/value store: typical durations per category (dur.eat=90…), gap buffer, and
  // per-day travel-mode overrides (mode.2026-10-26=driving). Edited from the app.
  Settings: ["key","value"],
  // Remembered route lookups (walk/transit/drive minutes between coordinate pairs) so the
  // free built-in DirectionFinder is only ever asked once per pair+mode.
  RouteCache: ["key","mins","km"]
};
const TEXT_COLS = {
  Artists: ["startTime","endTime"],
  Timeline: ["date","time"],
  Stays: ["checkInDate","checkInTime","checkOutDate","checkOutTime"],
  Tickets: ["orderNumber","availableDate","eventDate","eventTime"],
  Costs: ["date"]
};

// Columns that hold a clock time. If Google Sheets stored one as a real time value
// (e.g. you typed "11:30" straight into a cell), read it back as "HH:mm" instead of
// mangling it into a date.
const TIME_FIELDS = ["time","startTime","endTime","checkInTime","checkOutTime","eventTime"];

// Columns that hold a geocoded coordinate (kept as plain numbers, never reformatted on read).
const GEO_FIELDS = ["lat","lng"];

function doGet() {
  if (!hasAccess_()) {
    return HtmlService.createHtmlOutput(
      "<div style='font-family:sans-serif;padding:48px;text-align:center;color:#334155'>" +
      "<h2>Access restricted</h2><p>This planner is private. Ask the owner to add your Google account.</p></div>");
  }
  return HtmlService.createHtmlOutputFromFile("Index")
    .setTitle("ADE 2026 Planner")
    .addMetaTag("viewport", "width=device-width, initial-scale=1");
}

function hasAccess_() {
  if (!ALLOWED_EMAILS || ALLOWED_EMAILS.length === 0) return true;
  var email = (Session.getActiveUser().getEmail() || "").toLowerCase();
  return ALLOWED_EMAILS.map(function(e){ return String(e).toLowerCase(); }).indexOf(email) !== -1;
}

function ss_() {
  return (SHEET_ID && SHEET_ID !== "PASTE_YOUR_SHEET_ID_HERE")
    ? SpreadsheetApp.openById(SHEET_ID)
    : SpreadsheetApp.getActiveSpreadsheet();
}

function sheetFor_(tab) {
  var sh = ss_().getSheetByName(tab);
  if (!sh) { sh = ss_().insertSheet(tab); sh.appendRow(TABS[tab]); }
  ensureHeaders_(sh, tab);
  return sh;
}

// Self-healing: make sure the sheet's header row has every column we expect,
// so adding a field (like flightNumber) needs no manual rebuild.
function ensureHeaders_(sh, tab) {
  var want = TABS[tab];
  var lastCol = sh.getLastColumn();
  var have = lastCol > 0 ? sh.getRange(1, 1, 1, lastCol).getValues()[0].map(String) : [];
  var missing = want.filter(function(w){ return have.indexOf(w) === -1; });
  if (missing.length) {
    sh.getRange(1, have.length + 1, 1, missing.length).setValues([missing])
      .setFontWeight("bold").setBackground("#1e293b").setFontColor("#ffffff");
  }
}

function readTab_(tab) {
  var sh = sheetFor_(tab);
  var tz = ss_().getSpreadsheetTimeZone();
  var values = sh.getDataRange().getValues();
  if (values.length < 2) return [];
  var headers = values[0], out = [];
  for (var r = 1; r < values.length; r++) {
    var row = values[r];
    if (row.join("") === "") continue;
    var obj = {};
    for (var c = 0; c < headers.length; c++) {
      var v = row[c];
      if (Object.prototype.toString.call(v) === "[object Date]") {
        v = (TIME_FIELDS.indexOf(headers[c]) !== -1)
          ? Utilities.formatDate(v, tz, "HH:mm")
          : Utilities.formatDate(v, tz, "yyyy-MM-dd");
      }
      obj[headers[c]] = v;
    }
    out.push(obj);
  }
  return out;
}

function getAllData() {
  if (!hasAccess_()) throw new Error("Access denied");
  ensureSettings_();   // seed the default duration table on first run after the update
  var data = {};
  Object.keys(TABS).forEach(function(tab){ data[tab.toLowerCase()] = readTab_(tab); });
  data._user = Session.getActiveUser().getEmail() || "";
  return data;
}

function saveRecord(tab, record) {
  if (!hasAccess_()) throw new Error("Access denied");
  if (!TABS[tab]) throw new Error("Unknown tab");
  var sh = sheetFor_(tab), headers = TABS[tab];
  if (!record.id) record.id = Utilities.getUuid().slice(0, 8);
  var values = sh.getDataRange().getValues();
  var idCol = headers.indexOf("id"), rowIndex = -1;
  for (var r = 1; r < values.length; r++) {
    if (String(values[r][idCol]) === String(record.id)) { rowIndex = r + 1; break; }
  }
  var rowData = headers.map(function(h){ return (record[h] !== undefined && record[h] !== null) ? record[h] : ""; });
  if (rowIndex === -1) sh.appendRow(rowData);
  else sh.getRange(rowIndex, 1, 1, headers.length).setValues([rowData]);
  return record;
}

function deleteRecord(tab, id) {
  if (!hasAccess_()) throw new Error("Access denied");
  var sh = sheetFor_(tab), headers = TABS[tab], idCol = headers.indexOf("id");
  var values = sh.getDataRange().getValues();
  for (var r = values.length - 1; r >= 1; r--) {
    if (String(values[r][idCol]) === String(id)) { sh.deleteRow(r + 1); break; }
  }
  return true;
}

// ===== Geocoding (keyless) =====
// Fills lat/lng for any Place, Stay, or show venue that has an address/venue but no
// coordinates yet, using Apps Script's built-in Maps geocoder (no API key, no billing —
// default quota). Results are written back into the Sheet so each spot is looked up once.
// Safe to run repeatedly: it skips rows that already have coordinates. Callable from the
// app's "Locate pins" button or from the editor dropdown.
function geocodeMissing() {
  if (!hasAccess_()) throw new Error("Access denied");
  // Which column on each tab holds the text we geocode from.
  var SOURCES = { Places: "address", Stays: "address", Artists: "venue", Timeline: "address" };
  // A Timeline stop with no address is only auto-located when its TITLE clearly names a
  // place — a transit hub or a Dutch street/place word — so generic stops like
  // "Hotel check-in" or "Arrive home" never drop a misleading pin. Fuzzy ("partial")
  // title matches are rejected too, and a failed title-guess is silent (not an error).
  var PLACEISH = /\b(airport|schiphol|station|centraal|terminal|gate|plein|straat|gracht|kade|dijk|weg|laan|park|rai|ndsm|a'?dam|amstel|vondel|museum|hbf|hauptbahnhof|gare|a[e\u00e9]roport)\b/i;
  var geocoder = Maps.newGeocoder().setRegion("nl");
  var located = 0, failed = [], scanned = 0;
  Object.keys(SOURCES).forEach(function(tab){
    var sh = ss_().getSheetByName(tab);
    if (!sh) return;
    var values = sh.getDataRange().getValues();
    if (values.length < 2) return;
    var headers = values[0].map(String);
    var latC = headers.indexOf("lat"), lngC = headers.indexOf("lng");
    var srcC = headers.indexOf(SOURCES[tab]), areaC = headers.indexOf("area"),
        nameC = headers.indexOf("name"), titleC = headers.indexOf("title");
    if (latC === -1 || lngC === -1) return; // headers will exist after ensureHeaders_ runs
    for (var r = 1; r < values.length; r++) {
      var row = values[r];
      if (row.join("") === "") continue;
      var hasCoord = (row[latC] !== "" && row[latC] != null) && (row[lngC] !== "" && row[lngC] != null);
      if (hasCoord) continue;
      var base = srcC !== -1 ? String(row[srcC] || "").trim() : "";
      if (!base && areaC !== -1) base = String(row[areaC] || "").trim();
      // Imported ideas usually arrive with a name + Maps URL but no address — fall back to
      // geocoding the name itself (most imports already carry coordinates from their URL).
      if (!base && tab === "Places" && nameC !== -1) base = String(row[nameC] || "").trim();
      var title = titleC !== -1 ? String(row[titleC] || "").trim() : "";
      // Timeline fallback: borrow a place-like title when no address was given.
      var viaTitle = false;
      if (!base && tab === "Timeline" && title && PLACEISH.test(title)) { base = title; viaTitle = true; }
      var label = (nameC !== -1 ? String(row[nameC] || "").trim() : "") || title || base;
      if (!base) continue;
      scanned++;
      // Rows that mention Maastricht (area, notes, source list…) geocode there instead.
      var city = /maastricht/i.test(row.join(" ")) ? "Maastricht" : "Amsterdam";
      var query = base + ", " + city + ", Netherlands";
      try {
        var res = geocoder.geocode(query);
        var ok = res && res.status === "OK" && res.results && res.results.length;
        if (ok && viaTitle && res.results[0].partial_match) {
          // best-effort title guess was fuzzy — skip quietly (no pin, no error)
        } else if (ok) {
          var loc = res.results[0].geometry.location;
          sh.getRange(r + 1, latC + 1).setValue(loc.lat);
          sh.getRange(r + 1, lngC + 1).setValue(loc.lng);
          located++;
        } else if (!viaTitle) {
          failed.push(label || base);
        }
      } catch (err) {
        if (!viaTitle) failed.push((label || base) + " (" + (err && err.message ? err.message : "error") + ")");
      }
      Utilities.sleep(150); // be gentle on the default quota
    }
  });
  return { located: located, scanned: scanned, failed: failed };
}

// ===== Settings (typical durations, gap buffer, per-day travel modes) =====
var DEFAULT_SETTINGS = {
  "dur.eat": "90", "dur.coffee": "45", "dur.drinks": "60", "dur.see": "120",
  "dur.do": "90", "dur.shop": "30", "dur.late": "30", "dur.other": "60", "gap.buffer": "15"
};
function ensureSettings_() {
  var sh = sheetFor_("Settings");
  var values = sh.getDataRange().getValues(), have = {};
  for (var r = 1; r < values.length; r++) if (values[r][0]) have[String(values[r][0])] = 1;
  var rows = [];
  Object.keys(DEFAULT_SETTINGS).forEach(function(k){ if (!have[k]) rows.push([k, DEFAULT_SETTINGS[k]]); });
  if (rows.length) sh.getRange(sh.getLastRow() + 1, 1, rows.length, 2).setValues(rows);
}
function setSetting(key, value) {
  if (!hasAccess_()) throw new Error("Access denied");
  var sh = sheetFor_("Settings");
  var values = sh.getDataRange().getValues();
  for (var r = 1; r < values.length; r++) {
    if (String(values[r][0]) === String(key)) { sh.getRange(r + 1, 2).setValue(String(value)); return true; }
  }
  sh.appendRow([String(key), String(value)]);
  return true;
}
function getSettingValue_(key) {
  var values = sheetFor_("Settings").getDataRange().getValues();
  for (var r = 1; r < values.length; r++) if (String(values[r][0]) === String(key)) return String(values[r][1] == null ? "" : values[r][1]).trim();
  return "";
}

// ===== Mode & home base (one codebase, two audiences) =====
// mode = "visitor" (the original trip shape: hotel anchor, flight seed) or
// "local" (NL-based: home-station anchor, per-night template, last-train planner).
// Chosen once on first open; changeable later from ⚙ in the app. Everything else
// (sets, bookings, places, map, gap-filler, hearts, weather, digest) is shared.
function chooseMode(mode, homeStation) {
  if (!hasAccess_()) throw new Error("Access denied");
  mode = (mode === "local") ? "local" : "visitor";
  var home = null;
  if (mode === "local" && homeStation && String(homeStation).trim()) home = setHomeStation(homeStation);
  setSetting("mode", mode);
  // Seed only into EMPTY tabs — existing users flipping modes keep all their data.
  if (readTab_("Places").length === 0) (mode === "local" ? SEED_PLACES_LOCAL : SEED_PLACES).forEach(function(p){ saveRecord("Places", p); });
  if (readTab_("Timeline").length === 0) (mode === "local" ? SEED_TIMELINE_LOCAL : SEED_TIMELINE).forEach(function(t){ saveRecord("Timeline", t); });
  try { geocodeMissing(); } catch (e) { /* pins can be located later from the Map tab */ }
  return { mode: mode, home: home };
}

// Geocode the home station (free-text, keyless — same geocoder as Places) and store it
// in Settings. It becomes the anchor for every distance/directions and the destination
// of the last-train engine.
function setHomeStation(name) {
  if (!hasAccess_()) throw new Error("Access denied");
  name = String(name || "").trim();
  if (!name) throw new Error("Type your home station first");
  var q = /station|centraal|\bcs\b/i.test(name) ? name : (name + " station");
  var res = Maps.newGeocoder().setRegion("nl").geocode(q + ", Netherlands");
  if (!(res && res.status === "OK" && res.results && res.results.length)) {
    throw new Error("Couldn't find \u201C" + name + "\u201D \u2014 try the full NS name (e.g. Utrecht Centraal)");
  }
  var loc = res.results[0].geometry.location;
  setSetting("home.name", name);
  setSetting("home.lat", loc.lat);
  setSetting("home.lng", loc.lng);
  return { name: name, lat: loc.lat, lng: loc.lng };
}

// ===== Import saved places from a Google Takeout CSV =====
// Accepts the raw text of a Takeout "Saved" list CSV (Title,Note,URL[,Comment]).
// Dedupes against existing Places by Maps URL and by accent/punctuation-insensitive
// name, names (but skips) near-duplicates, guesses a category from the list name,
// pulls coordinates straight out of the Maps URL when they're embedded, and files
// everything new as a 💡 "want" idea. Safe to re-run with fresh exports anytime.
function importSavedPlaces(csvText, listName) {
  if (!hasAccess_()) throw new Error("Access denied");
  var rows = parseCsv_(String(csvText || ""));
  if (!rows.length) return { added: 0, skipped: 0, nearDupes: [], noCoord: 0, category: "other" };
  var head = rows[0].map(function(h){ return String(h).trim().toLowerCase(); });
  var iTitle = head.indexOf("title"), iNote = head.indexOf("note"), iUrl = head.indexOf("url"), iComment = head.indexOf("comment");
  var start = (iTitle !== -1 || iUrl !== -1) ? 1 : 0;   // tolerate a headerless paste
  if (iTitle === -1) iTitle = 0;
  if (iUrl === -1) iUrl = 2;
  var existing = readTab_("Places"), byUrl = {}, byName = {}, names = [];
  existing.forEach(function(p){
    if (p.mapsUrl) byUrl[normUrl_(p.mapsUrl)] = 1;
    var n = normName_(p.name);
    if (n) { byName[n] = 1; names.push([n, p.name]); }
  });
  var cat = guessCategory_(listName || "");
  var added = 0, skipped = 0, nearDupes = [], noCoord = 0;
  for (var r = start; r < rows.length; r++) {
    var row = rows[r];
    if (!row || !row.join("").trim()) continue;
    // Repair rows where an unquoted Maps URL (they contain commas: @52.36,4.88,17z)
    // was split by the parser: re-join the URL cells, preserving any trailing columns.
    if (start === 1 && row.length > head.length && String(row[iUrl] || "").slice(0, 4) === "http") {
      var tailCols = head.length - iUrl - 1;
      var end = row.length - tailCols;
      row = row.slice(0, iUrl).concat([row.slice(iUrl, end).join(",")]).concat(row.slice(end));
    }
    var title = String(row[iTitle] || "").trim();
    if (!title) continue;
    var url = String(row[iUrl] || "").trim();
    var note = [iNote !== -1 ? row[iNote] : "", iComment !== -1 ? row[iComment] : ""]
      .map(function(x){ return String(x || "").trim(); }).filter(Boolean).join(" — ");
    var n = normName_(title);
    if ((url && byUrl[normUrl_(url)]) || byName[n]) { skipped++; continue; }   // exact dupe
    var near = null;
    for (var i = 0; i < names.length; i++) {
      var en = names[i][0];
      if (en.length > 4 && n.length > 4 && (en.indexOf(n) !== -1 || n.indexOf(en) !== -1)) { near = names[i][1]; break; }
    }
    if (near) { nearDupes.push(title + " ≈ " + near); skipped++; continue; }   // named, not guessed
    var coord = coordsFromMapsUrl_(url);
    if (!coord) noCoord++;
    saveRecord("Places", {
      name: title, category: cat, priority: "want", area: "", address: "", notes: note,
      mapsUrl: url, sourceList: String(listName || "").trim(),
      lat: coord ? coord[0] : "", lng: coord ? coord[1] : ""
    });
    byName[n] = 1; names.push([n, title]); if (url) byUrl[normUrl_(url)] = 1;
    added++;
  }
  return { added: added, skipped: skipped, nearDupes: nearDupes, noCoord: noCoord, category: cat };
}
// A small real CSV parser (quoted fields, embedded commas/newlines, "" escapes).
function parseCsv_(text) {
  var rows = [], row = [], cur = "", inQ = false;
  for (var i = 0; i < text.length; i++) {
    var ch = text[i];
    if (inQ) {
      if (ch === '"') { if (text[i + 1] === '"') { cur += '"'; i++; } else inQ = false; }
      else cur += ch;
    }
    else if (ch === '"') inQ = true;
    else if (ch === ",") { row.push(cur); cur = ""; }
    else if (ch === "\n" || ch === "\r") {
      if (ch === "\r" && text[i + 1] === "\n") i++;
      row.push(cur); cur = "";
      if (row.join("").trim() !== "") rows.push(row);
      row = [];
    }
    else cur += ch;
  }
  row.push(cur);
  if (row.join("").trim() !== "") rows.push(row);
  return rows;
}
function normName_(s){ return String(s || "").toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/[^a-z0-9]+/g, ""); }
function normUrl_(u){ return String(u || "").trim().replace(/^http:/, "https:").replace(/\/+$/, ""); }
// Takeout URLs usually embed the pin: prefer the precise !3d..!4d.. pair, then @lat,lng, then ?q=lat,lng.
function coordsFromMapsUrl_(u) {
  if (!u) return null;
  var m = String(u).match(/!3d(-?\d+\.\d+)!4d(-?\d+\.\d+)/);
  if (!m) m = String(u).match(/@(-?\d+\.\d+),(-?\d+\.\d+)/);
  if (!m) m = String(u).match(/[?&]q=(-?\d+\.\d+),(-?\d+\.\d+)/);
  if (!m) return null;
  var lat = parseFloat(m[1]), lng = parseFloat(m[2]);
  return (isNaN(lat) || isNaN(lng)) ? null : [lat, lng];
}
function guessCategory_(listName) {
  var s = String(listName || "").toLowerCase();
  if (/(coffee|caf|espresso|roaster)/.test(s)) return "coffee";
  if (/(bar|drink|beer|wine|cocktail|club|brewer)/.test(s)) return "drinks";
  if (/(eat|food|restaurant|dinner|lunch|brunch|snack|bakery)/.test(s)) return "eat";
  if (/(shop|store|market|vintage|record|boutique)/.test(s)) return "shop";
  if (/(museum|sight|see|gallery|art|landmark)/.test(s)) return "see";
  if (/\b(do|activity|activities|experience|park|tour)\b/.test(s)) return "do";
  return "other";
}

// ===== Real route times (keyless, cached forever) =====
// Looks up walking / transit / driving minutes between coordinate pairs with the built-in
// DirectionFinder (same free service as the geocoder) and caches every answer in the
// RouteCache sheet — each pair+mode is only ever fetched once, keeping quota use tiny.
// pairs: [{o:[lat,lng], d:[lat,lng], mode:"walking"|"transit"|"driving"}], max 8 per call.
function getRoutes(pairs) {
  if (!hasAccess_()) throw new Error("Access denied");
  pairs = (pairs || []).slice(0, 8);
  var sh = sheetFor_("RouteCache");
  var values = sh.getDataRange().getValues(), cache = {};
  for (var r = 1; r < values.length; r++) if (values[r][0]) cache[String(values[r][0])] = { mins: Number(values[r][1]), km: Number(values[r][2]) };
  var MODES = { walking: Maps.DirectionFinder.Mode.WALKING, transit: Maps.DirectionFinder.Mode.TRANSIT, driving: Maps.DirectionFinder.Mode.DRIVING, bicycling: Maps.DirectionFinder.Mode.BICYCLING };
  var out = {};
  pairs.forEach(function(p) {
    if (!p || !p.o || !p.d) return;
    var key = routeKey_(p);
    if (cache[key]) { out[key] = cache[key]; return; }
    try {
      var f = Maps.newDirectionFinder()
        .setOrigin(Number(p.o[0]), Number(p.o[1]))
        .setDestination(Number(p.d[0]), Number(p.d[1]))
        .setMode(MODES[p.mode] || MODES.walking);
      if (p.mode === "transit") f.setDepart(new Date());
      var res = f.getDirections();
      var leg = res && res.routes && res.routes[0] && res.routes[0].legs && res.routes[0].legs[0];
      if (leg) {
        var rec = { mins: Math.max(1, Math.round(leg.duration.value / 60)), km: Math.round(leg.distance.value / 100) / 10 };
        sh.appendRow([key, rec.mins, rec.km]);
        cache[key] = rec; out[key] = rec;
      }
      Utilities.sleep(120);   // be gentle on the default quota
    } catch (e) { /* the client keeps its local estimate */ }
  });
  return out;
}
function routeKey_(p){ function f(n){ return Math.round(Number(n) * 1e4) / 1e4; } return f(p.o[0]) + "," + f(p.o[1]) + "|" + f(p.d[0]) + "," + f(p.d[1]) + "|" + p.mode; }

// ===== FLAGSHIP: last-train-home routing =====
// Same free DirectionFinder + RouteCache pattern as getRoutes, but pinned to a real
// departure moment: "the transit connection home leaving at/after this set's end".
// The Nachtnet corridor table (frontend) decides whether a night train even exists
// tonight; this call fills in the actual times. Together they cover each other —
// the table still answers if routing fails, and routing corrects thin 4am estimates.
// reqs: [{key:"NT|…", o:[lat,lng], d:[lat,lng], departMs}], max 6 per call.
// Returns {key: {depMin, arrMin}} in epoch-MINUTES. Cached forever in RouteCache
// under the "NT|" key prefix (mins = departure epoch-minute, km = arrival epoch-minute
// — reusing the sheet's two number columns; the prefix keeps them apart from walk/
// transit/drive rows).
function getNightRoutes(reqs) {
  if (!hasAccess_()) throw new Error("Access denied");
  reqs = (reqs || []).slice(0, 6);
  var sh = sheetFor_("RouteCache");
  var values = sh.getDataRange().getValues(), cache = {};
  for (var r = 1; r < values.length; r++) if (String(values[r][0]).slice(0, 3) === "NT|") cache[String(values[r][0])] = { depMin: Number(values[r][1]), arrMin: Number(values[r][2]) };
  var out = {};
  reqs.forEach(function(p) {
    if (!p || !p.key || !p.o || !p.d || !p.departMs) return;
    if (cache[p.key]) { out[p.key] = cache[p.key]; return; }
    try {
      var res = Maps.newDirectionFinder()
        .setOrigin(Number(p.o[0]), Number(p.o[1]))
        .setDestination(Number(p.d[0]), Number(p.d[1]))
        .setMode(Maps.DirectionFinder.Mode.TRANSIT)
        .setDepart(new Date(Number(p.departMs)))
        .getDirections();
      var leg = res && res.routes && res.routes[0] && res.routes[0].legs && res.routes[0].legs[0];
      if (leg && leg.departure_time && leg.arrival_time) {
        var rec = { depMin: Math.round(leg.departure_time.value / 60), arrMin: Math.round(leg.arrival_time.value / 60) };
        sh.appendRow([p.key, rec.depMin, rec.arrMin]);
        out[p.key] = rec;
      } else if (leg) {
        // No timetable in the answer (rare at 4am) — approximate from duration, don't cache.
        var dep = Math.round(Number(p.departMs) / 60000);
        out[p.key] = { depMin: dep, arrMin: dep + Math.max(1, Math.round(leg.duration.value / 60)) };
      }
      Utilities.sleep(150); // be gentle on the default quota
    } catch (e) { /* the client falls back to the corridor table's honest message */ }
  });
  return out;
}

// ===== Optional: live NS departures — the ONE feature needing extra setup =====
// Register a free key at apiportal.ns.nl and paste it into ⚙ in the app (stored as
// Settings "ns.apikey"). Real-time platform + delay data for the home station.
// Without a key this quietly returns null and the app keeps its routed estimates —
// everything else is fully functional.
function getLiveDepartures() {
  if (!hasAccess_()) throw new Error("Access denied");
  var key = getSettingValue_("ns.apikey"), station = getSettingValue_("home.name");
  if (!key || !station) return null;
  var cacheKey = "nsdep_" + normName_(station).slice(0, 80);
  var cache = CacheService.getScriptCache(), hit = cache.get(cacheKey);
  if (hit) return JSON.parse(hit);
  try {
    var url = "https://gateway.apiportal.ns.nl/reisinformatie-api/api/v2/departures?station=" + encodeURIComponent(station) + "&maxJourneys=8";
    var res = UrlFetchApp.fetch(url, { muteHttpExceptions: true, headers: { "Ocp-Apim-Subscription-Key": key } });
    if (res.getResponseCode() !== 200) return null;
    var deps = (JSON.parse(res.getContentText()).payload || {}).departures || [];
    var out = deps.map(function(d){
      return { time: String(d.plannedDateTime || "").slice(11, 16),
               actual: String(d.actualDateTime || "").slice(11, 16),
               to: d.direction || "", platform: d.actualTrack || d.plannedTrack || "",
               cancelled: !!d.cancelled };
    });
    cache.put(cacheKey, JSON.stringify(out), 120); // 2-min cache — it's live data
    return out;
  } catch (e) { return null; }
}

// ===== Weather (Open-Meteo — free, keyless, cached 30 min) =====
function getWeather() {
  if (!hasAccess_()) throw new Error("Access denied");
  try { return fetchWeather_(); } catch (e) { return null; }
}
function fetchWeather_() {
  var cache = CacheService.getScriptCache();
  var hit = cache.get("wx_ams");
  if (hit) return JSON.parse(hit);
  var url = "https://api.open-meteo.com/v1/forecast?latitude=52.37&longitude=4.90"
    + "&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max"
    + "&timezone=Europe%2FAmsterdam&forecast_days=5";
  var res = UrlFetchApp.fetch(url, { muteHttpExceptions: true });
  if (res.getResponseCode() !== 200) return null;
  var d = JSON.parse(res.getContentText()).daily;
  var out = { dates: d.time, code: d.weather_code, hi: d.temperature_2m_max, lo: d.temperature_2m_min, rain: d.precipitation_probability_max };
  cache.put("wx_ams", JSON.stringify(out), 1800);
  return out;
}

// ===== Morning digest (opt-in, per person) =====
// The daily trigger runs as whoever enabled it and emails only that person — so each of
// you toggles your own from the Overview and nothing doubles up. Quiet on empty days.
function enableDigest() {
  if (!hasAccess_()) throw new Error("Access denied");
  disableDigest();
  ScriptApp.newTrigger("sendDailyDigest").timeBased().everyDays(1).atHour(8).nearMinute(0)
    .inTimezone(ss_().getSpreadsheetTimeZone()).create();
  return true;
}
function disableDigest() {
  if (!hasAccess_()) throw new Error("Access denied");
  ScriptApp.getProjectTriggers().forEach(function(t){ if (t.getHandlerFunction() === "sendDailyDigest") ScriptApp.deleteTrigger(t); });
  return true;
}
function getDigestStatus() {
  if (!hasAccess_()) throw new Error("Access denied");
  return ScriptApp.getProjectTriggers().some(function(t){ return t.getHandlerFunction() === "sendDailyDigest"; });
}
var DAYDATE_GS = { wed: "2026-10-21", thu: "2026-10-22", fri: "2026-10-23", sat: "2026-10-24", sun: "2026-10-25" };
function sendDailyDigest() {
  var email = Session.getEffectiveUser().getEmail();
  if (!email) return;
  var tz = ss_().getSpreadsheetTimeZone();
  var today = Utilities.formatDate(new Date(), tz, "yyyy-MM-dd");
  var evs = [];
  function truthy(v){ return v === true || v === "true" || v === "TRUE" || v === 1 || v === "1"; }
  readTab_("Timeline").forEach(function(t){
    if (t.date === today && !truthy(t.done)) evs.push({ time: t.time || "", title: t.title, where: t.address || "", lat: t.lat, lng: t.lng });
  });
  readTab_("Artists").forEach(function(a){
    if (DAYDATE_GS[a.day] === today) evs.push({ time: a.startTime || "", title: "\uD83C\uDFB5 " + a.name, where: a.venue || "", lat: a.lat, lng: a.lng });
  });
  readTab_("Tickets").forEach(function(t){
    if (t.eventDate === today && !String(t.artistIds || "").trim())
      evs.push({ time: t.eventTime || "", title: (t.type === "reservation" ? "\uD83C\uDF7D " : "\uD83C\uDFAB ") + (t.event || "Booking"), where: t.venue || "" });
  });
  if (!evs.length) return;   // nothing planned — stay quiet
  evs.sort(function(a, b){ return String(a.time || "99:99").localeCompare(String(b.time || "99:99")); });
  var stay = anchorForDigest_();   // hotel in visitor mode, home station in local mode
  var wxLine = "";
  try {
    var wx = fetchWeather_();
    if (wx && wx.dates) {
      var i = wx.dates.indexOf(today);
      if (i !== -1) wxLine = "<p style='font-size:15px'>" + wxEmoji_(wx.code[i]) + " " + Math.round(wx.hi[i]) + "\u00B0 / " + Math.round(wx.lo[i]) + "\u00B0C \u00B7 \uD83D\uDCA7 " + wx.rain[i] + "% chance of rain</p>";
    }
  } catch (e) {}
  var rows = evs.map(function(e){
    var dir = "";
    if (stay && e.lat !== "" && e.lat != null && e.lng !== "" && e.lng != null)
      dir = ' \u2014 <a href="https://www.google.com/maps/dir/?api=1&origin=' + stay.lat + ',' + stay.lng + '&destination=' + e.lat + ',' + e.lng + '&travelmode=transit">directions</a>';
    return "<li style='margin-bottom:6px'><b>" + (e.time ? htmlEsc_(e.time) : "sometime") + "</b> \u2014 " + htmlEsc_(e.title) + (e.where ? " \u00B7 " + htmlEsc_(e.where) : "") + dir + "</li>";
  }).join("");
  MailApp.sendEmail({
    to: email,
    subject: "Today's plan \u2014 " + Utilities.formatDate(new Date(), tz, "EEE, MMM d"),
    htmlBody: "<div style='font-family:sans-serif'><h3>Your day</h3>" + wxLine + "<ul style='padding-left:18px'>" + rows + "</ul><p style='color:#888'>\u2014 ADE 2026 Planner</p></div>"
  });
}
// The digest's "directions from" origin: the home station when mode = local,
// otherwise the first located stay — the same generalized anchor the app uses.
function anchorForDigest_() {
  if (getSettingValue_("mode") === "local") {
    var la = parseFloat(getSettingValue_("home.lat")), ln = parseFloat(getSettingValue_("home.lng"));
    if (!isNaN(la) && !isNaN(ln)) return { name: getSettingValue_("home.name") || "home", lat: la, lng: ln };
  }
  return readTab_("Stays").filter(function(s){ return s.lat !== "" && s.lat != null && s.lng !== "" && s.lng != null; })[0] || null;
}
function htmlEsc_(s){ return String(s == null ? "" : s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
function wxEmoji_(c){ c = Number(c); if (c === 0) return "\u2600\uFE0F"; if (c <= 2) return "\uD83C\uDF24"; if (c === 3) return "\u2601\uFE0F"; if (c <= 48) return "\uD83C\uDF2B"; if (c <= 67) return "\uD83C\uDF27"; if (c <= 77) return "\uD83C\uDF28"; if (c <= 82) return "\uD83C\uDF26"; if (c <= 86) return "\uD83C\uDF28"; return "\u26C8"; }

// Run this ONCE from the editor.
function setup() {
  Object.keys(TABS).forEach(function(tab){
    var sh = ss_().getSheetByName(tab) || ss_().insertSheet(tab);
    if (sh.getLastRow() === 0) sh.appendRow(TABS[tab]);
    ensureHeaders_(sh, tab);
    sh.getRange(1, 1, 1, TABS[tab].length).setFontWeight("bold").setBackground("#1e293b").setFontColor("#ffffff");
    sh.setFrozenRows(1);
    (TEXT_COLS[tab] || []).forEach(function(col){
      var c = TABS[tab].indexOf(col) + 1;
      if (c > 0) sh.getRange(2, c, 2000, 1).setNumberFormat("@");
    });
  });
  var def = ss_().getSheetByName("Sheet1");
  if (def && def.getLastRow() === 0) ss_().deleteSheet(def);
  // Seeds load when the mode is chosen on first open ("visiting" vs "I live here") —
  // see chooseMode(). If a mode is already set (re-running setup after an update),
  // honor it here so a wiped tab can be re-seeded from the editor.
  var mode = getSettingValue_("mode");
  if (mode) {
    if (readTab_("Places").length === 0) (mode === "local" ? SEED_PLACES_LOCAL : SEED_PLACES).forEach(function(p){ saveRecord("Places", p); });
    if (readTab_("Timeline").length === 0) (mode === "local" ? SEED_TIMELINE_LOCAL : SEED_TIMELINE).forEach(function(t){ saveRecord("Timeline", t); });
  }
  try { geocodeMissing(); } catch (e) { /* non-fatal: pins can be located later from the Map tab */ }
}

var SEED_PLACES = [
  {name:"Toko Bersama West",category:"eat",priority:"want",area:"Oud-West",address:"Bilderdijkstraat 116",notes:"Phenomenal authentic Indonesian (4.9, 11k+ reviews). Big 'Rames Complete' platter."},
  {name:"Sampurna",category:"eat",priority:"want",area:"Centrum",address:"Singel 498",notes:"Classic central rijsttafel — Amsterdam's signature Indonesian feast. Reserve."},
  {name:"Warung Spang Makandra",category:"eat",priority:"want",area:"De Pijp",address:"Gerard Doustraat 33",notes:"Beloved cheap Surinamese: roti, chicken satay, bara."},
  {name:"Papa Aswa",category:"eat",priority:"want",area:"Oost",address:"Metselstraat 44",notes:"Stylish upscale Surinamese. Books up weeks ahead."},
  {name:"Moeders",category:"eat",priority:"want",area:"Jordaan",address:"Rozengracht 251",notes:"'Mothers' — institution for stamppot & Dutch comfort food. Reservation required."},
  {name:"The Pantry",category:"eat",priority:"want",area:"Leidseplein",address:"Leidsekruisstraat 21",notes:"Cozy traditional Dutch; hearty portions. Book ahead."},
  {name:"Cafe 't Smalle",category:"drinks",priority:"want",area:"Jordaan",address:"Egelantiersgracht 12",notes:"Gorgeous canalside brown cafe. Bitterballen + Dutch beer."},
  {name:"Cafe Hoppe",category:"drinks",priority:"want",area:"Spui, Centrum",address:"Spui 18-20",notes:"Historic sand-floored brown bar. Jenever + bitterballen."},
  {name:"Albert Cuyp Markt",category:"see",priority:"want",area:"De Pijp",address:"Albert Cuypstraat",notes:"Street market: fresh stroopwafels, kibbeling, cheese. Closed Sundays."},
  {name:"Foodhallen",category:"eat",priority:"want",area:"Oud-West",address:"Hannie Dankbaarpassage 16",notes:"Indoor food hall in an old tram depot. Many stalls."},
  {name:"Restaurant Showw",category:"eat",priority:"want",area:"Zuid (near RAI)",address:"Gelrestraat 28",notes:"1 Michelin star, 4.9. Raved value. Reserve well ahead."},
  {name:"Restaurant Flore",category:"eat",priority:"want",area:"Centrum (Amstel)",address:"Nieuwe Doelenstraat 2-14",notes:"2 Michelin stars, plant-forward, on the Amstel."},
  {name:"Ciel Bleu",category:"eat",priority:"want",area:"De Pijp / Zuid",address:"Ferdinand Bolstraat 333",notes:"2 stars, 23rd floor of Hotel Okura, city views."},
  {name:"MOS",category:"eat",priority:"want",area:"IJdok (near Centraal)",address:"IJdok 185",notes:"1 star, waterfront, seafood-forward."},
  {name:"Rijksmuseum",category:"see",priority:"want",area:"Museumplein, Zuid",address:"Museumstraat 1",notes:"Night Watch + Vermeer. Book timed tickets ahead."},
  {name:"Van Gogh Museum",category:"see",priority:"want",area:"Museumplein, Zuid",address:"Museumplein 6",notes:"Timed entry — book online ahead."},
  {name:"Anne Frank House",category:"see",priority:"want",area:"Centrum / Jordaan",address:"Prinsengracht 263-267",notes:"Timed tickets sell out weeks ahead — grab them the moment they release."},
  {name:"Canal Cruise (UNESCO canal ring)",category:"do",priority:"want",area:"Centrum",address:"Docks near Centraal / Damrak",notes:"Evening cruises are magic. Pick a small-boat tour."},
  {name:"Jordaan wander",category:"do",priority:"want",area:"Jordaan",address:"Jordaan district",notes:"Amsterdam's prettiest quarter. Best with no plan."},
  {name:"Vondelpark",category:"do",priority:"want",area:"Oud-Zuid",address:"Vondelpark",notes:"Big leafy park to reset between late nights."},
  {name:"A'DAM Lookout",category:"do",priority:"want",area:"Noord",address:"Overhoeksplein 5",notes:"Rooftop + swing over the IJ. Free ferry behind Centraal; near NDSM venues."},
  {name:"Brouwerij 't IJ",category:"drinks",priority:"want",area:"Oost",address:"Funenkade 7",notes:"Tasting room at the foot of a windmill."}
];

var SEED_TIMELINE = [
  {title:"Dog sitter arrives",date:"",time:"",kind:"prep",details:"Confirm arrival, key/lockbox, feeding + walk schedule, vet contact.",done:false},
  {title:"Leave for the airport",date:"",time:"",kind:"transit",details:"Set once flight is booked. Parking/ride arranged? Bags packed.",done:false},
  {title:"Outbound flight departs",date:"",time:"",kind:"flight",details:"Airline + flight #, terminal, seats, boarding time.",done:false},
  {title:"Layover — land",date:"",time:"",kind:"layover",details:"Connecting airport, arrival gate, landing time.",done:false},
  {title:"Layover — connecting flight departs",date:"",time:"",kind:"layover",details:"Departure gate, layover length.",done:false},
  {title:"Arrive in Amsterdam (Schiphol)",date:"",time:"",kind:"transit",details:"Passport control, eSIM, transit card, train/taxi to hotel.",done:false},
  {title:"Hotel check-in",date:"",time:"",kind:"lodging",details:"Address, earliest check-in, confirmation #.",done:false},
  {title:"Hotel check-out",date:"",time:"",kind:"lodging",details:"Check-out time, luggage storage if flight is later.",done:false},
  {title:"Return flight departs (Schiphol)",date:"",time:"",kind:"flight",details:"Leave with a buffer — Schiphol security is slow. Flight #, terminal.",done:false},
  {title:"Layover (return)",date:"",time:"",kind:"layover",details:"Connecting airport, gates, layover length.",done:false},
  {title:"Arrive home",date:"",time:"",kind:"transit",details:"Ride/parking pickup. Dog sitter handoff.",done:false}
];

// ===== Local-edition seeds (mode = "local") =====
// Not the guidebook — what's useful at closing time: 4am food, comedown coffee,
// the night pharmacy, and the P+R sites for the drive-in crowd.
var SEED_PLACES_LOCAL = [
  {name:"FEBO Leidsestraat",category:"late",priority:"want",area:"Leidseplein",address:"Leidsestraat 94",notes:"The automat wall of kroketten & frikandellen — the classic 4am stop."},
  {name:"Chipsy King",category:"late",priority:"want",area:"Centrum",address:"Damrak 42",notes:"Fries met oorlog at stupid o'clock, on the walk to Centraal."},
  {name:"Manneken Pis",category:"late",priority:"want",area:"Centrum",address:"Damrak 41",notes:"Famous fries — long queue by day, quick late. Also on the way to the station."},
  {name:"Wok to Walk Leidsestraat",category:"late",priority:"want",area:"Leidseplein",address:"Leidsestraat 96",notes:"Hot noodles when everything else's kitchen is closed."},
  {name:"New York Pizza Reguliersbreestraat",category:"late",priority:"maybe",area:"Rembrandtplein",address:"Reguliersbreestraat 15",notes:"Open very late on club nights — a slice for the night bus."},
  {name:"Coffee & Coconuts",category:"coffee",priority:"want",area:"De Pijp",address:"Ceintuurbaan 282-284",notes:"Big, easy comedown breakfast the morning after."},
  {name:"Back to Black",category:"coffee",priority:"maybe",area:"Weteringbuurt",address:"Weteringstraat 48",notes:"Small, quiet, excellent — recovery coffee."},
  {name:"Dam Apotheek (late-hours pharmacy)",category:"other",priority:"maybe",area:"Centrum",address:"Damstraat 2",notes:"Extended opening hours. For tonight's on-duty dienstapotheek anywhere in NL, check apotheek.nl."},
  {name:"P+R Noord",category:"other",priority:"maybe",area:"Noord",address:"Buikslotermeerplein 1000",notes:"Park & Ride — the cheap P+R rate only applies if you check in AND out by public transport. Metro 52 into town."},
  {name:"P+R RAI",category:"other",priority:"maybe",area:"Zuid",address:"Europaboulevard 2",notes:"Handy for RAI-side venues. Same rule: check in/out by GVB for the P+R rate."},
  {name:"P+R Sloterdijk",category:"other",priority:"maybe",area:"West",address:"Piarcoplein 1",notes:"Train + metro hub; good for west-side and NDSM-ish nights."},
  {name:"P+R Arena",category:"other",priority:"maybe",area:"Zuidoost",address:"Burgemeester Stramanweg 130",notes:"For Zuidoost venues (Ziggo Dome / AFAS side)."}
];

// Per-night in-and-out template — one block per ADE night, replacing the flight /
// layover / dog-sitter seed. Times stay blank until you decide which nights you're in.
var SEED_TIMELINE_LOCAL = (function(){
  var nights = [["Wed","2026-10-21"],["Thu","2026-10-22"],["Fri","2026-10-23"],["Sat","2026-10-24"],["Sun","2026-10-25"]];
  var out = [];
  nights.forEach(function(n){
    out.push({title:"\uD83D\uDE86 Train in \u2014 "+n[0]+" night",date:n[1],time:"",kind:"transit",
      details:"Check NS disruptions / engineering works before you leave (ADE weekend often has works). OV-fiets at the arrival station if the venue's a ride away.",done:false});
    out.push({title:"\uD83C\uDF19 Last train / night bus home \u2014 "+n[0]+" night",date:n[1],time:"",kind:"transit",
      details:"The \uD83D\uDFE2/\uD83D\uDFE0/\uD83D\uDD34 badge on each set fills this in. Heads-up: Sunday night is NOT a Nachtnet weekend night \u2014 only Fri\u2192Sat and Sat\u2192Sun get the extended network.",done:false});
  });
  return out;
})();

📄 File 2: Index.html

In Apps Script: + → HTML → name it exactly Index (fresh install) or open your existing Index file (upgrade), and paste this over the entire contents.

<!DOCTYPE html>
<html>
<head>
<base target="_top">
<meta charset="utf-8">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
      integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
      integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<style>
  * { box-sizing: border-box; margin: 0; padding: 0; }
  body { font-family: -apple-system, system-ui, sans-serif; background:#020617; color:#e2e8f0; }
  .loading { padding:60px; text-align:center; color:#94a3b8; }
  .header { background:linear-gradient(135deg,#c026d3,#7e22ce,#3730a3); padding:22px 20px; }
  .header .kick { font-size:11px; letter-spacing:.15em; text-transform:uppercase; color:#f5d0fe; font-weight:600; }
  .header h1 { font-size:24px; margin-top:2px; }
  .header .sub { font-size:13px; color:#ede9fe; margin-top:2px; }
  .htop { display:flex; justify-content:space-between; align-items:flex-start; }
  .cd { text-align:right; } .cd b { font-size:30px; display:block; line-height:1; } .cd span { font-size:11px; color:#ede9fe; }
  .nav { display:flex; overflow-x:auto; background:#0b1120; border-bottom:1px solid #1e293b; position:sticky; top:0; z-index:10; }
  .nav button { flex:1; min-width:84px; background:none; border:none; border-bottom:2px solid transparent; color:#94a3b8; padding:10px; font-size:12px; font-weight:600; cursor:pointer; }
  .nav button.active { color:#f0abfc; border-bottom-color:#d946ef; }
  .wrap { max-width:680px; margin:0 auto; padding:18px 16px 90px; }
  .card { background:#0f172a; border:1px solid #1e293b; border-radius:14px; padding:14px; margin-bottom:10px; }
  .row { display:flex; justify-content:space-between; align-items:flex-start; gap:8px; }
  .name { font-weight:700; color:#f1f5f9; }
  .muted { color:#94a3b8; font-size:13px; }
  .tiny { color:#64748b; font-size:12px; }
  .badge { display:inline-flex; align-items:center; gap:4px; font-size:11px; padding:2px 8px; border-radius:999px; border:1px solid; margin:4px 4px 0 0; }
  .grouphdr { font-size:11px; letter-spacing:.08em; text-transform:uppercase; color:#64748b; font-weight:600; margin:14px 0 4px; }
  .iconbtn { background:none; border:none; color:#64748b; cursor:pointer; font-size:15px; padding:4px; }
  .iconbtn:hover { color:#e2e8f0; }
  .linkbtn { display:inline-flex; align-items:center; gap:5px; font-size:12px; padding:6px 11px; border-radius:9px; background:#1e293b; border:1px solid #334155; color:#e2e8f0; text-decoration:none; cursor:pointer; margin:8px 6px 0 0; }
  .grid { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
  .stat { background:#0f172a; border:1px solid #1e293b; border-radius:14px; padding:14px; cursor:pointer; }
  .stat .big { font-size:24px; font-weight:700; }
  .filterbar { display:flex; gap:8px; overflow-x:auto; padding-bottom:6px; margin-bottom:8px; }
  .chip { white-space:nowrap; font-size:12px; padding:6px 12px; border-radius:999px; border:1px solid #334155; color:#94a3b8; background:none; cursor:pointer; }
  .chip.active { background:rgba(217,70,239,.18); border-color:rgba(217,70,239,.5); color:#f0abfc; }
  .fab { position:fixed; right:18px; bottom:18px; width:56px; height:56px; border-radius:50%; border:none; background:linear-gradient(135deg,#d946ef,#7c3aed); color:#fff; font-size:28px; cursor:pointer; box-shadow:0 8px 24px rgba(217,70,239,.4); z-index:20; }
  .status { position:fixed; left:18px; bottom:22px; font-size:12px; color:#64748b; z-index:20; }
  .empty { text-align:center; padding:48px 0; color:#64748b; font-size:14px; }
  .ovbtn { width:100%; background:#0f172a; border:1px solid #1e293b; border-radius:14px; padding:14px; color:#e2e8f0; text-align:left; cursor:pointer; }
  .timeline { position:relative; padding-left:20px; }
  .timeline:before { content:''; position:absolute; left:5px; top:6px; bottom:6px; width:1px; background:#1e293b; }
  .dot { position:absolute; left:-19px; top:14px; width:12px; height:12px; border-radius:50%; border:2px solid #d946ef; background:#020617; cursor:pointer; }
  .dot.done { background:#10b981; border-color:#10b981; }
  .done-txt { text-decoration:line-through; opacity:.6; }
  .modal-bg { position:fixed; inset:0; background:rgba(0,0,0,.7); display:flex; align-items:flex-end; justify-content:center; z-index:50; }
  .modal { background:#0f172a; border:1px solid #1e293b; border-radius:18px 18px 0 0; width:100%; max-width:520px; max-height:92vh; display:flex; flex-direction:column; }
  @media(min-width:600px){ .modal-bg{align-items:center;} .modal{border-radius:18px;} }
  .modal h3 { padding:16px; border-bottom:1px solid #1e293b; font-size:16px; }
  .modal .body { padding:16px; overflow-y:auto; }
  .modal .foot { padding:16px; border-top:1px solid #1e293b; display:flex; gap:8px; }
  label.fld { display:block; font-size:12px; color:#94a3b8; margin:0 0 4px; }
  .fldwrap { margin-bottom:12px; }
  input, select, textarea { width:100%; background:#020617; border:1px solid #334155; border-radius:9px; padding:9px; color:#f1f5f9; font-size:14px; font-family:inherit; }
  .half { display:flex; gap:10px; } .half > div { flex:1; }
  .btn { flex:1; padding:10px; border-radius:9px; font-size:14px; font-weight:600; cursor:pointer; border:1px solid #334155; background:none; color:#cbd5e1; }
  .btn.primary { background:linear-gradient(135deg,#d946ef,#7c3aed); color:#fff; border:none; }
  iframe { width:100%; height:200px; border:0; border-radius:9px; margin-top:10px; }

  /* ===== Timeline + Calendar (timeline tab) ===== */
  .tlbar { display:flex; gap:8px; align-items:center; margin-bottom:12px; flex-wrap:wrap; }
  .seg { display:inline-flex; background:#0b1120; border:1px solid #1e293b; border-radius:11px; padding:3px; }
  .seg button { border:none; background:none; color:#94a3b8; font-size:12px; font-weight:600; padding:7px 11px; border-radius:8px; cursor:pointer; white-space:nowrap; }
  .seg button.on { background:linear-gradient(135deg,#d946ef,#7c3aed); color:#fff; }
  .legend { display:flex; flex-wrap:wrap; gap:6px; margin-bottom:16px; }

  .dayhdr { display:flex; align-items:baseline; gap:8px; font-size:13px; font-weight:700; color:#e2e8f0; margin:20px 0 8px; padding-bottom:6px; border-bottom:1px solid #1e293b; }
  .dayhdr .dow { color:#f0abfc; }
  .dayhdr .cnt { margin-left:auto; font-size:11px; color:#64748b; font-weight:500; }
  .emptyday { font-size:11px; color:#475569; font-style:italic; padding:4px 0 4px 24px; }
  .gapdivider { text-align:center; font-size:11px; color:#64748b; letter-spacing:.04em; margin:14px 0; padding:6px 0; border-top:1px dashed #1e293b; border-bottom:1px dashed #1e293b; }

  .tline { position:relative; padding-left:24px; }
  .tline:before { content:''; position:absolute; left:6px; top:6px; bottom:6px; width:2px; background:#1e293b; border-radius:2px; }
  .gaplbl { display:flex; align-items:center; gap:7px; font-size:10px; color:#475569; padding:3px 0 0 2px; }
  .gaplbl:before { content:''; width:14px; height:1px; background:#1e293b; }
  .ev { position:relative; }
  .ev .edot { position:absolute; left:-22px; top:9px; width:13px; height:13px; border-radius:50%; border:2px solid; background:#020617; cursor:pointer; z-index:2; }
  .ev .evcard { background:#0f172a; border:1px solid #1e293b; border-left-width:3px; border-radius:12px; padding:11px 13px; }
  .ev.w3 .evcard { border-left-width:4px; padding:13px 14px; }
  .ev.w1 .evcard { border-left-width:2px; padding:9px 12px; opacity:.74; }
  .ev .ewhen { font-size:11px; font-weight:600; }
  .ev .etitle { font-weight:700; color:#f1f5f9; font-size:14px; margin-top:1px; }
  .ev.w3 .etitle { font-size:15px; }
  .ev.w1 .etitle { font-size:13px; font-weight:600; }

  .calwrap { overflow-x:auto; -webkit-overflow-scrolling:touch; padding-bottom:6px; }
  .calgrid { display:flex; gap:8px; min-width:min-content; }
  .calgutter { flex:none; width:42px; }
  .calcol { flex:none; width:150px; }
  .calcolhdr { text-align:center; font-size:11px; font-weight:600; color:#94a3b8; padding:6px 0 8px; }
  .calcolhdr .dow { display:block; font-size:14px; font-weight:700; color:#f0abfc; }
  .calbody { position:relative; border-left:1px solid #1e293b; }
  .calgutter .calbody { border-left:none; }
  .hourline { position:absolute; left:0; right:0; border-top:1px solid #0f172a; }
  .hourlbl { position:absolute; right:5px; font-size:10px; color:#475569; transform:translateY(-50%); }
  .cev { position:absolute; border:1px solid; border-left-width:3px; border-radius:8px; padding:4px 6px; overflow:hidden; cursor:pointer; }
  .cev .ct { display:block; font-weight:700; color:#f8fafc; font-size:11px; line-height:1.2; overflow:hidden; }
  .cev .cw { font-size:10px; }

  .artpick { max-height:240px; overflow-y:auto; border:1px solid #334155; border-radius:9px; padding:4px; background:#020617; }
  .artrow { display:flex; align-items:flex-start; gap:10px; padding:8px; border-radius:8px; cursor:pointer; }
  .artrow:hover { background:#0f172a; }
  .artrow input { width:18px; height:18px; flex:none; margin-top:1px; accent-color:#d946ef; }
  .artrow-t { display:block; color:#e2e8f0; font-size:14px; line-height:1.3; }
  .artrow-t .tiny { display:block; }

  /* ===== Map tab ===== */
  #map { height:62vh; min-height:380px; border:1px solid #1e293b; border-radius:14px; background:#0b1120; z-index:1; }
  #map .leaflet-interactive { filter: drop-shadow(0 0 1px rgba(0,0,0,.9)); }
  .leaflet-popup-content-wrapper, .leaflet-popup-tip { background:#0f172a; color:#e2e8f0; border:1px solid #1e293b; }
  .leaflet-popup-content { margin:11px 13px; font-family:inherit; font-size:13px; line-height:1.4; }
  .leaflet-container { font-family:inherit; }
  .leaflet-container a.leaflet-popup-close-button { color:#64748b; }
  .pop-name { font-weight:700; color:#f1f5f9; font-size:14px; }
  .pop-when { color:#f0abfc; font-size:12px; margin-top:2px; }
  .pop-row { margin-top:6px; }
  .pop-row a { color:#7dd3fc; text-decoration:none; font-weight:600; }
  .maptoggles { display:flex; flex-wrap:wrap; gap:7px; margin-bottom:10px; }
  .mtog { display:inline-flex; align-items:center; gap:6px; font-size:12px; font-weight:600; padding:6px 11px; border-radius:999px; border:1px solid #334155; background:#0b1120; color:#94a3b8; cursor:pointer; user-select:none; }
  .mtog.on { color:#e2e8f0; border-color:#475569; background:#0f172a; }
  .mtog .sw { width:11px; height:11px; border-radius:50%; flex:none; }
  .mtog.off .sw { opacity:.35; }
  .maphint { font-size:12px; color:#64748b; margin:10px 0; }
  /* Day scrubber + layer chips share one control block above the map */
  .mapcontrols { margin-bottom:10px; }
  .dayscrub { display:flex; flex-wrap:wrap; align-items:center; gap:6px; margin-bottom:8px; }
  .daychip { font-size:12px; font-weight:600; padding:6px 11px; border-radius:999px; border:1px solid #334155; background:#0b1120; color:#94a3b8; cursor:pointer; user-select:none; }
  .daychip.active { color:#0b1120; background:#f59e0b; border-color:#f59e0b; }
  .dayarrow { font-size:15px; font-weight:700; line-height:1; width:28px; height:28px; border-radius:8px; border:1px solid #334155; background:#0f172a; color:#cbd5e1; cursor:pointer; user-select:none; }
  .dayarrow:hover { background:#1e293b; color:#fff; }
  /* Numbered journey pins */
  .leaflet-div-icon.seqwrap { background:transparent; border:none; }
  .seqpin { display:flex; align-items:center; gap:4px; white-space:nowrap; }
  .seqnum { width:24px; height:24px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:12px; font-weight:700; color:#0b1120; box-shadow:0 1px 4px rgba(0,0,0,.55); }
  .seqtime { font-size:11px; font-weight:600; color:#e2e8f0; background:rgba(15,23,42,.92); border:1px solid #1e293b; border-radius:7px; padding:1px 5px; box-shadow:0 1px 3px rgba(0,0,0,.4); }
  .seqpin.soft .seqnum { opacity:.82; box-shadow:0 1px 3px rgba(0,0,0,.4); }
  .seqpin.soft .seqtime { opacity:.78; }
  /* Fullscreen map (CSS overlay — the iOS / no-native fallback) */
  #map.fs { position:fixed; top:0; left:0; right:0; bottom:0; width:100%; height:100% !important; min-height:0; border:none; border-radius:0; z-index:9999; }
  #map:fullscreen, #map:-webkit-full-screen { width:100%; height:100%; border:none; border-radius:0; }
  body.mapfs-lock { overflow:hidden; }
  .leaflet-bar a.mapfsbtn { width:30px; height:30px; line-height:28px; text-align:center; font-size:16px; font-weight:700; color:#e2e8f0; background:#0f172a; border-bottom:none; }
  .leaflet-bar a.mapfsbtn:hover { background:#1e293b; color:#fff; }
  /* Controls while they ride inside the map (fullscreen) */
  .leaflet-control.mapcontrols.inmap { margin:10px; padding:8px 9px; background:rgba(15,23,42,.88); border:1px solid #1e293b; border-radius:12px; box-shadow:0 4px 14px rgba(0,0,0,.45); max-width:calc(100vw - 32px); max-height:calc(100% - 24px); overflow:auto; backdrop-filter:blur(4px); }
  .leaflet-control.mapcontrols.inmap .dayscrub { margin-bottom:7px; }
  .leaflet-control.mapcontrols.inmap .maptoggles { margin-bottom:0; }
  .leaflet-control.mapcontrols.inmap .mtog { background:#0b1120; }
  .booked-line { margin-top:8px; font-size:13px; padding:7px 9px; border-radius:9px; border:1px solid; display:flex; gap:7px; align-items:flex-start; }
  .hop { display:flex; align-items:center; gap:7px; font-size:11px; color:#64748b; padding:2px 0 0 2px; margin-top:4px; }
  .hop a { color:#7dd3fc; text-decoration:none; }
  .hop:before { content:''; width:14px; height:1px; background:#1e293b; }
  .geobtn { display:inline-flex; align-items:center; gap:6px; font-size:12px; font-weight:600; padding:7px 12px; border-radius:9px; border:1px solid #334155; background:#0f172a; color:#cbd5e1; cursor:pointer; }
  .plcrow .artrow-t b { font-weight:700; }

  /* ===== Gap-filler, hearts, warnings, weather ===== */
  .gapchip { display:inline-flex; align-items:center; gap:6px; font-size:11px; font-weight:600; color:#67e8f9; background:rgba(34,211,238,.08); border:1px dashed rgba(34,211,238,.45); border-radius:999px; padding:5px 11px; margin:10px 0 0 2px; cursor:pointer; user-select:none; }
  .gapchip:hover { background:rgba(34,211,238,.16); }
  .hop.warn a { color:#fda4af; }
  .hop .warntxt { color:#fb7185; font-weight:600; }
  .heart { font-size:16px; padding:2px 6px; }
  .gaprow { border:1px solid #1e293b; border-radius:11px; padding:10px 11px; margin-bottom:8px; background:#020617; }
  .gaprow .fitline { font-size:12px; color:#94a3b8; margin-top:5px; }
  .gaprow .spare { color:#6ee7b7; font-weight:600; }
  .modechip { margin-left:8px; font-size:10px; font-weight:600; color:#94a3b8; border:1px solid #334155; border-radius:999px; padding:2px 8px; cursor:pointer; user-select:none; }
  .modechip:hover { color:#e2e8f0; border-color:#475569; }
  .wxrow { display:flex; gap:8px; }
  .wxday { flex:1; text-align:center; background:#1e293b; border-radius:10px; padding:8px 4px; }
  .wxday .t { font-size:12px; color:#e2e8f0; margin-top:2px; }
</style>
</head>
<body>
<div id="app"><div class="loading">Loading your trip…</div></div>
<div id="modalRoot"></div>
<script>
var DATA = {artists:[],timeline:[],places:[],stays:[],todos:[],tickets:[],costs:[],settings:[],routecache:[]};
var TAB="overview", dayFilter="all", placeCat="all", expanded=null, modalState=null;
var USER="";            // signed-in email — powers the ❤ mutual-like voting
var SET={};             // settings key -> value (typical durations, gap buffer, day modes)
var ROUTES={};          // routeKey -> {mins,km} — server cache mirrored locally
var ADE = new Date("2026-10-21T10:00:00");

var DAYS=[["other","Pre / Other"],["wed","Wed Oct 21"],["thu","Thu Oct 22"],["fri","Fri Oct 23"],["sat","Sat Oct 24"],["sun","Sun Oct 25"]];
var DAYIDX={}; DAYS.forEach(function(d,i){DAYIDX[d[0]]=i;}); var DAYLBL={}; DAYS.forEach(function(d){DAYLBL[d[0]]=d[1];});
var PRI={must:["Must-see","rgba(217,70,239,.18)","#f0abfc","rgba(217,70,239,.4)"],interested:["Interested","rgba(99,102,241,.18)","#a5b4fc","rgba(99,102,241,.4)"],maybe:["Maybe","rgba(100,116,139,.25)","#cbd5e1","rgba(100,116,139,.4)"]};
var TIC={have:["Have ticket","rgba(16,185,129,.18)","#6ee7b7","rgba(16,185,129,.4)"],need:["Need ticket","rgba(245,158,11,.18)","#fcd34d","rgba(245,158,11,.4)"],soldout:["Sold out","rgba(244,63,94,.18)","#fda4af","rgba(244,63,94,.4)"],free:["Free entry","rgba(14,165,233,.18)","#7dd3fc","rgba(14,165,233,.4)"]};
var PCAT={eat:["Eat","rgba(249,115,22,.18)","#fdba74","rgba(249,115,22,.4)"],coffee:["Coffee","rgba(217,119,6,.18)","#fcd34d","rgba(217,119,6,.4)"],drinks:["Drinks","rgba(244,63,94,.18)","#fda4af","rgba(244,63,94,.4)"],see:["See","rgba(14,165,233,.18)","#7dd3fc","rgba(14,165,233,.4)"],do:["Activity","rgba(16,185,129,.18)","#6ee7b7","rgba(16,185,129,.4)"],shop:["Shop","rgba(139,92,246,.18)","#c4b5fd","rgba(139,92,246,.4)"],late:["Late night","rgba(56,189,248,.16)","#7dd3fc","rgba(56,189,248,.4)"],other:["Other","rgba(100,116,139,.25)","#cbd5e1","rgba(100,116,139,.4)"]};
var PPRI={want:["Want to go","rgba(217,70,239,.18)","#f0abfc","rgba(217,70,239,.4)"],maybe:["Maybe","rgba(100,116,139,.25)","#cbd5e1","rgba(100,116,139,.4)"],been:["Been","rgba(16,185,129,.18)","#6ee7b7","rgba(16,185,129,.4)"]};
var TCAT={todo:["To do","rgba(100,116,139,.25)","#cbd5e1","rgba(100,116,139,.4)"],book:["To book","rgba(245,158,11,.18)","#fcd34d","rgba(245,158,11,.4)"],decision:["Decision","rgba(139,92,246,.18)","#c4b5fd","rgba(139,92,246,.4)"],idea:["Idea","rgba(14,165,233,.18)","#7dd3fc","rgba(14,165,233,.4)"]};
var TSTAT={purchased:["Purchased","rgba(99,102,241,.18)","#a5b4fc","rgba(99,102,241,.4)"],personalize:["⚠ Personalize","rgba(245,158,11,.18)","#fcd34d","rgba(245,158,11,.4)"],ready:["Ready","rgba(16,185,129,.18)","#6ee7b7","rgba(16,185,129,.4)"],booked:["Booked","rgba(99,102,241,.18)","#a5b4fc","rgba(99,102,241,.4)"],confirmed:["Confirmed","rgba(16,185,129,.18)","#6ee7b7","rgba(16,185,129,.4)"],used:["Used / done","rgba(100,116,139,.25)","#94a3b8","rgba(100,116,139,.4)"]};
var THOLD={both:["Both of us","rgba(217,70,239,.18)","#f0abfc","rgba(217,70,239,.4)"],me:["Me","rgba(14,165,233,.18)","#7dd3fc","rgba(14,165,233,.4)"],partner:["Partner","rgba(244,63,94,.18)","#fda4af","rgba(244,63,94,.4)"]};
// What kind of booking: a ticket vs a reservation. Drives icon, default status and timeline styling.
var BTYPE={ticket:["🎫 Ticket"],reservation:["🍽 Reservation"]};

// ===== Timeline / Calendar config =====
var tlView="timeline";   // "timeline" | "calendar"
var tlLayer="all";       // "all" | "travel" | "sets"
// Maps the Artists "day" code to the real ADE date so sets land on the trip timeline.
var DAYDATE={wed:"2026-10-21",thu:"2026-10-22",fri:"2026-10-23",sat:"2026-10-24",sun:"2026-10-25"};
var DATEDAY={}; Object.keys(DAYDATE).forEach(function(k){ DATEDAY[DAYDATE[k]]=k; });
// kind -> label, icon, and emphasis weight (3 = loud, 1 = quiet)
var KIND={
  flight:{label:"Flight",icon:"\u2708",weight:3},
  concert:{label:"Set",icon:"\uD83C\uDFB5",weight:3},
  lodging:{label:"Stay",icon:"\uD83C\uDFE8",weight:2},
  transit:{label:"Transit",icon:"\uD83D\uDE86",weight:2},
  prep:{label:"Prep",icon:"\u2705",weight:1},
  layover:{label:"Layover",icon:"\u23F1",weight:1},
  ticket:{label:"Ticket",icon:"\uD83C\uDFAB",weight:2},
  reservation:{label:"Reservation",icon:"\uD83C\uDF7D",weight:3},
  general:{label:"Event",icon:"\u2022",weight:2}
};
var KACC={flight:"#38bdf8",concert:"#e879f9",lodging:"#34d399",transit:"#a78bfa",prep:"#fbbf24",layover:"#64748b",ticket:"#fb923c",reservation:"#fb7185",general:"#94a3b8"};
var KSOFT={flight:"rgba(56,189,248,.14)",concert:"rgba(232,121,249,.16)",lodging:"rgba(52,211,153,.12)",transit:"rgba(167,139,250,.12)",prep:"rgba(251,191,36,.12)",layover:"rgba(100,116,139,.12)",ticket:"rgba(251,146,60,.14)",reservation:"rgba(251,113,133,.15)",general:"rgba(148,163,184,.12)"};

// ===== FLAGSHIP — "Last train home" (local mode) =====
// Two layers that cover each other:
//  (a) the Nachtnet corridor table below — the weekday/geography gate: does an NS
//      night train even exist tonight to YOUR home station?
//  (b) live routing (getNightRoutes on the server, cached in RouteCache) — the actual
//      departure/arrival of the connection leaving after each set ends.
// Grounded in NS's current Nachtnet structure — VERIFY against ns.nl before ADE;
// exact times drift year to year, but this structure is stable. Editable data:
// a corridor is {nights:"every"|"weekend", stations:[...]}, matched (accent- and
// punctuation-insensitively) against the free-text home station.
var NACHTNET=[
  // The Randstad U-line — runs EVERY night, roughly hourly after the last regular train.
  {name:"Randstad night line", nights:"every",
   stations:["Rotterdam Centraal","Delft","Den Haag HS","Leiden Centraal","Schiphol Airport","Amsterdam Centraal","Utrecht Centraal"]},
  // Fri & Sat nights only — extensions off the U-line (connecting in Utrecht / Amersfoort).
  {name:"Utrecht \u2013 Amersfoort", nights:"weekend", stations:["Amersfoort Centraal"]},
  {name:"Utrecht \u2013 Arnhem \u2013 Nijmegen", nights:"weekend", stations:["Arnhem Centraal","Nijmegen"]},
  {name:"Randstad \u2013 Groningen / Drenthe", nights:"weekend", stations:["Groningen","Assen"]},
  {name:"Amsterdam \u2013 Amersfoort \u2013 Deventer / Zwolle", nights:"weekend", stations:["Apeldoorn","Deventer","Zwolle"]},
  {name:"Rotterdam \u2013 Dordrecht \u2013 Brabant", nights:"weekend",
   stations:["Dordrecht","Breda","Tilburg","Eindhoven Centraal","'s-Hertogenbosch","Den Bosch"]}
  // Everywhere else (most of Limburg incl. Maastricht, Twente, Friesland, Zeeland):
  // no NS night train — the engine goes straight to first-train / night-bus / stay-over.
];
// ADE 2026 runs Wed 21 – Sun 25 Oct. "Weekend night" for the Nachtnet means the nights
// Fri→Sat and Sat→Sun ONLY. Sunday night (Oct 25) is NOT a weekend night — a Sunday
// set home to Nijmegen is 🔴 even though Saturday was fine. Kept explicit on purpose.
var WEEKEND_NIGHTS={"2026-10-23":1,"2026-10-24":1};
var NS_DISRUPT="https://www.ns.nl/en/travel-information/disruptions-and-engineering-work";
var NS_OVFIETS="https://www.ns.nl/en/door-to-door/ov-fiets";
var URL_9292="https://9292.nl/en";
var GVB_NIGHT="https://www.gvb.nl/en/travel-information/night-buses";

function normNameJs(s){ return String(s||"").toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g,"").replace(/[^a-z0-9]+/g,""); }
function corridorForHome(){
  var hp=homePoint(); if(!hp) return null;
  var n=normNameJs(hp.name); if(!n) return null;
  for(var i=0;i<NACHTNET.length;i++){
    var c=NACHTNET[i];
    for(var j=0;j<c.stations.length;j++){
      var st=normNameJs(c.stations[j]);
      // fuzzy either-way containment, but short names must match exactly (avoids "Ede"-style false hits)
      if(n.length>=4&&st.length>=4 ? (st.indexOf(n)!==-1||n.indexOf(st)!==-1) : st===n) return c;
    }
  }
  return null;
}
function isWeekendNight(date){ return !!WEEKEND_NIGHTS[date]; }
function nightGate(date){
  var c=corridorForHome();
  if(!c) return {train:false,corridor:null};
  return {train:c.nights==="every"||isWeekendNight(date),corridor:c};
}

// Live routed departures, batched. LT mirrors the server's "NT|" RouteCache rows.
var LT={}, LTQ={}, LTFAIL={}, ltTimer=null;
function queueNight(req){ if(LT[req.key]||LTQ[req.key]||LTFAIL[req.key]) return; LTQ[req.key]=req; if(!ltTimer) ltTimer=setTimeout(flushNight,60); }
function flushNight(){
  ltTimer=null;
  var reqs=Object.keys(LTQ).slice(0,6).map(function(k){return LTQ[k];});
  if(!reqs.length) return;
  google.script.run.withSuccessHandler(function(res){
    reqs.forEach(function(r){ if(res&&res[r.key]) LT[r.key]=res[r.key]; else LTFAIL[r.key]=1; delete LTQ[r.key]; });
    render();
    if(Object.keys(LTQ).length&&!ltTimer) ltTimer=setTimeout(flushNight,300);
  }).withFailureHandler(function(){
    reqs.forEach(function(r){ LTFAIL[r.key]=1; delete LTQ[r.key]; });
    render();
  }).getNightRoutes(reqs);
}
function epochMinClock(m){ var d=new Date(m*60000); return fmtT(d.getHours()+":"+(d.getMinutes()<10?"0":"")+d.getMinutes()); }

// The engine. Returns {state: green|amber|red|table|pending|unknown|setup, msg, short, dep, arr, fallback}
// per set with a known end time. Amber reuses the badHops idea — the same "can this
// transition be made" comparison, aimed at the journey home instead of the next stop.
function lastTrainCore(date,startT,endT,lat,lng){
  if(!isLocal()||!date) return null;
  var hp=homePoint(); if(!hp) return {state:"setup",msg:"set your home station in \u2699 Mode & home base",short:"set home station"};
  var e=to24(endT); if(e==null) return {state:"unknown",msg:"add an end time to check the way home",short:"add an end time"};
  var la=num(lat), ln=num(lng);
  if(la==null||ln==null) return {state:"unknown",msg:"locate the venue pin (Map tab) to check the way home",short:"locate venue pin"};
  var s=to24(startT);
  var endAbs=e; if(s!=null&&e<=s) endAbs+=1440; else if(s==null&&e<300) endAbs+=1440;   // after-midnight ends belong to the same night
  if(haversineKm(la,ln,hp.lat,hp.lng)<2.5) return {state:"green",msg:"you're basically home \u2014 walk or bike it",short:"walk home"};
  var gate=nightGate(date);
  if(!gate.train){
    var why=gate.corridor?("Nachtnet to "+hp.name+" only runs Fri & Sat nights"):("no NS night train serves "+hp.name);
    return {state:"red",msg:why,short:gate.corridor?"no night train tonight":"no night train",fallback:ltFallback(la,ln)};
  }
  var departMs=new Date(date+"T00:00:00").getTime()+endAbs*60000;
  if(isNaN(departMs)) return {state:"unknown",msg:"date unreadable",short:"?"};
  var key="NT|"+routeKeyJs([la,ln],[hp.lat,hp.lng],"transit")+"|"+Math.round(departMs/60000);
  var r=LT[key];
  if(!r){
    if(LTFAIL[key]) return {state:"table",msg:"Nachtnet runs to "+hp.name+" tonight (~hourly) \u2014 check 9292 for the exact departure",short:"night trains run \u2014 check 9292"};
    queueNight({key:key,o:[la,ln],d:[hp.lat,hp.lng],departMs:departMs});
    return {state:"pending",msg:"checking the last train\u2026",short:"checking\u2026"};
  }
  var slack=r.depMin-Math.round(departMs/60000);
  var dep=epochMinClock(r.depMin), arr=epochMinClock(r.arrMin);
  var margin=Math.max(gapBuffer(),10);
  if(slack>100) return {state:"red",dep:dep,arr:arr,msg:"last train's gone \u2014 first connection "+dep+" ("+fmtDur(slack)+" wait)",short:"first train "+dep,fallback:ltFallback(la,ln)};
  if(slack<margin) return {state:"amber",dep:dep,arr:arr,msg:"makeable but TIGHT \u2014 aim for the "+dep+" (only "+fmtDur(Math.max(slack,0))+" after the set ends) \u00B7 home "+arr,short:"tight \u2014 "+dep};
  return {state:"green",dep:dep,arr:arr,msg:"aim for the "+dep+" \u00B7 home "+arr,short:"home \u00B7 "+dep};
}
function ltFallback(lat,lng){
  var nearAms=haversineKm(lat,lng,AMS[0],AMS[1])<18;
  return (nearAms?"night bus from Centraal via Rembrandtplein / Leidseplein, ~\u20AC6, GVB app \u00B7 ":"")+"wait for the first train, or plan to crash over";
}
function ltEmoji(st){ return st==="green"?"\uD83D\uDFE2":st==="amber"?"\uD83D\uDFE0":st==="red"?"\uD83D\uDD34":st==="pending"?"\u23F3":st==="table"?"\uD83D\uDFE2":"\uD83C\uDF19"; }
function ltColor(st){ return st==="green"||st==="table"?"#6ee7b7":st==="amber"?"#fcd34d":st==="red"?"#fda4af":"#94a3b8"; }
function ltBadgeHtml(lt,withLinks){
  if(!lt) return "";
  var h='<div class="tiny" style="margin-top:6px;color:'+ltColor(lt.state)+'">'+ltEmoji(lt.state)+' <b>Home:</b> '+esc(lt.msg)+(lt.fallback?' \u2014 '+esc(lt.fallback):'')+'</div>';
  if(withLinks&&(lt.state==="red"||lt.state==="table"||lt.state==="amber")){
    h+='<div class="tiny" style="margin-top:3px">'
      +'<a href="'+URL_9292+'" target="_blank" rel="noopener" style="color:#7dd3fc">9292 planner \u2197</a>'
      +(lt.state==="red"?' \u00B7 <a href="'+GVB_NIGHT+'" target="_blank" rel="noopener" style="color:#7dd3fc">GVB night buses \u2197</a>':'')
      +' \u00B7 <a href="'+NS_DISRUPT+'" target="_blank" rel="noopener" style="color:#7dd3fc">NS disruptions \u2197</a></div>';
  }
  return h;
}

var FIELDS = {
  Artists:[["name","Artist / set name","text",1],["genre","Genre","text"],["day","Day","select",0,DAYS],["venue","Venue","text"],["startTime","Start","time"],["endTime","End","time"],["priority","Priority","select",0,Object.keys(PRI).map(function(k){return [k,PRI[k][0]];})],["ticketStatus","Ticket","select",0,Object.keys(TIC).map(function(k){return [k,TIC[k][0]];})],["notes","Notes","textarea"]],
  Timeline:[["title","What's happening","text",1],["kind","Type (controls how it's highlighted)","select",0,[["","Auto-detect"],["flight","✈ Flight"],["layover","⏱ Layover"],["concert","🎵 Set / Concert"],["lodging","🏨 Stay / Hotel"],["transit","🚆 Transit / transfer"],["prep","✅ Prep / errand"],["general","• Other"]]],["date","Date","date"],["time","Time","time"],["flightNumber","Flight number — adds a status link (e.g. KL602)","text"],["address","Address — powers the 🧭 Directions link & map pin (e.g. Schiphol Airport, or Stationsplein 1). Leave blank and a clearly-named stop auto-locates from its title.","text"],["details","Details","textarea"],["lat","Latitude (optional — filled by Locate pins)","text"],["lng","Longitude (optional — filled by Locate pins)","text"]],
  Places:[["name","Name","text",1],["category","Category","select",0,Object.keys(PCAT).map(function(k){return [k,PCAT[k][0]];})],["priority","Priority","select",0,Object.keys(PPRI).map(function(k){return [k,PPRI[k][0]];})],["area","Area / neighborhood","text"],["address","Address","text"],["durationMin","Typical time here (minutes) — blank uses the category default","number"],["notes","Notes","textarea"],["mapsUrl","Google Maps link (optional — used by ↗ Open in Maps)","text"],["lat","Latitude (optional — filled by Locate pins)","text"],["lng","Longitude (optional — filled by Locate pins)","text"]],
  Stays:[["name","Name","text",1],["address","Address","text"],["checkInDate","Check-in date","date"],["checkInTime","Check-in time","time"],["checkOutDate","Check-out date","date"],["checkOutTime","Check-out time","time"],["confirmation","Confirmation #","text"],["notes","Notes","textarea"],["lat","Latitude (optional — filled by Locate pins)","text"],["lng","Longitude (optional — filled by Locate pins)","text"]],
  Todos:[["text","Item","textarea",1],["category","Type","select",0,Object.keys(TCAT).map(function(k){return [k,TCAT[k][0]];})]],
  Costs:[["what","What (NS fare, parking / P+R, OV-fiets, 4am fries\u2026)","text",1],["amount","Amount (\u20AC)","number"],["date","Night / date","date"],["paidBy","Paid by","text"],["splitWith","Split with (names, comma-separated \u2014 even shares with the payer)","text"],["notes","Notes","textarea"]],
  Tickets:[["type","Booking type","select",0,Object.keys(BTYPE).map(function(k){return [k,BTYPE[k][0]];})],["event","What's it for (e.g. ADE Pass · Tale Of Us @ Warehouse — or Dinner @ Moeders)","text",1],["status","Status","select",0,Object.keys(TSTAT).map(function(k){return [k,TSTAT[k][0]];})],["artistIds","Which sets does this cover? (flips them to Have ticket)","artists"],["placeIds","Which place is this for? (museum, restaurant, activity…)","places"],["orderUrl","Order / reservation link","text"],["orderNumber","Order / confirmation number","text"],["vendor","Seller / platform / where you booked","text"],["holder","Who's it for","select",0,Object.keys(THOLD).map(function(k){return [k,THOLD[k][0]];})],["quantity","How many (tickets / party size)","number"],["price","Price (optional)","text"],["availableDate","Available / unlocks on (tickets only)","date"],["eventDate","Date","date"],["eventTime","Time","time"],["venue","Venue / address","text"],["notes","Notes — paste any status text here","textarea"]]
};
var DEFAULTS = {Artists:{day:"fri",priority:"interested",ticketStatus:"need"},Timeline:{kind:""},Places:{category:"eat",priority:"want"},Todos:{category:"todo"},Tickets:{type:"ticket",status:"purchased",holder:"both",quantity:"2"},Costs:{}};

function esc(s){ return String(s==null?"":s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"); }
function linkify(s){
  return esc(s).replace(/(https?:\/\/[^\s<]+|www\.[^\s<]+)/gi, function(m){
    var tail=""; var t=m.match(/[.,;:!?)\]]+$/); if(t){ tail=t[0]; m=m.slice(0, m.length-tail.length); }
    var href=/^https?:\/\//i.test(m)?m:("https://"+m);
    return '<a href="'+href+'" target="_blank" rel="noopener" style="color:#7dd3fc;text-decoration:underline">'+m+'</a>'+tail;
  });
}
function isTrue(v){ return v===true||v==="true"||v==="TRUE"||v===1||v==="1"; }
function badge(arr){ return '<span class="badge" style="background:'+arr[1]+';color:'+arr[2]+';border-color:'+arr[3]+'">'+arr[0]+'</span>'; }
function tmin(t){ if(!t) return null; var p=String(t).split(":"); return (+p[0])*60+(+p[1]); }
function fmtT(t){
  if(t==null||t==="") return "";
  var s=String(t).trim();
  var ap=null, mm=s.match(/(a\.?m\.?|p\.?m\.?)/i);
  if(mm){ ap=/p/i.test(mm[1])?"PM":"AM"; s=s.replace(mm[0],"").trim(); }
  var parts=s.split(":");
  if(parts.length<2) return String(t).trim();           // unparseable — show as-is, never NaN
  var h=parseInt(parts[0],10);
  var m=(parts[1].replace(/[^\d]/g,"")||"00").slice(0,2);
  if(m.length<2) m="0"+m;
  if(isNaN(h)) return String(t).trim();
  if(ap){ if(ap==="PM"&&h<12) h+=12; if(ap==="AM"&&h===12) h=0; }
  if(h<0||h>23) return String(t).trim();
  if(use24()) return (h<10?"0"+h:h)+":"+m;
  return (((h+11)%12)+1)+":"+m+" "+(h>=12?"PM":"AM");
}
// Localization: local mode defaults to the 24-hour clock and Dutch DD-MM style dates
// (Europeans expect both). Overridable via the clock.24 setting ("1"/"0") from ⚙.
function use24(){ if(SET["clock.24"]==="1") return true; if(SET["clock.24"]==="0") return false; return isLocal(); }
function loc_(){ return isLocal()?"nl-NL":undefined; }
function fmtD(d){ if(!d) return ""; var dt=new Date(d+"T00:00:00"); if(isNaN(dt)) return d; return dt.toLocaleDateString(loc_(),isLocal()?{weekday:"short",day:"numeric",month:"short"}:{weekday:"short",month:"short",day:"numeric"}); }
function mapsLink(p){ if(p&&p.mapsUrl&&String(p.mapsUrl).trim()) return String(p.mapsUrl).trim(); var q=encodeURIComponent([p.name,p.address||p.area,"Amsterdam"].filter(Boolean).join(", ")); return "https://www.google.com/maps/search/?api=1&query="+q; }
function mapsEmbed(p){ var q=encodeURIComponent([p.name,p.address||p.area,"Amsterdam"].filter(Boolean).join(", ")); return "https://maps.google.com/maps?q="+q+"&z=14&output=embed"; }
function flightStatusUrl(num){ var n=String(num||"").replace(/\s+/g,"").toLowerCase(); return "https://www.flightradar24.com/data/flights/"+encodeURIComponent(n); }

// ---- timeline/calendar helpers ----
// Parse a time string to minutes-from-midnight (handles "23:55", "9:00 PM", "1:30"). null if unusable.
function to24(t){
  if(t==null||t==="") return null;
  var s=String(t).trim(), ap=null, mm=s.match(/(a\.?m\.?|p\.?m\.?)/i);
  if(mm){ ap=/p/i.test(mm[1])?"PM":"AM"; s=s.replace(mm[0],"").trim(); }
  var p=s.split(":"); if(p.length<2) return null;
  var h=parseInt(p[0],10), m=parseInt((p[1].replace(/[^\d]/g,"")||"0"),10);
  if(isNaN(h)||isNaN(m)) return null;
  if(ap){ if(ap==="PM"&&h<12) h+=12; if(ap==="AM"&&h===12) h=0; }
  if(h<0||h>23||m<0||m>59) return null;
  return h*60+m;
}
function evStartMs(e){ if(!e.date) return null; var mins=to24(e.time); if(mins==null) mins=0; var d=new Date(e.date+"T00:00:00"); return isNaN(d)?null:d.getTime()+mins*60000; }
function ymd(dt){ var m=dt.getMonth()+1,d=dt.getDate(); return dt.getFullYear()+"-"+(m<10?"0"+m:m)+"-"+(d<10?"0"+d:d); }
function dParts(d){ var dt=new Date(d+"T12:00:00"); return isNaN(dt)?null:dt; }
function dowName(d){ var dt=dParts(d); return dt?dt.toLocaleDateString(loc_(),{weekday:"short"}):d; }
function dmName(d){ var dt=dParts(d); return dt?(isLocal()?dt.toLocaleDateString("nl-NL",{day:"numeric",month:"short"}):dt.toLocaleDateString(undefined,{month:"short",day:"numeric"})):d; }
function daysBetween(a,b){ var out=[],cur=dParts(a),end=dParts(b),g=0; if(!cur||!end) return [a]; while(cur<=end&&g<400){ out.push(ymd(cur)); cur=new Date(cur.getTime()+864e5); g++; } return out; }
function fmtGap(mins){ if(mins<60) return "+"+mins+"m"; if(mins<1440){ var h=Math.floor(mins/60),m=mins%60; return "+"+h+"h"+(m?(" "+m+"m"):""); } var d=Math.floor(mins/1440),hh=Math.round(mins%1440/60); return "+"+d+"d"+(hh?(" "+hh+"h"):""); }
// proportional (compressed) gap in px from a minute delta
function gapPx(mins){ if(mins<=0) return 8; return Math.max(8,Math.min(120,Math.round(12+30*Math.log10(1+mins/30)))); }
// loudness of an event: booked things shout, un-ticketed sets are quieted
function evWeight(e){
  if(e.kind==="concert") return e.ticket?3:1;            // have a ticket = loud · no ticket yet = muted
  if(e.booked) return 3;                                  // confirmed reservation / ticketed entry
  return (KIND[e.kind]||KIND.general).weight;
}
// guess a kind for timeline rows that don't have one set
function inferKind(t){
  if(t.flightNumber&&String(t.flightNumber).trim()) return "flight";
  var s=(String(t.title||"")+" "+String(t.details||"")).toLowerCase();
  if(/layover|connect/.test(s)) return "layover";
  if(/\bflight\b|departs?\b|outbound|boarding/.test(s)) return "flight";
  if(/check-?in|check-?out|hotel|lodg/.test(s)) return "lodging";
  if(/dog|sitter|pack|bag|errand/.test(s)) return "prep";
  if(/airport|schiphol|train|taxi|ride|drive|\bcar\b|transfer|leave for|arrive home|pickup|customs|passport/.test(s)) return "transit";
  if(/concert|\bset\b|\bshow\b|\bgig\b|venue|stage/.test(s)) return "concert";
  return "general";
}
// merge logistics (Timeline) + sets (Artists) into one event list, honoring the layer filter
function tripEvents(){
  var evs=[];
  if(tlLayer!=="sets") DATA.timeline.forEach(function(t){
    var k=(t.kind&&String(t.kind).trim())?String(t.kind).trim():inferKind(t); if(!KIND[k]) k="general";
    evs.push({src:"Timeline",id:t.id,title:t.title,date:t.date||"",time:t.time||"",details:t.details,done:isTrue(t.done),flightNumber:t.flightNumber,kind:k,address:t.address,lat:t.lat,lng:t.lng});
  });
  if(tlLayer!=="travel") DATA.artists.forEach(function(a){
    evs.push({src:"Artists",id:a.id,title:a.name,date:DAYDATE[a.day]||"",time:a.startTime||"",endTime:a.endTime,venue:a.venue,priority:a.priority,ticketStatus:a.ticketStatus,details:a.notes,kind:"concert",ticket:coveringTicket(a.id),lat:a.lat,lng:a.lng});
  });
  if(tlLayer!=="travel") DATA.tickets.forEach(function(t){
    // (a) actionable milestones — needs personalizing, or unlocks in the future
    if(t.availableDate){
      var av=ticketAvail(t);
      if(t.status==="personalize" || (av&&av.future)){
        var title=(t.status==="personalize"?"Personalize & download — ":"Tickets unlock — ")+(t.event||"order");
        evs.push({src:"Tickets",id:t.id,title:title,date:t.availableDate,time:"09:00",kind:"ticket",ticket:t,details:t.notes});
      }
    }
    // (b) the booking itself when it's a reservation / place-linked entry with a date.
    //     Concert-ticket bookings are skipped here — their sets already appear via Artists.
    if(t.eventDate && splitIds(t.artistIds).length===0){
      var isRes=bkType(t)==="reservation";
      var pl=splitIds(t.placeIds).map(function(id){return DATA.places.filter(function(x){return x.id===id;})[0];}).filter(Boolean)[0]||null;
      evs.push({src:"Tickets",id:t.id,title:t.event||(pl?pl.name:(isRes?"Reservation":"Booking")),date:t.eventDate,time:t.eventTime||"",venue:t.venue||(pl?(pl.address||pl.area||pl.name):""),kind:isRes?"reservation":"ticket",ticket:t,booked:true,details:t.notes,lat:pl?pl.lat:null,lng:pl?pl.lng:null,place:pl});
    }
  });
  return evs;
}
// greedy lane assignment so overlapping calendar blocks sit side by side
function assignLanes(items){
  var sorted=items.slice().sort(function(a,b){return a.start-b.start||a.end-b.end;}), lanes=[];
  sorted.forEach(function(it){ var placed=false; for(var i=0;i<lanes.length;i++){ if(lanes[i]<=it.start){ it.lane=i; lanes[i]=it.end; placed=true; break; } } if(!placed){ it.lane=lanes.length; lanes.push(it.end); } });
  var n=lanes.length||1; sorted.forEach(function(it){ it.lanes=n; }); return sorted;
}
// calendar vertical axis: 06:00 -> 05:00 next day
var AX_START=360, AX_END=1740, AX_PXH=30, AX_H=(AX_END-AX_START)/60*AX_PXH;
function slotMin(e){ var m=to24(e.time); if(m==null) return null; if(m<300) m+=1440; return m; } // after-midnight => late night
function topPx(m){ return (m-AX_START)/(AX_END-AX_START)*AX_H; }

function setStatus(s){ var e=document.getElementById("status"); if(e) e.textContent=s; if(s==="✓ Saved") setTimeout(function(){ if(e&&e.textContent==="✓ Saved") e.textContent=""; },1500); }
function onErr(e){ setStatus("⚠ "+(e&&e.message?e.message:"error")); }

function load(){ google.script.run.withSuccessHandler(function(d){ DATA={artists:d.artists||[],timeline:d.timeline||[],places:d.places||[],stays:d.stays||[],todos:d.todos||[],tickets:d.tickets||[],costs:d.costs||[],settings:d.settings||[],routecache:d.routecache||[]}; USER=d._user||""; SET={}; DATA.settings.forEach(function(s){ SET[String(s.key)]=String(s.value); }); ROUTES={}; DATA.routecache.forEach(function(r){ if(!r.key) return; var k=String(r.key); if(k.slice(0,3)==="NT|") LT[k]={depMin:Number(r.mins),arrMin:Number(r.km)}; else ROUTES[k]={mins:Number(r.mins),km:Number(r.km)}; }); render(); if(!SET.mode) openOnboard(1); }).withFailureHandler(onErr).getAllData(); }

// ===== Mode & anchor (visitor = hotel/stay · local = home station) =====
function isLocal(){ return SET.mode==="local"; }
function homePoint(){ var la=num(SET["home.lat"]), ln=num(SET["home.lng"]); if(la==null||ln==null) return null; return {name:SET["home.name"]||"home", lat:la, lng:ln, home:true}; }
// Every "distance from / directions from" routes through this ONE function —
// the generalized refStay(): home station in local mode, first located stay otherwise.
function anchorPoint(){ return isLocal() ? (homePoint()||refStay()) : refStay(); }

function save(tab, rec){ setStatus("Saving…"); google.script.run.withSuccessHandler(function(saved){ var k=tab.toLowerCase(); var i=DATA[k].map(function(x){return x.id;}).indexOf(saved.id); if(i<0) DATA[k].push(saved); else DATA[k][i]=saved; setStatus("✓ Saved"); render(); }).withFailureHandler(onErr).saveRecord(tab, rec); }
function patch(tab, id, ch){ var k=tab.toLowerCase(); var rec=DATA[k].filter(function(x){return x.id===id;})[0]; if(!rec) return; for(var p in ch) rec[p]=ch[p]; save(tab, rec); }
function del(tab, id){ if(!confirm("Delete this?")) return; var delTk=tab==="Tickets"?(DATA.tickets.filter(function(x){return x.id===id;})[0]||null):null; setStatus("Saving…"); google.script.run.withSuccessHandler(function(){ var k=tab.toLowerCase(); DATA[k]=DATA[k].filter(function(x){return x.id!==id;}); if(delTk) splitIds(delTk.artistIds).forEach(function(aid){ var coveredElse=DATA.tickets.some(function(t){return splitIds(t.artistIds).indexOf(aid)!==-1;}); if(!coveredElse){ var a=DATA.artists.filter(function(x){return x.id===aid;})[0]; if(a&&a.ticketStatus==="have") save("Artists", Object.assign({}, a, {ticketStatus:"need"})); } }); setStatus("✓ Saved"); render(); }).withFailureHandler(onErr).deleteRecord(tab, id); }

function conflicts(){ var ids={}, byDay={}; DATA.artists.forEach(function(a){ if(a.priority==="maybe") return; var s=tmin(a.startTime); if(s==null) return; var e=tmin(a.endTime); if(e==null) e=s+60; if(e<=s) e+=1440; (byDay[a.day]=byDay[a.day]||[]).push([a.id,s,e]); }); for(var d in byDay){ var arr=byDay[d]; for(var i=0;i<arr.length;i++) for(var j=i+1;j<arr.length;j++) if(arr[i][1]<arr[j][2]&&arr[j][1]<arr[i][2]){ ids[arr[i][0]]=1; ids[arr[j][0]]=1; } } return ids; }

function render(){
  var now=new Date(), diff=ADE-now;
  var cd = (!isLocal()&&diff>0) ? {d:Math.floor(diff/864e5),h:Math.floor(diff%864e5/36e5),m:Math.floor(diff%36e5/6e4)} : null;
  var tabs=[["overview","Overview"],["artists","Artists"],["bookings","Bookings"],["timeline","Timeline"],["map","Map"],["places","Places"],["stays","Stays"],["todos","To-Dos"]];
  if(isLocal()) tabs.push(["costs","Costs"]);
  var h;
  if(isLocal()){
    // Local edition: no flight countdown — the headline is your NEXT SET, not days-to-departure.
    var right='', nb=nextSetCountdown();
    if(nb){ var mm=Math.max(0,Math.round((nb.ms-now.getTime())/6e4)); var big=mm>=1440?(Math.floor(mm/1440)+'d'):(mm>=60?(Math.floor(mm/60)+'h'):(mm+'m'));
      right='<div class="cd"><b>'+big+'</b><span>to your next set</span></div>'; }
    else if(diff>0) right='<div class="cd"><b>'+Math.floor(diff/864e5)+'</b><span>days to ADE</span></div>';
    var hp=homePoint();
    h='<div class="header"><div class="htop"><div>'
      +'<div class="kick">Amsterdam Dance Event · 30 Years</div><h1>ADE 2026 — Local Edition</h1><div class="sub">21–25 okt · in &amp; out from '+esc(hp?hp.name:"home")+'</div></div>'
      +right+'</div></div>';
  } else {
    h='<div class="header"><div class="htop"><div>'
      +'<div class="kick">Amsterdam Dance Event · 30 Years</div><h1>Our ADE 2026 Trip</h1><div class="sub">Oct 21–25 · Amsterdam</div></div>'
      +(cd?'<div class="cd"><b>'+cd.d+'</b><span>days to go</span></div>':'')+'</div></div>';
  }
  h+='<div class="nav">'+tabs.map(function(t){return '<button class="'+(TAB===t[0]?"active":"")+'" onclick="go(\''+t[0]+'\')">'+t[1]+'</button>';}).join("")+'</div>';
  h+='<div class="wrap">'+panel(cd)+'</div>';
  if(TAB!=="overview"&&TAB!=="map") h+='<button class="fab" onclick="openModal(\''+capTab()+'\')">+</button>';
  h+='<div class="status" id="status"></div>';
  document.getElementById("app").innerHTML=h;
  if(TAB==="map") setTimeout(initMap,0);
}
function go(t){ if(TAB==="map"&&t!=="map"&&MAP){ try{MAP.remove();}catch(e){} MAP=null; } TAB=t; expanded=null; render(); }
// "bookings" is the UI name; its data + sheet are still "Tickets" under the hood.
function capTab(){ return {artists:"Artists",timeline:"Timeline",places:"Places",stays:"Stays",todos:"Todos",bookings:"Tickets",costs:"Costs"}[TAB]; }

function panel(cd){
  if(TAB==="overview") return ovPanel(cd);
  if(TAB==="artists") return artistsPanel();
  if(TAB==="timeline") return timelinePanel();
  if(TAB==="map") return mapPanel();
  if(TAB==="places") return placesPanel();
  if(TAB==="stays") return staysPanel();
  if(TAB==="todos") return todosPanel();
  if(TAB==="bookings") return ticketsPanel();
  if(TAB==="costs") return costsPanel();
  return "";
}

function ovPanel(cd){
  var must=DATA.artists.filter(function(a){return a.priority==="must";}).length;
  var need=DATA.artists.filter(function(a){return a.ticketStatus==="need"&&!coveringTicket(a.id);}).length;
  var pend=DATA.timeline.filter(function(t){return !isTrue(t.done);});
  pend.sort(function(a,b){ return ((a.date||"9999")+(a.time||"99")) < ((b.date||"9999")+(b.time||"99")) ? -1:1; });
  var nx=pend[0];
  var cflag=Object.keys(conflicts()).length;
  var personalize=DATA.tickets.filter(function(t){return t.status==="personalize";}).length;
  var h="";
  loadWeather(); loadDigest();
  if(cd) h+='<div class="grid" style="grid-template-columns:1fr 1fr 1fr">'+[["Days",cd.d],["Hours",cd.h],["Mins",cd.m]].map(function(x){return '<div class="card" style="text-align:center"><div style="font-size:24px;font-weight:700;color:#f0abfc">'+x[1]+'</div><div class="tiny">'+x[0]+'</div></div>';}).join("")+'</div>';
  if(isLocal()){ h+=nightsCard(); h+=nsDepCard(); }
  if(WEATHER&&WEATHER.dates&&WEATHER.dates.length){
    h+='<div class="card"><div class="tiny" style="text-transform:uppercase;letter-spacing:.08em;margin-bottom:8px">Amsterdam weather</div><div class="wxrow">';
    for(var wi=0; wi<Math.min(3,WEATHER.dates.length); wi++){
      h+='<div class="wxday"><div class="tiny">'+esc(dowName(WEATHER.dates[wi]))+'</div><div style="font-size:20px;margin-top:2px">'+wxIcon(WEATHER.code[wi])+'</div><div class="t">'+Math.round(WEATHER.hi[wi])+'° / '+Math.round(WEATHER.lo[wi])+'°</div><div class="tiny">💧 '+WEATHER.rain[wi]+'%</div></div>';
    }
    h+='</div></div>';
  }
  var nn=nowNext();
  if(nn.cur||(nn.nxt&&(evStartMs(nn.nxt)-Date.now())<1296e5)){
    h+='<div class="card" style="border-color:rgba(34,211,238,.4)"><div class="tiny" style="text-transform:uppercase;letter-spacing:.08em;margin-bottom:6px;color:#67e8f9">▶ Now / up next</div>';
    if(nn.cur) h+='<div class="name">▶ '+esc(nn.cur.title)+'</div><div class="tiny">since '+esc(fmtT(nn.cur.time))+(nn.cur.venue?' · '+esc(nn.cur.venue):'')+'</div>';
    if(nn.nxt){
      var mm=Math.max(0,Math.round((evStartMs(nn.nxt)-Date.now())/6e4));
      h+='<div class="name" style="margin-top:'+(nn.cur?'8':'0')+'px">'+(nn.cur?'Then: ':'Next: ')+esc(nn.nxt.title)+'</div><div style="color:#67e8f9;font-size:13px">in '+fmtDur(mm)+' · '+esc(fmtT(nn.nxt.time))+(nn.nxt.venue?' · '+esc(nn.nxt.venue):'')+'</div>';
      var from=(nn.cur&&hasCoord(nn.cur))?nn.cur:anchorPoint();
      if(from&&hasCoord(from)&&hasCoord(nn.nxt)) h+='<div><a class="linkbtn" href="'+dirUrl(num(from.lat),num(from.lng),num(nn.nxt.lat),num(nn.nxt.lng),"transit")+'" target="_blank" rel="noopener">🧭 Directions to next</a></div>';
    }
    h+='</div>';
  }
  h+='<div class="card"><div class="tiny" style="text-transform:uppercase;letter-spacing:.08em;margin-bottom:6px">What\'s next</div>'+(nx?'<div class="name">'+esc(nx.title)+'</div><div style="color:#f0abfc;font-size:13px">'+esc(fmtD(nx.date))+(nx.time?" · "+esc(fmtT(nx.time)):"")+'</div>'+(nx.details?'<div class="muted" style="margin-top:4px">'+esc(nx.details)+'</div>':''):'<div class="muted">Add timeline items to see what\'s coming up.</div>')+'</div>';
  h+='<div class="grid">';
  h+=ovStat("artists",DATA.artists.length,must+" must-see");
  h+=ovStat("artists",need,"tickets needed");
  h+=ovStat("places",DATA.places.length,"places pinned");
  h+=ovStat("stays",DATA.stays.length,"accommodations");
  h+=ovStat("todos",DATA.todos.filter(function(t){return !isTrue(t.done);}).length,"open to-dos");
  h+=ovStat("timeline",pend.length,"timeline steps left");
  h+=ovStat("bookings",DATA.tickets.length,DATA.tickets.length===1?"booking":"bookings");
  h+='</div>';
  if(personalize) h+='<div class="card" style="background:rgba(245,158,11,.1);border-color:rgba(245,158,11,.4);color:#fde68a;cursor:pointer" onclick="go(\'bookings\')">⚠ '+personalize+' booking'+(personalize>1?"s":"")+' still need'+(personalize>1?"":"s")+' personalizing — tap to open.</div>';
  if(cflag) h+='<div class="card" style="background:rgba(244,63,94,.1);border-color:rgba(244,63,94,.4);color:#fecdd3">⚠ You have overlapping sets — check the Artists tab.</div>';
  var bh=badHops();
  if(bh) h+='<div class="card" style="background:rgba(244,63,94,.1);border-color:rgba(244,63,94,.4);color:#fecdd3;cursor:pointer" onclick="go(\'timeline\')">⚠ '+bh+' transition'+(bh>1?'s look':' looks')+' too tight to make — check the hops on the Timeline.</div>';
  h+='<div class="card" style="cursor:pointer" onclick="toggleDigest()"><div class="name" style="font-size:14px">📧 Morning digest — '+(DIGEST==null?'…':(DIGEST?'<span style="color:#6ee7b7">on</span>':'<span style="color:#94a3b8">off</span>'))+'</div><div class="tiny" style="margin-top:3px">Emails you (just you) the day\'s schedule, directions and weather at 8:00 on days with plans. Tap to turn '+(DIGEST?'off':'on')+' — you each control your own.</div></div>';
  h+='<div class="card" style="cursor:pointer" onclick="openLocalSettings()"><div class="name" style="font-size:14px">⚙ Mode &amp; home base — '+(isLocal()?'🇳🇱 NL-based':'✈ visiting')+'</div><div class="tiny" style="margin-top:3px">'+(isLocal()?('Home station: '+esc(SET["home.name"]||"not set")+' · 24h clock · optional NS live-departures key'):'Switch to the NL-local edition anytime — last-train planner, per-night template, home-station anchor. Your data stays.')+'</div></div>';
  return h;
}
function ovStat(tab,n,lbl){ return '<div class="stat" onclick="go(\''+tab+'\')"><div class="big">'+n+'</div><div class="tiny">'+lbl+'</div></div>'; }

// ===== Local edition: nights, coordination, settings, costs =====

// countdown target: the next upcoming set (skipping "maybe" sets on nights you're not going)
function nextSetCountdown(){
  var now=Date.now(), best=null;
  DATA.artists.forEach(function(a){
    var date=DAYDATE[a.day]; if(!date) return;
    var s=to24(a.startTime); if(s==null) return;
    var ms=new Date(date+"T00:00:00").getTime()+s*60000;
    if(isNaN(ms)||ms<=now) return;
    if(a.priority==="maybe"&&!iGo(date)) return;
    if(!best||ms<best.ms) best={ms:ms,a:a};
  });
  return best;
}

// per-night attendance — stored as a comma list of emails in Settings (night.<date>),
// so friends sharing the app see who's in which night. The friend-group version of hearts.
function nightGoers(date){ return splitIds(SET["night."+date]); }
function iGo(date){ return !!USER&&nightGoers(date).indexOf(USER)!==-1; }
function toggleNight(date){
  if(!USER){ alert("Sign-in email unavailable — can't record who's going."); return; }
  var g=nightGoers(date), i=g.indexOf(USER);
  if(i===-1) g.push(USER); else g.splice(i,1);
  var v=g.join(","); SET["night."+date]=v;
  setStatus("Saving…");
  google.script.run.withSuccessHandler(function(){ setStatus("✓ Saved"); }).withFailureHandler(onErr).setSetting("night."+date,v);
  render();
}
// the set that decides "can I get home": the latest end among that day's timed sets
function nightLastSet(day){
  var last=null, lastEnd=-1;
  DATA.artists.forEach(function(a){
    if(a.day!==day) return;
    var s=to24(a.startTime), e=to24(a.endTime); if(e==null) return;
    var ea=e; if(s!=null&&e<=s) ea+=1440;
    if(ea>lastEnd){ lastEnd=ea; last=a; }
  });
  return last;
}
function nightsCard(){
  var hp=homePoint();
  var h='<div class="card"><div class="tiny" style="text-transform:uppercase;letter-spacing:.08em;margin-bottom:4px">🌙 Your ADE nights'+(hp?' · home = '+esc(hp.name):'')+'</div>';
  DAYS.slice(1).forEach(function(dd){
    var day=dd[0], date=DAYDATE[day];
    var going=nightGoers(date), me=iGo(date), others=going.filter(function(g){return g!==USER;}).length;
    var last=nightLastSet(day);
    var lt=last?lastTrainCore(date,last.startTime,last.endTime,last.lat,last.lng):null;
    var mp=SET["meet."+date], mpl=mp?DATA.places.filter(function(p){return p.id===mp;})[0]:null;
    h+='<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:8px 0;border-top:1px solid #1e293b">'
      +'<span style="width:36px;font-weight:700;color:#f0abfc">'+esc(dowName(date))+'</span>'
      +'<span class="daychip '+(me?'active':'')+'" onclick="toggleNight(\''+date+'\')">'+(me?'✓ going':'going?')+(others?(' +'+others):'')+'</span>'
      +(lt?('<span class="tiny" style="color:'+ltColor(lt.state)+'">'+ltEmoji(lt.state)+' '+esc(lt.short||lt.msg)+'</span>'):'<span class="tiny">no timed sets yet</span>')
      +'<span class="modechip" style="margin-left:auto" onclick="openMeet(\''+date+'\')">📍 '+(mpl?esc(mpl.name):'meet point')+'</span>'
      +'</div>';
  });
  h+='<div class="tiny" style="margin-top:8px">Tap a night to mark yourself in — friends sharing the app see who\'s going. 🟢🟠🔴 = the last train home after that night\'s final set (⚠ Sunday is not a Nachtnet weekend night). '
    +'<a href="'+NS_DISRUPT+'" target="_blank" rel="noopener" style="color:#7dd3fc">NS disruptions ↗</a> · '
    +'<a href="'+URL_9292+'" target="_blank" rel="noopener" style="color:#7dd3fc">9292 ↗</a> · '
    +'<a href="'+NS_OVFIETS+'" target="_blank" rel="noopener" style="color:#7dd3fc">OV-fiets ↗</a></div></div>';
  return h;
}
// 📍 per-night meetup pin — pick any located saved place
function openMeet(date){
  var cur=SET["meet."+date]||"";
  var pls=DATA.places.slice().sort(function(x,y){ return String((PCAT[x.category]||PCAT.other)[0]).localeCompare(String((PCAT[y.category]||PCAT.other)[0]))||String(x.name).localeCompare(y.name); });
  var rows=pls.map(function(p){
    var meta=(PCAT[p.category]||PCAT.other)[0]+(p.area?(' · '+p.area):'');
    return '<label class="artrow"><input type="radio" name="meetpick" value="'+p.id+'"'+(cur===p.id?' checked':'')+'><span class="artrow-t"><b>'+esc(p.name)+'</b><span class="tiny"> '+esc(meta)+'</span></span></label>';
  }).join("");
  if(!rows) rows='<div class="tiny" style="padding:10px">No saved places yet — add a bar or snack spot on the Places tab first.</div>';
  var m='<div class="modal-bg" onclick="closeModal(event)"><div class="modal" onclick="event.stopPropagation()"><h3>📍 Meet point — '+esc(fmtD(date))+'</h3><div class="body"><div class="tiny" style="margin-bottom:8px">Where the group links up before / during the night.</div><div class="artpick">'+rows+'</div></div>'
    +'<div class="foot"><button class="btn" onclick="saveMeet(\''+date+'\',true)">Clear</button><button class="btn" onclick="closeModal()">Cancel</button><button class="btn primary" onclick="saveMeet(\''+date+'\')">Save</button></div></div></div>';
  document.getElementById("modalRoot").innerHTML=m;
}
function saveMeet(date,clear){
  var v=""; if(!clear){ var el=document.querySelector('input[name="meetpick"]:checked'); v=el?el.value:""; }
  SET["meet."+date]=v;
  document.getElementById("modalRoot").innerHTML="";
  setStatus("Saving…");
  google.script.run.withSuccessHandler(function(){ setStatus("✓ Saved"); render(); }).withFailureHandler(onErr).setSetting("meet."+date,v);
}

// 🚉 live NS departures (only when the optional key is set — see ⚙)
var NSDEP=null, nsdepTried=false;
function loadNsDep(){ if(nsdepTried) return; nsdepTried=true; google.script.run.withSuccessHandler(function(v){ NSDEP=v; if(TAB==="overview"&&v&&v.length) render(); }).withFailureHandler(function(){}).getLiveDepartures(); }
function nsDepCard(){
  if(!SET["ns.apikey"]||!SET["home.name"]) return "";
  loadNsDep();
  if(!NSDEP||!NSDEP.length) return "";
  var rows=NSDEP.slice(0,4).map(function(d){
    var late=d.actual&&d.actual!==d.time;
    return '<div class="tiny" style="padding:3px 0;'+(d.cancelled?'text-decoration:line-through;opacity:.6':'')+'"><b style="color:'+(d.cancelled?'#fda4af':late?'#fcd34d':'#e2e8f0')+'">'+esc(late?d.actual:d.time)+'</b>'+(late?' <s style="opacity:.6">'+esc(d.time)+'</s>':'')+' → '+esc(d.to)+(d.platform?(' · spoor '+esc(d.platform)):'')+(d.cancelled?' · ❌':'')+'</div>';
  }).join("");
  return '<div class="card"><div class="tiny" style="text-transform:uppercase;letter-spacing:.08em;margin-bottom:6px">🚉 '+esc(SET["home.name"])+' — live departures</div>'+rows+'</div>';
}

// ⚙ Mode & home base — the one-time onboarding choice, changeable anytime
function openLocalSettings(){
  var m='<div class="modal-bg" onclick="closeModal(event)"><div class="modal" onclick="event.stopPropagation()"><h3>⚙ Mode &amp; home base</h3><div class="body">'
    +'<div class="fldwrap"><label class="fld">Mode</label><select id="ls_mode"><option value="visitor"'+(isLocal()?'':' selected')+'>✈ Visiting the Netherlands (trip shape: hotel anchor, flight logistics)</option><option value="local"'+(isLocal()?' selected':'')+'>🇳🇱 I live in the Netherlands (home anchor, last-train planner)</option></select></div>'
    +'<div class="fldwrap"><label class="fld">Home station (local mode) — free text, e.g. "Utrecht Centraal", "Nijmegen", "Delft"</label><input id="ls_home" type="text" value="'+esc(SET["home.name"]||"")+'" placeholder="Utrecht Centraal"></div>'
    +'<div class="fldwrap"><label class="fld">NS API key — OPTIONAL, for live departure boards (real-time platforms &amp; delays). Free registration at apiportal.ns.nl. Skip it and routed estimates cover the same need.</label><input id="ls_key" type="text" value="'+esc(SET["ns.apikey"]||"")+'" placeholder="paste key or leave blank"></div>'
    +'<div class="fldwrap"><label class="fld">Clock</label><select id="ls_24"><option value=""'+(SET["clock.24"]!=="1"&&SET["clock.24"]!=="0"?' selected':'')+'>Auto (24h in local mode, AM/PM otherwise)</option><option value="1"'+(SET["clock.24"]==="1"?' selected':'')+'>24-hour</option><option value="0"'+(SET["clock.24"]==="0"?' selected':'')+'>AM / PM</option></select></div>'
    +'<div class="tiny">Switching mode keeps ALL your data — it only changes the anchor point, which seed loads into empty tabs, and whether you see flight logistics or the 🟢🟠🔴 last-train planner.</div>'
    +'</div><div class="foot"><button class="btn" onclick="closeModal()">Cancel</button><button class="btn primary" onclick="saveLocalSettings()">Save</button></div></div></div>';
  document.getElementById("modalRoot").innerHTML=m;
}
function saveLocalSettings(){
  var mode=document.getElementById("ls_mode").value;
  var home=String(document.getElementById("ls_home").value||"").trim();
  var key=String(document.getElementById("ls_key").value||"").trim();
  var c24=document.getElementById("ls_24").value;
  document.getElementById("modalRoot").innerHTML="";
  setStatus("Saving…");
  var pend=1; function done(){ if(--pend<=0) load(); }
  if(key!==(SET["ns.apikey"]||"")){ pend++; google.script.run.withSuccessHandler(done).withFailureHandler(onErr).setSetting("ns.apikey",key); nsdepTried=false; NSDEP=null; }
  if(c24!==(SET["clock.24"]||"")){ pend++; google.script.run.withSuccessHandler(done).withFailureHandler(onErr).setSetting("clock.24",c24); }
  if(mode==="local"&&home&&home!==(SET["home.name"]||"")){ pend++; google.script.run.withSuccessHandler(done).withFailureHandler(function(e){ onErr(e); done(); }).setHomeStation(home); }
  // chooseMode also seeds empty tabs and re-geocodes — safe on every save
  google.script.run.withSuccessHandler(done).withFailureHandler(onErr).chooseMode(mode,"");
}

// First-open onboarding — "I'm visiting" vs "I live here". One codebase, two audiences.
function openOnboard(step){
  var body;
  if(step===1){
    body='<div class="tiny" style="margin-bottom:14px;line-height:1.55">One-time choice — change it later under <b>⚙ Mode &amp; home base</b>. Everything (sets, bookings, places, map, gap ideas) is shared; this only picks your starter template, your anchor point, and whether you get flight logistics or the last-train-home planner.</div>'
      +'<button class="btn primary" style="width:100%;margin-bottom:10px;padding:14px" onclick="obPick(\'visitor\')">✈ I\'m visiting the Netherlands</button>'
      +'<button class="btn" style="width:100%;padding:14px" onclick="openOnboard(2)">🇳🇱 I live in the Netherlands</button>';
  } else {
    body='<div class="fldwrap"><label class="fld">Your home station — free text, e.g. "Utrecht Centraal", "Nijmegen", "Delft"</label><input id="ob_home" type="text" placeholder="Utrecht Centraal"></div>'
      +'<div class="tiny" style="margin-bottom:12px;line-height:1.55">This drives every distance, all directions, and the 🟢🟠🔴 <b>last-train-home</b> planner — it\'s matched against the NS Nachtnet map, so use the station\'s real name.</div>'
      +'<button class="btn primary" style="width:100%;padding:12px" onclick="obLocal()">Let\'s go</button>'
      +'<button class="btn" style="width:100%;margin-top:8px" onclick="openOnboard(1)">← Back</button>';
  }
  document.getElementById("modalRoot").innerHTML='<div class="modal-bg"><div class="modal" onclick="event.stopPropagation()"><h3>'+(step===1?'Welcome — how are you doing ADE 2026?':'🇳🇱 Where\'s home?')+'</h3><div class="body">'+body+'</div></div></div>';
}
function obPick(m){
  document.getElementById("modalRoot").innerHTML=""; setStatus("Setting up…");
  google.script.run.withSuccessHandler(function(){ setStatus("✓ Ready"); load(); }).withFailureHandler(function(e){ onErr(e); openOnboard(1); }).chooseMode(m,"");
}
function obLocal(){
  var v=String(document.getElementById("ob_home").value||"").trim();
  if(!v){ alert("Type your home station first."); return; }
  document.getElementById("modalRoot").innerHTML=""; setStatus("Finding "+v+"…");
  google.script.run.withSuccessHandler(function(){ setStatus("✓ Ready"); load(); }).withFailureHandler(function(e){ onErr(e); openOnboard(2); }).chooseMode("local",v);
}

// ===== 💶 Costs (local mode) — NS fares, parking, OV-fiets, per night, split =====
function costAmt(c){ var n=parseFloat(String(c.amount).replace(",",".")); return isNaN(n)?0:n; }
function costPeople(c){
  var out=[String(c.paidBy||"").trim()||"?"];
  String(c.splitWith||"").split(",").map(function(x){return x.trim();}).filter(Boolean).forEach(function(p){ if(out.indexOf(p)===-1) out.push(p); });
  return out;
}
function costsPanel(){
  var h='<div class="card" style="font-size:12px;color:#94a3b8">Per-night money: NS fares, parking / P+R, OV-fiets, 4am food. "Split with" assumes even shares between the payer and everyone listed — balances below.</div>';
  if(!DATA.costs.length) return h+'<div class="empty">No costs yet. Tap + after your first night in.</div>';
  var total=0, net={}, byDate={};
  DATA.costs.forEach(function(c){
    var amt=costAmt(c); total+=amt;
    var ppl=costPeople(c), share=ppl.length?amt/ppl.length:0;
    ppl.forEach(function(p,i){ net[p]=(net[p]||0)+(i===0?amt-share:-share); });
    (byDate[c.date||""]=byDate[c.date||""]||[]).push(c);
  });
  h+='<div class="card"><div class="tiny" style="text-transform:uppercase;letter-spacing:.08em;margin-bottom:6px">Totals</div><div class="name">€'+total.toFixed(2)+' so far</div>';
  var bal=Object.keys(net).filter(function(p){ return Math.abs(net[p])>=0.01; });
  if(bal.length) h+='<div class="tiny" style="margin-top:6px">'+bal.map(function(p){ var v=net[p]; return esc(p)+(v>0?' is owed <b style="color:#6ee7b7">€'+v.toFixed(2)+'</b>':' owes <b style="color:#fda4af">€'+(-v).toFixed(2)+'</b>'); }).join(' · ')+'</div>';
  h+='</div>';
  Object.keys(byDate).sort().forEach(function(d){
    var sub=byDate[d].reduce(function(s,c){return s+costAmt(c);},0);
    h+='<div class="grouphdr">'+(d?esc(fmtD(d)):"No date")+' · €'+sub.toFixed(2)+'</div>';
    byDate[d].forEach(function(c){
      h+='<div class="card"><div class="row"><div style="min-width:0;flex:1"><div class="name" style="font-size:14px">'+esc(c.what)+'</div>'
        +'<div class="tiny" style="margin-top:2px">€'+costAmt(c).toFixed(2)+(c.paidBy?' · paid by '+esc(c.paidBy):'')+(c.splitWith?' · split with '+esc(c.splitWith):'')+'</div>'
        +(c.notes?'<div class="muted" style="margin-top:4px;white-space:pre-wrap">'+esc(c.notes)+'</div>':'')
        +'</div><div style="flex:none">'+editDel("Costs",c.id)+'</div></div></div>';
    });
  });
  return h;
}

function artistsPanel(){
  var cf=conflicts();
  var h='<div class="filterbar">'+[["all","All days"]].concat(DAYS).map(function(d){return '<button class="chip '+(dayFilter===d[0]?"active":"")+'" onclick="setDay(\''+d[0]+'\')">'+d[1]+'</button>';}).join("")+'</div>';
  var list=DATA.artists.filter(function(a){return dayFilter==="all"||a.day===dayFilter;}).slice().sort(function(x,y){ return (DAYIDX[x.day]-DAYIDX[y.day])||((tmin(x.startTime)==null?9999:tmin(x.startTime))-(tmin(y.startTime)==null?9999:tmin(y.startTime)))||String(x.name).localeCompare(y.name); });
  if(!list.length) return h+'<div class="empty">No artists yet. Add sets you want to catch as lineups drop.</div>';
  var last=null;
  list.forEach(function(a){
    if(a.day!==last){ h+='<div class="grouphdr">'+DAYLBL[a.day]+'</div>'; last=a.day; }
    h+='<div class="card"><div class="row"><div><div class="name">'+esc(a.name)+(a.priority==="must"?' ★':'')+'</div>'+(a.genre?'<div class="tiny">'+esc(a.genre)+'</div>':'')+'</div><div>'+editDel("Artists",a.id)+'</div></div>';
    var meta=[]; if(a.startTime||a.endTime) meta.push("🕒 "+esc(fmtT(a.startTime))+(a.endTime?" – "+esc(fmtT(a.endTime)):"")); if(a.venue) meta.push("📍 "+esc(a.venue));
    if(meta.length) h+='<div class="muted" style="margin-top:6px">'+meta.join("&nbsp;&nbsp;")+'</div>';
    var tk=coveringTicket(a.id);
    var ticBadge=tk?badge(TIC.have):badge(TIC[a.ticketStatus]||TIC.need);
    h+='<div style="margin-top:4px">'+badge(PRI[a.priority]||PRI.maybe)+ticBadge+(cf[a.id]?badge(["⚠ Time clash","rgba(244,63,94,.18)","#fda4af","rgba(244,63,94,.4)"]):'')+'</div>';
    if(tk){
      h+='<div class="muted" style="margin-top:6px;color:'+(tk.status==="personalize"?"#fcd34d":"#cbd5e1")+'">'+esc(ticketLineText(tk))+'</div>';
      var tu=ticketUrl(tk.orderUrl);
      h+='<div>'+(tu?'<a class="linkbtn" href="'+esc(tu)+'" target="_blank" rel="noopener">🎫 Open order ↗</a>':'')+'<span class="linkbtn" onclick="go(\'bookings\')">🔗 Ticket details</span></div>';
    }
    if(isLocal()&&a.endTime) h+=ltBadgeHtml(lastTrainCore(DAYDATE[a.day],a.startTime,a.endTime,a.lat,a.lng));
    if(a.notes) h+='<div class="muted" style="margin-top:6px;white-space:pre-wrap">'+linkify(a.notes)+'</div>';
    h+='</div>';
  });
  return h;
}
function setDay(d){ dayFilter=d; render(); }

function timelinePanel(){
  GAPS=[];   // rebuilt on every render so gap chips index correctly
  if(isLocal()) loadWeather();   // powers the 🚲-day rain hint in the day headers
  var evs=tripEvents();
  var seg1='<div class="seg"><button class="'+(tlView==="timeline"?"on":"")+'" onclick="setTlView(\'timeline\')">\uD83D\uDCCB Timeline</button><button class="'+(tlView==="calendar"?"on":"")+'" onclick="setTlView(\'calendar\')">\uD83D\uDDD3 Calendar</button></div>';
  var seg2='<div class="seg" style="margin-left:auto">'+[["all","All"],["travel","Travel"],["sets","Sets"]].map(function(o){return '<button class="'+(tlLayer===o[0]?"on":"")+'" onclick="setTlLayer(\''+o[0]+'\')">'+o[1]+'</button>';}).join("")+'</div>';
  var legend='<div class="legend">'+(isLocal()?["concert","transit","ticket","reservation","lodging","prep"]:["flight","concert","ticket","lodging","transit","prep","layover"]).map(function(k){return kbadge(k);}).join("")+'</div>';
  var body=tlView==="calendar"?tlCalendarHtml(evs):tlTimelineHtml(evs);
  return '<div class="tlbar">'+seg1+seg2+'</div>'+legend+body;
}
function setTlView(v){ tlView=v; render(); }
function setTlLayer(l){ tlLayer=l; render(); }
function kbadge(k){ return '<span class="badge" style="background:'+KSOFT[k]+';color:'+KACC[k]+';border-color:'+KACC[k]+'">'+(KIND[k]||KIND.general).icon+' '+(KIND[k]||KIND.general).label+'</span>'; }

// ----- weighted, time-proportional vertical timeline -----
function tlTimelineHtml(evs){
  var dated=evs.filter(function(e){return e.date;}).sort(function(a,b){return (evStartMs(a)-evStartMs(b))||((to24(a.time)==null?9999:to24(a.time))-(to24(b.time)==null?9999:to24(b.time)));});
  var undated=evs.filter(function(e){return !e.date;});
  if(!dated.length&&!undated.length) return '<div class="empty">No items in this view yet. Add departures, layovers, check-ins — or flip to <b>All</b> to fold in your sets.</div>';
  var h="";
  if(dated.length){
    var byDate={}; dated.forEach(function(e){ (byDate[e.date]=byDate[e.date]||[]).push(e); });
    var popDays=Object.keys(byDate).sort();
    popDays.forEach(function(d,idx){
      if(idx>0){
        var span=daysBetween(popDays[idx-1], d), gap=span.length-1; // calendar days between (exclusive)
        if(gap>=1&&gap<=3){ span.slice(1,-1).forEach(function(ed){ h+='<div class="dayhdr" style="opacity:.6"><span class="dow">'+esc(dowName(ed))+'</span><span>'+esc(dmName(ed))+'</span></div><div class="emptyday">— nothing planned —</div>'; }); }
        else if(gap>3){ h+='<div class="gapdivider">· '+gap+' days later ·</div>'; }
      }
      var list=byDate[d];
      var dmode=dayModeFor(d,list);
      var wx='';
      if(dmode==="bike"&&WEATHER&&WEATHER.dates){ var wI=WEATHER.dates.indexOf(d); if(wI!==-1&&Number(WEATHER.rain[wI])>=55) wx='<span class="tiny" style="color:#7dd3fc">🌧 '+WEATHER.rain[wI]+'% rain — maybe leave the bike</span>'; }
      h+='<div class="dayhdr"><span class="dow">'+esc(dowName(d))+'</span><span>'+esc(dmName(d))+'</span>'+modeChip(d,dmode)+wx+'<span class="cnt">'+list.length+(list.length>1?" items":" item")+'</span></div>';
      h+='<div class="tline">';
      var prev=null, prevEv=null;
      list.forEach(function(e){
        var mins=to24(e.time), space=8;
        if(prev!=null&&mins!=null){ var g=mins-prev; if(g>=90) h+='<div class="gaplbl">'+fmtGap(g)+'</div>'; space=gapPx(g); }
        // a quick "how do we get from the last stop to this one" hop — real/estimated time, and a red whisper when it can't fit
        if(prevEv&&hasCoord(prevEv)&&hasCoord(e)&&!(num(prevEv.lat)===num(e.lat)&&num(prevEv.lng)===num(e.lng))){
          var tr=travelBetween(prevEv,e,dmode);
          var hu=dirUrl(num(prevEv.lat),num(prevEv.lng),num(e.lat),num(e.lng),tr.mode);
          var pS=to24(prevEv.time), pE=to24(prevEv.endTime), bS=mins;
          var leave=(pE!=null&&pS!=null&&pE>pS)?pE:pS;
          var avail=(leave!=null&&bS!=null)?(bS-leave):null;
          var warn=(avail!=null&&tr.mins>avail+3);
          h+='<div class="hop'+(warn?' warn':'')+'"><a href="'+hu+'" target="_blank" rel="noopener">'+tr.icon+' ~'+tr.mins+' min · '+tr.km.toFixed(tr.km<10?1:0)+' km to next — directions</a>'+(warn?'&nbsp;<span class="warntxt">⚠ only '+fmtDur(Math.max(avail,0))+' before it starts</span>':'')+'</div>';
        }
        // free-time chip: saved ideas that genuinely fit between the last located stop and this one
        if(prevEv&&hasCoord(prevEv)&&hasCoord(e)&&mins!=null&&departMin(prevEv)!=null){
          var free=mins-departMin(prevEv);
          if(free>=45){
            var cands=gapCandidates(prevEv,e,free,dmode);
            if(cands.length){
              GAPS.push({date:d,a:prevEv,b:e,free:free,mode:dmode});
              h+='<div class="gapchip" onclick="openGap('+(GAPS.length-1)+')">⏱ '+fmtDur(free)+' free · '+cands.length+' idea'+(cands.length>1?'s':'')+' fit</div>';
            }
          }
        }
        h+=evCardHtml(e,space);
        if(mins!=null) prev=mins;
        if(hasCoord(e)) prevEv=e;
      });
      // the night's real last leg: → home. Directions from the final stop, plus the
      // 🟢🟠🔴 last-train verdict for the day's last timed set.
      if(isLocal()&&prevEv){
        var hpT=homePoint();
        if(hpT&&!(num(prevEv.lat)===hpT.lat&&num(prevEv.lng)===hpT.lng)){
          var trH=travelBetween(prevEv,hpT,dmode==="driving"?"driving":dmode==="bike"?"bike":"mixed");
          var huH=dirUrl(num(prevEv.lat),num(prevEv.lng),hpT.lat,hpT.lng,dmode==="driving"?"driving":dmode==="bike"?"bicycling":"transit");
          var lastSetT=null;
          for(var li=list.length-1;li>=0;li--){ if(list[li].kind==="concert"&&list[li].endTime){ lastSetT=list[li]; break; } }
          var ltT=lastSetT?lastTrainCore(d,lastSetT.time,lastSetT.endTime,lastSetT.lat,lastSetT.lng):null;
          h+='<div class="hop"><a href="'+huH+'" target="_blank" rel="noopener">🏠 → home ('+esc(hpT.name)+')'+(dmode==="bike"?' · 🚲':dmode==="driving"?' · 🚗':' · ~'+trH.mins+' min')+' — directions</a>'
            +'&nbsp;· <a href="'+URL_9292+'" target="_blank" rel="noopener" style="color:#7dd3fc">9292</a>'
            +'&nbsp;· <a href="'+NS_DISRUPT+'" target="_blank" rel="noopener" style="color:#7dd3fc">NS works</a></div>';
          if(ltT&&dmode!=="driving"&&dmode!=="bike") h+='<div style="margin-left:26px">'+ltBadgeHtml(ltT,true)+'</div>';
        }
      }
      h+='</div>';
    });
  }
  if(undated.length){
    h+='<div class="dayhdr"><span class="dow">Unscheduled</span><span class="cnt">'+undated.length+'</span></div><div class="tline">';
    undated.forEach(function(e){ h+=evCardHtml(e,8); });
    h+='</div>';
  }
  return h;
}
function evCardHtml(e,space){
  var k=KIND[e.kind]||KIND.general, w=evWeight(e), acc=KACC[e.kind]||KACC.general, soft=KSOFT[e.kind]||KSOFT.general;
  var done=e.src==="Timeline"&&isTrue(e.done);
  var star=(e.kind==="concert"&&e.priority==="must")?' \u2605':'';
  var when=(e.time?esc(fmtT(e.time)):"Time TBD")+(e.endTime?(" \u2013 "+esc(fmtT(e.endTime))):"");
  var dotClick=e.src==="Timeline"?(' onclick="patch(\'Timeline\',\''+e.id+'\',{done:'+(!done)+'})"'):'';
  var h='<div class="ev w'+w+'" style="margin-top:'+space+'px">';
  h+='<span class="edot" style="border-color:'+acc+';'+(done?'background:'+acc+';':'')+'"'+dotClick+'></span>';
  h+='<div class="evcard" style="border-left-color:'+acc+';'+(w>=3?'background:linear-gradient(100deg,'+soft+',#0f172a 65%);':'')+'">';
  h+='<div class="row"><div style="min-width:0;flex:1">';
  h+='<div class="ewhen" style="color:'+acc+'">'+k.icon+'\u2002'+when+'</div>';
  h+='<div class="etitle '+(done?'done-txt':'')+'">'+esc(e.title)+star+'</div>';
  if(e.venue) h+='<div class="muted" style="margin-top:3px">\uD83D\uDCCD '+esc(e.venue)+'</div>';
  var bg='<span class="badge" style="background:'+soft+';color:'+acc+';border-color:'+acc+'">'+k.label+'</span>';
  if(e.kind==="concert"){ var eff=e.ticket?"have":e.ticketStatus; if(eff) bg+=badge(TIC[eff]||TIC.need); }
  if(e.kind==="ticket"&&e.ticket) bg+=badge(TSTAT[e.ticket.status]||TSTAT.purchased);
  if(e.kind==="reservation"&&e.ticket) bg+=badge(TSTAT[e.ticket.status]||TSTAT.booked);
  h+='<div style="margin-top:5px">'+bg+'</div>';
  if(e.ticket) h+='<div class="muted" style="margin-top:6px;color:'+(e.ticket.status==="personalize"?"#fcd34d":"#cbd5e1")+'">'+esc(ticketLineText(e.ticket))+'</div>';
  if(e.details) h+='<div class="muted" style="margin-top:6px;white-space:pre-wrap">'+linkify(e.details)+'</div>';
  if(hasCoord(e)) h+=distLine(e);
  if(isLocal()&&e.kind==="concert"&&e.endTime&&e.date) h+=ltBadgeHtml(lastTrainCore(e.date,e.time,e.endTime,e.lat,e.lng));
  if(!isLocal()&&e.flightNumber&&String(e.flightNumber).trim()) h+='<div><a class="linkbtn" href="'+flightStatusUrl(e.flightNumber)+'" target="_blank" rel="noopener">\u2708 See flight status ('+esc(String(e.flightNumber).toUpperCase())+') \u2197</a></div>';
  if(isLocal()&&e.kind==="transit") h+='<div><a class="linkbtn" href="'+NS_DISRUPT+'" target="_blank" rel="noopener">\uD83D\uDE86 NS disruptions \u2197</a> <a class="linkbtn" href="'+NS_OVFIETS+'" target="_blank" rel="noopener">\uD83D\uDEB2 OV-fiets \u2197</a></div>';
  if(e.ticket){ var tu=ticketUrl(e.ticket.orderUrl); if(tu) h+='<div><a class="linkbtn" href="'+esc(tu)+'" target="_blank" rel="noopener">'+(bkType(e.ticket)==="reservation"?"\uD83C\uDF7D Open reservation":"\uD83C\uDFAB Open order")+' \u2197</a></div>'; }
  h+='</div><div style="flex:none">'+editDel(e.src,e.id)+'</div></div></div></div>';
  return h;
}

// ----- calendar: day columns with a proportional time axis -----
function tlCalendarHtml(evs){
  var dated=evs.filter(function(e){return e.date&&to24(e.time)!=null;});
  var loose=evs.filter(function(e){return !e.date||to24(e.time)==null;});
  var h="";
  if(loose.length){
    h+='<div class="tiny" style="margin-bottom:6px">No date/time yet — tap to set</div><div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:16px">';
    loose.forEach(function(e){ var acc=KACC[e.kind]||KACC.general,soft=KSOFT[e.kind]||KSOFT.general;
      h+='<span class="badge" style="background:'+soft+';color:'+acc+';border-color:'+acc+';cursor:pointer" onclick="openModal(\''+e.src+'\',\''+e.id+'\')">'+(KIND[e.kind]||KIND.general).icon+' '+esc(e.title)+'</span>'; });
    h+='</div>';
  }
  if(!dated.length) return h+'<div class="empty">Give events a date <i>and</i> time to place them on the calendar.</div>';
  var dset={}; dated.forEach(function(e){ dset[e.date]=1; });
  var cols=Object.keys(dset).sort();
  var hours=''; for(var hh=Math.ceil(AX_START/60); hh*60<=AX_END; hh++){ hours+='<div class="hourlbl" style="top:'+topPx(hh*60)+'px">'+(hh%24)+':00</div>'; }
  var gutter='<div class="calgutter"><div class="calcolhdr">&nbsp;</div><div class="calbody" style="height:'+AX_H+'px">'+hours+'</div></div>';
  var colsHtml=cols.map(function(d){
    var items=dated.filter(function(e){return e.date===d;}).map(function(e){
      var s=slotMin(e), en;
      if(e.endTime){ var m=to24(e.endTime); if(m==null){ en=s+45; } else { if(m<300) m+=1440; en=(m<=s)?m+1440:m; } }
      else en=s+(e.kind==="concert"?60:40);
      return {e:e,start:s,end:en};
    });
    var lines=''; for(var hh=Math.ceil(AX_START/60); hh*60<=AX_END; hh++){ lines+='<div class="hourline" style="top:'+topPx(hh*60)+'px"></div>'; }
    var blocks=assignLanes(items).map(function(it){
      var e=it.e, acc=KACC[e.kind]||KACC.general, soft=KSOFT[e.kind]||KSOFT.general, w=evWeight(e);
      var top=topPx(it.start), hgt=Math.max(26, topPx(it.end)-topPx(it.start)-2);
      var left=(it.lane/it.lanes)*100, wid=(1/it.lanes)*100;
      var star=(e.kind==="concert"&&e.priority==="must")?' \u2605':'';
      return '<div class="cev" style="top:'+top+'px;height:'+hgt+'px;left:calc('+left+'% + 2px);width:calc('+wid+'% - 4px);background:'+soft+';border-color:'+acc+';'+(w>=3?'box-shadow:0 0 0 1px '+acc+'55;':'')+'" onclick="openModal(\''+e.src+'\',\''+e.id+'\')"><span class="ct">'+(KIND[e.kind]||KIND.general).icon+' '+esc(e.title)+star+'</span><span class="cw" style="color:'+acc+'">'+esc(fmtT(e.time))+'</span></div>';
    }).join("");
    return '<div class="calcol"><div class="calcolhdr"><span class="dow">'+esc(dowName(d))+'</span>'+esc(dmName(d))+'</div><div class="calbody" style="height:'+AX_H+'px">'+lines+blocks+'</div></div>';
  }).join("");
  h+='<div class="calwrap"><div class="calgrid">'+gutter+colsHtml+'</div></div>';
  h+='<div class="tiny" style="margin-top:10px;color:#475569">Swipe sideways for more days · tap a block to edit · after-midnight sets sit at the bottom of their date.</div>';
  return h;
}

function placesPanel(){
  var cats=Object.keys(PCAT), order={}; cats.forEach(function(c,i){order[c]=i;});
  var h='<div class="card" style="font-size:12px;color:#94a3b8">Tap <b>Open in Maps</b>, then Save in Google Maps to add it to your own list for navigation. Heart 🤍 the ideas you\'re keen on — 💞 means you both are, and mutual likes rank first in gap suggestions.</div>';
  h+='<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px"><span class="geobtn" onclick="openImport()">⬇ Import from Google Maps</span><span class="geobtn" onclick="openDurations()">⏱ Typical durations</span></div>';
  h+='<div class="filterbar">'+[["all","All"]].concat(cats.map(function(c){return [c,PCAT[c][0]];})).map(function(c){return '<button class="chip '+(placeCat===c[0]?"active":"")+'" onclick="setCat(\''+c[0]+'\')">'+c[1]+'</button>';}).join("")+'</div>';
  var pri={want:0,maybe:1,been:2};
  var list=DATA.places.filter(function(p){return placeCat==="all"||p.category===placeCat;}).slice().sort(function(a,b){ return (order[a.category]-order[b.category])||((pri[a.priority]||1)-(pri[b.priority]||1))||String(a.name).localeCompare(b.name); });
  if(!list.length) return h+'<div class="empty">No places yet. Pin restaurants, coffee, sights, activities.</div>';
  var last=null;
  list.forEach(function(p){
    if(placeCat==="all"&&p.category!==last){ h+='<div class="grouphdr">'+(PCAT[p.category]||PCAT.other)[0]+'</div>'; last=p.category; }
    h+='<div class="card"><div class="row"><div><div class="name">'+esc(p.name)+'</div>'+((p.address||p.area)?'<div class="tiny">📍 '+esc(p.address||p.area)+'</div>':'')+'</div><div style="flex:none">'+heartBtn(p)+editDel("Places",p.id)+'</div></div>';
    h+='<div style="margin-top:4px">'+badge(PCAT[p.category]||PCAT.other)+badge(PPRI[p.priority]||PPRI.want)+'<span class="badge" style="background:rgba(34,211,238,.12);color:#67e8f9;border-color:rgba(34,211,238,.35)">⏱ ~'+fmtDur(durFor(p))+'</span>'+(mutualHeart(p)?'<span class="badge" style="background:rgba(217,70,239,.18);color:#f0abfc;border-color:rgba(217,70,239,.4)">💞 Both of us</span>':'')+'</div>';
    if(p.sourceList) h+='<div class="tiny" style="margin-top:4px">⬇ from “'+esc(p.sourceList)+'”</div>';
    var bk=coveringBooking(p.id);
    if(bk){ var isRes=bkType(bk)==="reservation"; var acc=isRes?"#fb7185":"#fb923c"; var soft=isRes?"rgba(251,113,133,.12)":"rgba(251,146,60,.12)";
      var when=[]; if(bk.eventDate) when.push(esc(fmtD(bk.eventDate))); if(bk.eventTime) when.push(esc(fmtT(bk.eventTime)));
      var bu=ticketUrl(bk.orderUrl);
      h+='<div class="booked-line" style="border-color:'+acc+';background:'+soft+';color:#e2e8f0"><span>'+(isRes?"🍽":"🎫")+'</span><span style="flex:1">'+(isRes?"Reserved":"Ticket")+(when.length?" · "+when.join(" · "):"")+(bk.quantity?(" · "+(isRes?"party of ":"")+esc(bk.quantity)):"")+(bu?' &nbsp;<a href="'+esc(bu)+'" target="_blank" rel="noopener" style="color:'+acc+';font-weight:600;text-decoration:none">'+(isRes?"Open reservation ↗":"Open order ↗")+'</a>':"")+'</span></div>'; }
    if(p.notes) h+='<div class="muted" style="margin-top:6px;white-space:pre-wrap">'+esc(p.notes)+'</div>';
    if(hasCoord(p)) h+=distLine(p);
    h+='<div><a class="linkbtn" href="'+mapsLink(p)+'" target="_blank" rel="noopener">↗ Open in Maps</a><span class="linkbtn" onclick="toggle(\''+p.id+'\')">🗺 '+(expanded===p.id?"Hide map":"Preview map")+'</span></div>';
    if(expanded===p.id) h+='<iframe loading="lazy" src="'+mapsEmbed(p)+'"></iframe>';
    h+='</div>';
  });
  return h;
}
function setCat(c){ placeCat=c; render(); }
function toggle(id){ expanded=expanded===id?null:id; render(); }

function staysPanel(){
  if(!DATA.stays.length) return isLocal()
    ? '<div class="empty">Crashing somewhere some nights (a friend\'s couch, a one-night hotel after a 🔴 no-train night)? Add it here — otherwise home is home and this tab can stay empty.</div>'
    : '<div class="empty">No accommodations yet. Add bookings with check-in/out times.</div>';
  var h="";
  DATA.stays.forEach(function(s){
    h+='<div class="card"><div class="row"><div><div class="name">'+esc(s.name)+'</div>'+(s.address?'<div class="muted">📍 '+esc(s.address)+'</div>':'')+'</div><div>'+editDel("Stays",s.id)+'</div></div>';
    h+='<div class="grid" style="margin-top:10px"><div class="card" style="margin:0;background:#1e293b"><div class="tiny">Check-in</div><div>'+(esc(fmtD(s.checkInDate))||"—")+'</div>'+(s.checkInTime?'<div class="tiny" style="color:#6ee7b7">'+esc(fmtT(s.checkInTime))+'</div>':'')+'</div>';
    h+='<div class="card" style="margin:0;background:#1e293b"><div class="tiny">Check-out</div><div>'+(esc(fmtD(s.checkOutDate))||"—")+'</div>'+(s.checkOutTime?'<div class="tiny" style="color:#fda4af">'+esc(fmtT(s.checkOutTime))+'</div>':'')+'</div></div>';
    if(s.confirmation) h+='<div class="tiny" style="margin-top:8px">Confirmation: '+esc(s.confirmation)+'</div>';
    if(s.notes) h+='<div class="muted" style="margin-top:6px;white-space:pre-wrap">'+esc(s.notes)+'</div>';
    h+='<div><a class="linkbtn" href="https://www.google.com/maps/search/?api=1&query='+encodeURIComponent((s.address||s.name)+", Amsterdam")+'" target="_blank" rel="noopener">↗ Open in Maps</a></div></div>';
  });
  return h;
}

function todosPanel(){
  if(!DATA.todos.length) return '<div class="empty">Nothing here yet. Track open questions, things to book, decisions.</div>';
  var list=DATA.todos.slice().sort(function(a,b){ return (isTrue(a.done)===isTrue(b.done))?0:(isTrue(a.done)?1:-1); });
  var h="";
  list.forEach(function(t){ var dn=isTrue(t.done);
    h+='<div class="card" style="display:flex;gap:10px;align-items:flex-start'+(dn?";opacity:.6":"")+'"><div onclick="patch(\'Todos\',\''+t.id+'\',{done:'+(!dn)+'})" style="width:20px;height:20px;border-radius:6px;border:1px solid '+(dn?"#10b981":"#475569")+';background:'+(dn?"#10b981":"none")+';color:#fff;text-align:center;line-height:18px;cursor:pointer;flex:none">'+(dn?"✓":"")+'</div><div style="flex:1"><div class="'+(dn?"done-txt":"")+'">'+esc(t.text)+'</div><div>'+badge(TCAT[t.category]||TCAT.todo)+'</div></div><div>'+editDel("Todos",t.id)+'</div></div>';
  });
  return h;
}

var ticketFilter="all";
function setTFilter(s){ ticketFilter=s; render(); }
function ticketUrl(u){ var s=String(u||"").trim(); if(!s) return ""; return /^https?:\/\//i.test(s)?s:("https://"+s); }
function ticketAvail(t){
  if(!t.availableDate) return null;
  var ad=new Date(t.availableDate+"T00:00:00"); if(isNaN(ad)) return null;
  var today=new Date(); today.setHours(0,0,0,0);
  return {future: ad>today, label: fmtD(t.availableDate)};
}
// ---- ticket <-> set linking ----
function splitIds(s){ return String(s||"").split(",").map(function(x){return x.trim();}).filter(Boolean); }
function coveringTicket(artistId){ var ts=DATA.tickets.filter(function(t){return splitIds(t.artistIds).indexOf(artistId)!==-1;}); if(!ts.length) return null; var pers=ts.filter(function(t){return t.status==="personalize";}); return pers[0]||ts[0]; }
function artistNames(ids){ return splitIds(ids).map(function(id){ var a=DATA.artists.filter(function(x){return x.id===id;})[0]; return a?a.name:null; }).filter(Boolean); }
// ---- booking helpers (tickets + reservations share the "tickets" store) ----
function bkType(t){ return (t&&t.type==="reservation")?"reservation":"ticket"; }
function placeNames(ids){ return splitIds(ids).map(function(id){ var p=DATA.places.filter(function(x){return x.id===id;})[0]; return p?p.name:null; }).filter(Boolean); }
// the booking covering a place (a reservation wins over a plain ticket for display)
function coveringBooking(placeId){ var bs=DATA.tickets.filter(function(t){return splitIds(t.placeIds).indexOf(placeId)!==-1;}); if(!bs.length) return null; var resv=bs.filter(function(t){return bkType(t)==="reservation";}); return resv[0]||bs[0]; }
// ---- geo: distances + keyless directions hand-off ----
function num(v){ var n=parseFloat(v); return isNaN(n)?null:n; }
function hasCoord(o){ return o&&num(o.lat)!=null&&num(o.lng)!=null; }
// the stay we measure "distance from where we're staying" against (first one with coordinates)
function refStay(){ var s=DATA.stays.filter(hasCoord); return s[0]||null; }
function haversineKm(la1,lo1,la2,lo2){ var R=6371,dLa=(la2-la1)*Math.PI/180,dLo=(lo2-lo1)*Math.PI/180; var a=Math.sin(dLa/2)*Math.sin(dLa/2)+Math.cos(la1*Math.PI/180)*Math.cos(la2*Math.PI/180)*Math.sin(dLo/2)*Math.sin(dLo/2); return R*2*Math.atan2(Math.sqrt(a),Math.sqrt(1-a)); }
function kmFromStay(o){ var s=anchorPoint(); if(!s||!hasCoord(o)) return null; return haversineKm(num(s.lat),num(s.lng),num(o.lat),num(o.lng)); }
// Google Maps directions deep-link (no key needed — opens the Maps app/site with a live route)
function dirUrl(fromLat,fromLng,toLat,toLng,mode){ return "https://www.google.com/maps/dir/?api=1&origin="+fromLat+","+fromLng+"&destination="+toLat+","+toLng+"&travelmode="+(mode||"transit"); }
// "0.8 km from The Hoxton  ·  🧭 Directions" line for any coord-bearing record
function distLine(o){ var s=anchorPoint(); if(!hasCoord(o)) return ""; var bits=""; var km=kmFromStay(o); if(km!=null&&s) bits+='📍 '+km.toFixed(km<10?1:0)+' km from '+esc(s.name); if(s&&hasCoord(s)){ var u=dirUrl(num(s.lat),num(s.lng),num(o.lat),num(o.lng),"transit"); bits+=(bits?'&nbsp;&nbsp;':'')+'<a class="linkbtn" style="margin-top:0;padding:3px 8px" href="'+u+'" target="_blank" rel="noopener">🧭 Directions</a>'; } return bits?'<div class="tiny" style="margin-top:6px;display:flex;align-items:center;flex-wrap:wrap;gap:4px">'+bits+'</div>':''; }

// ===== Planner smarts: durations, hearts, travel times, gap-filler, weather, digest =====

// ---- typical durations (Settings-driven, per-place override) ----
function durFor(p){ var o=parseInt(p&&p.durationMin,10); if(!isNaN(o)&&o>0) return o; var d=parseInt(SET["dur."+((p&&p.category)||"other")],10); return isNaN(d)?60:d; }
function fmtDur(m){ m=Math.round(m); if(m<60) return m+"m"; var h=Math.floor(m/60),r=m%60; return h+"h"+(r?(" "+r+"m"):""); }
function hhmmStr(m){ m=((Math.round(m)%1440)+1440)%1440; var h=Math.floor(m/60),mm=m%60; return (h<10?"0":"")+h+":"+(mm<10?"0":"")+mm; }

// ---- ❤ hearts (mutual likes bubble up in gap suggestions) ----
function heartsOf(p){ return splitIds(p&&p.hearts); }
function iHeart(p){ return USER&&heartsOf(p).indexOf(USER)!==-1; }
function mutualHeart(p){ return heartsOf(p).length>=2; }
function heartBtn(p,gi){ var me=iHeart(p),mu=mutualHeart(p); return '<button class="iconbtn heart" onclick="toggleHeart(\''+p.id+'\''+(gi!=null?(','+gi):'')+')" title="'+(me?'Un-heart':'Heart this idea')+'">'+(mu?'💞':(me?'❤️':'🤍'))+'</button>'; }
function toggleHeart(id,gi){
  var p=DATA.places.filter(function(x){return x.id===id;})[0]; if(!p||!USER) return;
  var hs=heartsOf(p), i=hs.indexOf(USER);
  if(i===-1) hs.push(USER); else hs.splice(i,1);
  p.hearts=hs.join(",");
  if(gi!=null&&GAPS[gi]) renderGap(gi);
  setStatus("Saving…");
  google.script.run.withSuccessHandler(function(){ setStatus("✓ Saved"); if(gi==null) render(); }).withFailureHandler(onErr).saveRecord("Places",p);
}

// ---- travel estimates (instant, local) + real cached routes ----
var AMS=[52.3676,4.9041];
function walkMins(km){ return Math.round(km*1.3/4.7*60); }     // 1.3 route factor, 4.7 km/h
function transitMins(km){ return Math.round(9+km*3.4); }        // wait + ~17 km/h door to door
function driveMins(km){ return Math.round(6+km*1.4); }          // park + city driving
function travelEst(km,dmode){ if(dmode==="driving") return {mins:driveMins(km),mode:"driving",icon:"🚗"}; if(dmode==="bike") return {mins:Math.max(3,Math.round(km*1.3/15*60)),mode:"bicycling",icon:"🚲"}; var w=walkMins(km); if(w<=30) return {mins:w,mode:"walking",icon:"🚶"}; return {mins:transitMins(km),mode:"transit",icon:"🚊"}; }
function routeKeyJs(o,d,mode){ function f(n){ return Math.round(Number(n)*1e4)/1e4; } return f(o[0])+","+f(o[1])+"|"+f(d[0])+","+f(d[1])+"|"+mode; }
// best travel figure between two coord-bearing things: a cached real route if we have one, else the estimate
function travelBetween(a,b,dmode){
  var o=[num(a.lat),num(a.lng)], d=[num(b.lat),num(b.lng)];
  var km=haversineKm(o[0],o[1],d[0],d[1]);
  var est=travelEst(km,dmode);
  var key=routeKeyJs(o,d,est.mode), real=ROUTES[key];
  return {km:km,mins:real?real.mins:est.mins,mode:est.mode,icon:est.icon,real:!!real,key:key,o:o,d:d};
}

// ---- day travel mode: walk+transit by default, car auto-detected (Maastricht), tap to override ----
var DAYMODE={};
function dayModeFor(date,list){
  var ov=SET["mode."+date]; if(ov){ DAYMODE[date]=ov; return ov; }
  var pts=(list||[]).filter(hasCoord);
  var far=pts.length>0&&pts.every(function(p){ return haversineKm(num(p.lat),num(p.lng),AMS[0],AMS[1])>60; });
  var m=far?"driving":"mixed"; DAYMODE[date]=m; return m;
}
function cycleDayMode(date){
  var cur=DAYMODE[date]||"mixed";
  var nxt=cur==="mixed"?"driving":cur==="driving"?"bike":"mixed";   // walk+transit → car → bike → …
  SET["mode."+date]=nxt; DAYMODE[date]=nxt;
  google.script.run.withFailureHandler(onErr).setSetting("mode."+date,nxt);
  render();
}
function modeChip(date,dmode){ return '<span class="modechip" onclick="cycleDayMode(\''+date+'\')" title="Tap to switch this day\'s travel mode">'+(dmode==="driving"?"🚗 driving":dmode==="bike"?"🚲 bike":"🚶🚊 walk+transit")+'</span>'; }

// ---- how long an event occupies before you can leave (end time, or a sensible dwell) ----
var EVDUR={concert:60,reservation:90,ticket:90,lodging:20,transit:20,flight:0,layover:0,prep:30,general:45};
function departMin(e){
  var s=to24(e.time); if(s==null) return null;
  var en=to24(e.endTime); if(en!=null){ if(en<=s) en+=1440; return en; }
  if(e.place) return s+durFor(e.place);
  return s+(EVDUR[e.kind]!=null?EVDUR[e.kind]:45);
}

// ---- the gap-filler: which saved ideas genuinely fit between two stops ----
var GAPS=[], gapCat="all";
function gapBuffer(){ var b=parseInt(SET["gap.buffer"],10); return isNaN(b)?15:b; }
function gapCandidates(a,b,free,dmode){
  var buffer=gapBuffer();
  var base=haversineKm(num(a.lat),num(a.lng),num(b.lat),num(b.lng));
  var out=[];
  DATA.places.forEach(function(p){
    if(!hasCoord(p)||coveringBooking(p.id)||p.priority==="been") return;
    var tin=travelBetween(a,p,dmode), tout=travelBetween(p,b,dmode);
    var dur=durFor(p), need=tin.mins+dur+tout.mins+buffer;
    if(need>free) return;
    out.push({p:p,tin:tin,tout:tout,dur:dur,need:need,detour:Math.max(0,tin.km+tout.km-base)});
  });
  out.sort(function(x,y){ return ((mutualHeart(y.p)?1:0)-(mutualHeart(x.p)?1:0))||((heartsOf(y.p).length?1:0)-(heartsOf(x.p).length?1:0))||(x.detour-y.detour); });
  return out;
}
function arriveMin(g,c){ return departMin(g.a)+c.tin.mins; }
function openGap(i){ gapCat="all"; renderGap(i); fetchGapRoutes(i); }
function setGapCat(i,c){ gapCat=c; renderGap(i); }
function renderGap(i){
  var g=GAPS[i]; if(!g) return;
  var all=gapCandidates(g.a,g.b,g.free,g.mode);
  var cats={}; all.forEach(function(c){ cats[c.p.category]=1; });
  var cands=all.filter(function(c){ return gapCat==="all"||c.p.category===gapCat; });
  var chips='<div class="filterbar" style="margin-bottom:10px">'+[["all","All"]].concat(Object.keys(cats).map(function(c){return [c,(PCAT[c]||PCAT.other)[0]];})).map(function(c){ return '<button class="chip '+(gapCat===c[0]?"active":"")+'" onclick="setGapCat('+i+',\''+c[0]+'\')">'+c[1]+'</button>'; }).join("")+'</div>';
  var rows=cands.slice(0,8).map(function(c){
    var spare=g.free-c.need;
    var at=fmtT(hhmmStr(Math.round(arriveMin(g,c)/5)*5));
    return '<div class="gaprow"><div class="row"><div style="min-width:0;flex:1"><div class="name" style="font-size:14px">'+(mutualHeart(c.p)?'💞 ':'')+esc(c.p.name)+'</div>'
      +'<div class="tiny">'+(PCAT[c.p.category]||PCAT.other)[0]+(c.p.area?' · '+esc(c.p.area):'')+' · +'+c.detour.toFixed(1)+' km off your path</div></div>'
      +'<div style="flex:none">'+heartBtn(c.p,i)+'</div></div>'
      +'<div class="fitline">'+c.tin.icon+' '+c.tin.mins+'m in · ⏱ ~'+fmtDur(c.dur)+' there · '+c.tout.icon+' '+c.tout.mins+'m out · <span class="spare">fits with '+fmtDur(spare)+' spare</span>'+((c.tin.real&&c.tout.real)?'':' <span class="tiny">(est.)</span>')+'</div>'
      +'<div><span class="linkbtn" onclick="addFromGap('+i+',\''+c.p.id+'\')">➕ Add at '+at+'</span><a class="linkbtn" href="'+mapsLink(c.p)+'" target="_blank" rel="noopener">↗ Maps</a></div></div>';
  }).join("");
  if(!rows) rows='<div class="empty" style="padding:24px 0">Nothing in this category fits the gap — try another, or shorten a duration in ⏱ Typical durations.</div>';
  var m='<div class="modal-bg" onclick="closeModal(event)"><div class="modal" onclick="event.stopPropagation()">'
    +'<h3>⏱ '+fmtDur(g.free)+' free — '+esc(g.a.title)+' → '+esc(g.b.title)+'</h3>'
    +'<div class="body" id="gapbody">'+chips+rows
    +'<div class="tiny" style="margin-top:4px">Ranked by detour off your path · 💞 mutual likes first · top picks refresh with live '+(g.mode==="driving"?"driving":"walking/transit")+' routes.</div></div>'
    +'<div class="foot"><button class="btn" onclick="closeModal()">Close</button></div></div></div>';
  document.getElementById("modalRoot").innerHTML=m;
}
function fetchGapRoutes(i){
  var g=GAPS[i]; if(!g) return;
  var top=gapCandidates(g.a,g.b,g.free,g.mode).slice(0,3), pairs=[];
  top.forEach(function(c){ if(!c.tin.real) pairs.push({o:c.tin.o,d:c.tin.d,mode:c.tin.mode}); if(!c.tout.real) pairs.push({o:c.tout.o,d:c.tout.d,mode:c.tout.mode}); });
  pairs=pairs.slice(0,8);
  if(!pairs.length) return;
  google.script.run.withSuccessHandler(function(res){
    var got=false; for(var k in res){ ROUTES[k]=res[k]; got=true; }
    if(got&&document.getElementById("gapbody")) renderGap(i);
  }).withFailureHandler(function(){}).getRoutes(pairs);
}
function addFromGap(gi,pid){
  var g=GAPS[gi], p=DATA.places.filter(function(x){return x.id===pid;})[0]; if(!g||!p) return;
  var tin=travelBetween(g.a,p,g.mode);
  var t=hhmmStr(Math.round((departMin(g.a)+tin.mins)/5)*5);
  document.getElementById("modalRoot").innerHTML="";
  save("Timeline",{title:p.name,kind:"general",date:g.date,time:t,address:p.address||"",lat:p.lat||"",lng:p.lng||"",details:"💡 From gap ideas · ~"+fmtDur(durFor(p))+" typical"+(p.notes?("\n"+p.notes):"")});
}

// ---- too-tight transitions (conservative: uses end times when present, else start) ----
function badHops(){
  var _l=tlLayer; tlLayer="all";                       // check the whole day, not the filtered view
  var evs=tripEvents().filter(function(e){ return e.date&&to24(e.time)!=null&&hasCoord(e); });
  tlLayer=_l;
  var n=0;
  var byD={}; evs.forEach(function(e){ (byD[e.date]=byD[e.date]||[]).push(e); });
  Object.keys(byD).forEach(function(d){
    var l=byD[d].sort(function(a,b){ return to24(a.time)-to24(b.time); });
    var dm=dayModeFor(d,l);
    for(var i=1;i<l.length;i++){
      var a=l[i-1],b=l[i];
      if(num(a.lat)===num(b.lat)&&num(a.lng)===num(b.lng)) continue;
      var s=to24(a.time),en=to24(a.endTime),leave=(en!=null&&s!=null&&en>s)?en:s;
      var avail=to24(b.time)-leave;
      if(travelBetween(a,b,dm).mins>avail+3) n++;
    }
  });
  return n;
}

// ---- ▶ Now / Up next ----
function nowNext(){
  var now=Date.now(), cur=null, nxt=null;
  var _l=tlLayer; tlLayer="all"; var evs=tripEvents(); tlLayer=_l;
  evs.forEach(function(e){
    if(!e.date||to24(e.time)==null) return;
    var s=evStartMs(e); if(s==null) return;
    var dwell=departMin(e)-to24(e.time); if(!(dwell>0)) dwell=45;
    if(s<=now&&now<s+dwell*60000){ if(!cur||s>evStartMs(cur)) cur=e; }
    else if(s>now){ if(!nxt||s<evStartMs(nxt)) nxt=e; }
  });
  return {cur:cur,nxt:nxt};
}

// ---- weather ribbon (Open-Meteo via the server, cached) ----
var WEATHER=null, wxTried=false;
function loadWeather(){ if(wxTried) return; wxTried=true; google.script.run.withSuccessHandler(function(w){ WEATHER=w; if(TAB==="overview"||TAB==="timeline") render(); }).withFailureHandler(function(){}).getWeather(); }
function wxIcon(c){ c=Number(c); if(c===0)return"☀️"; if(c<=2)return"🌤"; if(c===3)return"☁️"; if(c<=48)return"🌫"; if(c<=67)return"🌧"; if(c<=77)return"🌨"; if(c<=82)return"🌦"; if(c<=86)return"🌨"; return"⛈"; }

// ---- 📧 morning digest toggle (per person) ----
var DIGEST=null, digestTried=false;
function loadDigest(){ if(digestTried) return; digestTried=true; google.script.run.withSuccessHandler(function(v){ DIGEST=!!v; if(TAB==="overview") render(); }).withFailureHandler(function(){}).getDigestStatus(); }
function toggleDigest(){
  if(DIGEST==null) return;
  var turnOn=!DIGEST; setStatus("Saving…");
  google.script.run.withSuccessHandler(function(){ DIGEST=turnOn; setStatus("✓ Saved"); render(); }).withFailureHandler(onErr)[turnOn?"enableDigest":"disableDigest"]();
}

// ---- ⬇ import saved Google Maps places (Takeout CSV) ----
function openImport(){
  var m='<div class="modal-bg" onclick="closeModal(event)"><div class="modal" onclick="event.stopPropagation()"><h3>⬇ Import saved places</h3><div class="body">'
    +'<div class="tiny" style="margin-bottom:10px;line-height:1.55">One-time: at <b>takeout.google.com</b> → Deselect all → tick <b>Saved</b> → Export. Each Maps list arrives as a small CSV file — open one, copy everything, paste below. Re-run anytime with a fresh export: places already in the app are skipped automatically.</div>'
    +'<div class="fldwrap"><label class="fld">List name — guesses the category (e.g. “Amsterdam eats” → Eat)</label><input id="imp_list" type="text" placeholder="Want to go"></div>'
    +'<div class="fldwrap"><label class="fld">Paste the CSV text</label><textarea id="imp_csv" rows="8" placeholder="Title,Note,URL&#10;Foam,,https://www.google.com/maps/place/…"></textarea></div>'
    +'<div class="tiny" id="imp_msg" style="white-space:pre-wrap"></div>'
    +'</div><div class="foot"><button class="btn" onclick="closeModal()">Close</button><button class="btn primary" onclick="doImport()">Import</button></div></div></div>';
  document.getElementById("modalRoot").innerHTML=m;
}
function doImport(){
  var csv=document.getElementById("imp_csv").value, list=document.getElementById("imp_list").value;
  var msgEl=document.getElementById("imp_msg");
  if(!csv.trim()){ msgEl.textContent="Paste the CSV text first."; return; }
  msgEl.textContent="Importing…";
  google.script.run.withSuccessHandler(function(r){
    var msg="✓ "+r.added+" new imported as 💡 "+(PCAT[r.category]||PCAT.other)[0]+" ideas · "+r.skipped+" already in.";
    if(r.nearDupes&&r.nearDupes.length) msg+="\nSkipped near-duplicates (add manually if they're really different): "+r.nearDupes.join("; ");
    if(r.noCoord) msg+="\n"+r.noCoord+" arrived without coordinates — tap 📍 Locate pins on the Map tab to place them.";
    var el=document.getElementById("imp_msg"); if(el) el.textContent=msg;
    setStatus("✓ Imported "+r.added); load();
  }).withFailureHandler(onErr).importSavedPlaces(csv,list);
}

// ---- ⏱ typical durations editor (writes to the Settings tab) ----
function openDurations(){
  var body=Object.keys(PCAT).map(function(c){
    var v=parseInt(SET["dur."+c],10); if(isNaN(v)) v=60;
    return '<div class="half" style="align-items:center;margin-bottom:8px"><div style="flex:2;font-size:14px">'+(PCAT[c][0])+'</div><div><input id="dur_'+c+'" type="number" min="10" step="5" value="'+v+'"></div></div>';
  }).join("");
  body+='<div class="half" style="align-items:center;margin-bottom:8px"><div style="flex:2;font-size:14px">Buffer between stops</div><div><input id="dur_buffer" type="number" min="0" step="5" value="'+gapBuffer()+'"></div></div>';
  body+='<div class="tiny">Minutes per category — every place inherits its category\'s number unless it has its own override. These power the “does it fit” math in gap suggestions.</div>';
  var m='<div class="modal-bg" onclick="closeModal(event)"><div class="modal" onclick="event.stopPropagation()"><h3>⏱ Typical time spent (minutes)</h3><div class="body">'+body+'</div>'
    +'<div class="foot"><button class="btn" onclick="closeModal()">Cancel</button><button class="btn primary" onclick="saveDurations()">Save</button></div></div></div>';
  document.getElementById("modalRoot").innerHTML=m;
}
function saveDurations(){
  var pend=0; function done(){ pend--; if(pend<=0){ setStatus("✓ Saved"); render(); } }
  Object.keys(PCAT).forEach(function(c){
    var v=String(document.getElementById("dur_"+c).value||"").trim();
    if(v&&SET["dur."+c]!==v){ SET["dur."+c]=v; pend++; google.script.run.withSuccessHandler(done).withFailureHandler(onErr).setSetting("dur."+c,v); }
  });
  var b=String(document.getElementById("dur_buffer").value||"").trim();
  if(b&&SET["gap.buffer"]!==b){ SET["gap.buffer"]=b; pend++; google.script.run.withSuccessHandler(done).withFailureHandler(onErr).setSetting("gap.buffer",b); }
  document.getElementById("modalRoot").innerHTML="";
  if(pend) setStatus("Saving…"); else render();
}

// ===== Map tab =====
var MAP=null, MAPLAYER=null, mapDay="all", mapOn={stays:true,shows:true,tickets:true,reservations:true,transit:true,ideas:false};
var DAYCOL=['#94a3b8','#f59e0b','#38bdf8','#a78bfa','#34d399','#fb7185'];
function mapPanel(){
  var coordPlaces=DATA.places, coordStays=DATA.stays;
  var needGeo = coordStays.some(function(o){return o.address&&!hasCoord(o);})
    || coordPlaces.some(function(o){return (o.address||o.area)&&!hasCoord(o);})
    || DATA.artists.some(function(a){return a.venue&&coveringTicket(a.id)&&!hasCoord(a);})
    || DATA.timeline.some(function(t){return t.address&&!hasCoord(t);});
  var anyCoord = coordStays.some(hasCoord) || coordPlaces.some(hasCoord) || DATA.artists.some(hasCoord) || DATA.timeline.some(hasCoord);
  var LYS=[['stays','🏨 Stays','#34d399'],['shows','🎵 Shows','#e879f9'],['tickets','🎫 Tickets','#fb923c'],['reservations','🍽 Reservations','#fb7185'],['transit','🚉 Transit','#a78bfa'],['ideas','💡 Ideas','#94a3b8']];
  var dayBtns=tripDates();
  var dayRow=dayBtns.length?('<div class="dayscrub">'
      +'<button class="dayarrow" data-step="-1" onclick="stepMapDay(-1)" aria-label="Previous day">‹</button>'
      +'<button class="daychip '+(mapDay==="all"?"active":"")+'" data-day="all" onclick="setMapDay(\'all\')">All days</button>'
      +dayBtns.map(function(d){ return '<button class="daychip '+(mapDay===d?"active":"")+'" data-day="'+esc(d)+'" onclick="setMapDay(\''+esc(d)+'\')">'+esc(fmtD(d))+'</button>'; }).join('')
      +'<button class="dayarrow" data-step="1" onclick="stepMapDay(1)" aria-label="Next day">›</button>'
    +'</div>'):'';
  var togRow='<div class="maptoggles">'+LYS.map(function(l){ return '<span id="mtog_'+l[0]+'" class="mtog '+(mapOn[l[0]]?'on':'off')+'" onclick="toggleLayer(\''+l[0]+'\')"><span class="sw" style="background:'+l[2]+'"></span>'+l[1]+'</span>'; }).join('')+'</div>';
  var h='<div class="mapcontrols">'+dayRow+togRow+'</div>';
  h+='<div id="map"></div>';
  h+='<div class="maphint">Numbered pins trace the order you move through a day — tap one for its time, distance and directions. Pick a day above to follow just that day (numbered 1, 2, 3…); “All days” numbers the whole trip in order. 🏨 Stays stay put as anchors; soft 🚉 transit pins are travel stops; faint dashed pins are ideas.</div>';
  if(needGeo||!anyCoord) h+='<div style="margin-bottom:4px"><span class="geobtn" onclick="runGeocode()">📍 '+(anyCoord?'Locate remaining pins':'Locate pins on the map')+'</span> <span class="tiny" id="geomsg" style="margin-left:8px"></span></div>';
  if(!anchorPoint()) h+=isLocal()
    ?'<div class="maphint" style="color:#fcd34d">Set your <b onclick="openLocalSettings()" style="cursor:pointer;text-decoration:underline">home station</b> (⚙ on the Overview tab) — distances, directions and the last-train planner are measured from there.</div>'
    :'<div class="maphint" style="color:#fcd34d">Add where you\'re staying (with an address) on the <b onclick="go(\'stays\')" style="cursor:pointer;text-decoration:underline">Stays</b> tab, then Locate pins — distances and directions are measured from there.</div>';
  return h;
}
function toggleLayer(k){ mapOn[k]=!mapOn[k]; var el=document.getElementById('mtog_'+k); if(el){ el.classList.toggle('on'); el.classList.toggle('off'); } drawMapData(false); }
function setMapDay(d){ mapDay=d; document.querySelectorAll('.dayscrub .daychip').forEach(function(b){ b.classList.toggle('active', b.getAttribute('data-day')===d); }); drawMapData(true); }
function stepMapDay(dir){ var keys=['all']; document.querySelectorAll('.dayscrub .daychip').forEach(function(b){ var k=b.getAttribute('data-day'); if(k!=='all') keys.push(k); }); var i=keys.indexOf(mapDay); if(i===-1) i=0; i=(i+dir+keys.length)%keys.length; setMapDay(keys[i]); }
/* ===== Fullscreen map ===== */
var MAPTOG_HOME=null, MAPTOG_CTL=null;
function isMapFs(){ var el=document.getElementById('map'); return !!(el&&el.classList.contains('fs')); }
function nativeFsEl(){ return document.fullscreenElement||document.webkitFullscreenElement||null; }
function chipsIntoMap(){            // move the day scrubber + layer chips inside the map so they ride along in true fullscreen
  var tog=document.querySelector('.mapcontrols'); if(!tog||!MAP||MAPTOG_CTL) return;
  if(!MAPTOG_HOME){ MAPTOG_HOME=document.createComment('mapcontrols-home'); if(tog.parentNode) tog.parentNode.insertBefore(MAPTOG_HOME,tog); }
  var Ctl=L.Control.extend({ options:{position:'bottomleft'}, onAdd:function(){ tog.classList.add('inmap'); L.DomEvent.disableClickPropagation(tog); L.DomEvent.disableScrollPropagation(tog); return tog; } });
  MAPTOG_CTL=new Ctl(); MAP.addControl(MAPTOG_CTL);
}
function chipsOutOfMap(){           // put the controls back above the map
  var tog=document.querySelector('.mapcontrols');
  if(MAPTOG_CTL&&MAP){ try{MAP.removeControl(MAPTOG_CTL);}catch(e){} MAPTOG_CTL=null; }
  if(tog) tog.classList.remove('inmap');
  if(tog&&MAPTOG_HOME&&MAPTOG_HOME.parentNode){ MAPTOG_HOME.parentNode.insertBefore(tog,MAPTOG_HOME); MAPTOG_HOME.parentNode.removeChild(MAPTOG_HOME); MAPTOG_HOME=null; }
}
function fsBtn(){ var el=document.getElementById('map'); return el?el.querySelector('.mapfsbtn'):null; }
function enterMapFs(){
  var el=document.getElementById('map'); if(!el) return;
  el.classList.add('fs'); document.body.classList.add('mapfs-lock'); chipsIntoMap();
  var b=fsBtn(); if(b) b.innerHTML='\u2715';
  try{ (el.requestFullscreen||el.webkitRequestFullscreen||function(){}).call(el); }catch(e){}   // true browser fullscreen on desktop / Android; iOS falls back to the CSS overlay
  setTimeout(function(){ if(MAP) MAP.invalidateSize(); },80);
}
function exitMapFs(){
  var el=document.getElementById('map'); if(!el) return;
  el.classList.remove('fs'); document.body.classList.remove('mapfs-lock'); chipsOutOfMap();
  var b=fsBtn(); if(b) b.innerHTML='\u2922';
  try{ if(nativeFsEl()) (document.exitFullscreen||document.webkitExitFullscreen).call(document); }catch(e){}
  setTimeout(function(){ if(MAP) MAP.invalidateSize(); },80);
}
function toggleMapFs(){ isMapFs()?exitMapFs():enterMapFs(); }
function onMapFsChange(){ if(!nativeFsEl()&&isMapFs()) exitMapFs(); }   // sync when the user exits native fullscreen via Esc / browser UI
if(!window._mapFsWired){
  window._mapFsWired=true;
  document.addEventListener('fullscreenchange',onMapFsChange);
  document.addEventListener('webkitfullscreenchange',onMapFsChange);
  document.addEventListener('keydown',function(e){ if(e.key==='Escape'&&isMapFs()) exitMapFs(); });
}
function runGeocode(){ var m=document.getElementById('geomsg'); if(m) m.textContent='Locating… this can take a few seconds.'; google.script.run.withSuccessHandler(function(r){ var msg='Located '+r.located+(r.located===1?' spot.':' spots.'); if(r.failed&&r.failed.length) msg+=' Couldn\u2019t place '+r.failed.length+' — check the address.'; setStatus(msg); load(); }).withFailureHandler(function(e){ if(m) m.textContent='⚠ '+((e&&e.message)||'could not locate'); }).geocodeMissing(); }
function initMap(){
  if(typeof L==="undefined"){ setTimeout(initMap,160); return; }
  var el=document.getElementById('map'); if(!el) return;
  if(MAP){ try{MAP.remove();}catch(e){} MAP=null; MAPLAYER=null; }
  MAP=L.map('map',{scrollWheelZoom:true}).setView([52.3676,4.9041],12);
  L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',{maxZoom:20,subdomains:'abcd',attribution:'&copy; OpenStreetMap &copy; CARTO'}).addTo(MAP);
  var FsCtl=L.Control.extend({ options:{position:'topright'}, onAdd:function(){
    var b=L.DomUtil.create('a','leaflet-bar leaflet-control mapfsbtn');
    b.href='#'; b.title='Toggle full screen'; b.innerHTML=isMapFs()?'\u2715':'\u2922';
    L.DomEvent.on(b,'click',function(e){ L.DomEvent.preventDefault(e); L.DomEvent.stopPropagation(e); toggleMapFs(); });
    return b;
  }});
  MAP.addControl(new FsCtl());
  drawMapData(true);
  setTimeout(function(){ if(MAP) MAP.invalidateSize(); },80);
}
function seqIcon(pt){
  var soft=pt.muted?' soft':'';
  var time=pt.timeLabel?('<span class="seqtime">'+esc(pt.timeLabel)+'</span>'):'';
  var html='<div class="seqpin'+soft+'"><span class="seqnum" style="background:'+pt.color+'">'+pt._seq+'</span>'+time+'</div>';
  return L.divIcon({className:'seqwrap',html:html,iconSize:[24,24],iconAnchor:[12,12]});
}
function drawMapData(fit){
  if(!MAP) return;
  if(MAPLAYER){ try{MAP.removeLayer(MAPLAYER);}catch(e){} } MAPLAYER=L.layerGroup().addTo(MAP);
  var all=mapPoints();
  var order={}; tripDates(all).forEach(function(d,i){ order[d]=i; });   // stable color per calendar day
  function dcol(d){ return DAYCOL[(order[d]||0)%DAYCOL.length]; }
  var vis=all.filter(function(p){ if(!mapOn[p.layer]) return false; if(p.anchor) return true; if(mapDay==="all") return true; return p.date===mapDay; });
  var seq=vis.filter(function(p){ return !p.anchor && p.date; }).sort(function(x,y){
    return (x.date<y.date?-1:x.date>y.date?1:0) || ((x.mins==null?9999:x.mins)-(y.mins==null?9999:y.mins)) || String(x.label||'').localeCompare(String(y.label||''));
  });
  seq.forEach(function(p,i){ p._seq=i+1; });          // 1..n within the visible day, or 1..N across the whole trip on "All days"
  for(var i=1;i<seq.length;i++){ if(seq[i].date===seq[i-1].date){     // connect consecutive stops only within the same day
    L.polyline([[seq[i-1].lat,seq[i-1].lng],[seq[i].lat,seq[i].lng]],{color:dcol(seq[i].date),weight:3,opacity:.65,lineCap:'round'}).addTo(MAPLAYER);
  } }
  var bounds=[];
  vis.forEach(function(p){
    var mk;
    if(p._seq) mk=L.marker([p.lat,p.lng],{icon:seqIcon(p)});
    else if(p.anchor) mk=L.circleMarker([p.lat,p.lng],{radius:9,color:p.color,weight:2,opacity:1,fillColor:p.color,fillOpacity:.9});
    else if(p.muted) mk=L.circleMarker([p.lat,p.lng],{radius:6,color:p.color,weight:1.5,opacity:.7,fillColor:p.color,fillOpacity:.45});
    else mk=L.circleMarker([p.lat,p.lng],{radius:p.booked?9:7,color:p.color,weight:p.booked?2:1.5,opacity:p.booked?1:.85,fillColor:p.color,fillOpacity:p.booked?.9:.35,dashArray:p.booked?null:'3'});
    mk.bindPopup(p.popup,{maxWidth:260}); mk.addTo(MAPLAYER); bounds.push([p.lat,p.lng]);
  });
  if(fit&&bounds.length) MAP.fitBounds(bounds,{padding:[40,40],maxZoom:15});
}
function tripDates(pts){ var seen={},out=[]; (pts||mapPoints()).forEach(function(p){ if(!p.anchor&&p.date&&!seen[p.date]){ seen[p.date]=1; out.push(p.date); } }); out.sort(); return out; }
function mapPoints(){
  var pts=[];
  if(isLocal()){ var hp=homePoint(); if(hp) pts.push({lat:hp.lat,lng:hp.lng,layer:'stays',color:'#38bdf8',booked:true,anchor:true,date:null,mins:null,timeLabel:'',label:hp.name,popup:popHead('🏠 '+hp.name,'Home station — distances, directions and the last-train planner measure from here')+popGmap({lat:hp.lat,lng:hp.lng},hp.name)}); }
  DATA.stays.forEach(function(s){ if(hasCoord(s)) pts.push({lat:num(s.lat),lng:num(s.lng),layer:'stays',color:'#34d399',booked:true,anchor:true,date:null,mins:null,timeLabel:'',label:s.name,popup:popStay(s)}); });
  DATA.artists.forEach(function(a){ var tk=coveringTicket(a.id); if(tk&&hasCoord(a)) pts.push({lat:num(a.lat),lng:num(a.lng),layer:'shows',color:'#e879f9',booked:true,date:(a.day?DAYDATE[a.day]:null)||null,mins:tmin(a.startTime),timeLabel:a.startTime?fmtT(a.startTime):'',label:a.name,popup:popShow(a,tk)}); });
  DATA.places.forEach(function(p){ if(!hasCoord(p)) return; var bk=coveringBooking(p.id);
    if(bk){ var isRes=bkType(bk)==="reservation"; pts.push({lat:num(p.lat),lng:num(p.lng),layer:isRes?'reservations':'tickets',color:isRes?'#fb7185':'#fb923c',booked:true,date:bk.eventDate||null,mins:tmin(bk.eventTime),timeLabel:bk.eventTime?fmtT(bk.eventTime):'',label:p.name,popup:popPlace(p,bk)}); }
    else { pts.push({lat:num(p.lat),lng:num(p.lng),layer:'ideas',color:(PCAT[p.category]||PCAT.other)[2],booked:false,date:null,mins:null,timeLabel:'',label:p.name,popup:popPlace(p,null)}); }
  });
  DATA.timeline.forEach(function(t){ if(!hasCoord(t)) return; pts.push({lat:num(t.lat),lng:num(t.lng),layer:'transit',color:'#a78bfa',muted:true,date:t.date||null,mins:tmin(t.time),timeLabel:t.time?fmtT(t.time):'',label:t.title,popup:popStop(t)}); });
  return pts;
}
function popStop(t){
  var k=(t.kind&&KIND[t.kind])?t.kind:inferKind(t); var ki=KIND[k]||KIND.general;
  var when=((ki.icon||'')+' '+ki.label+(t.date?(' · '+esc(fmtD(t.date))):'')+(t.time?(' · '+esc(fmtT(t.time))):'')).trim();
  var h=popHead(t.title,when);
  if(t.address) h+='<div class="pop-row" style="color:#94a3b8;font-size:12px">📍 '+esc(t.address)+'</div>';
  h+=popDir(t);
  h+=popGmap(t,t.address||t.title);
  return h;
}
function popHead(name,when){ return '<div class="pop-name">'+esc(name)+'</div>'+(when?'<div class="pop-when">'+when+'</div>':''); }
function popDir(o){ var s=anchorPoint(); if(!hasCoord(o)||!s||!hasCoord(s)) return ''; if(num(s.lat)===num(o.lat)&&num(s.lng)===num(o.lng)) return ''; var km=kmFromStay(o); var u=dirUrl(num(s.lat),num(s.lng),num(o.lat),num(o.lng),'transit'); return '<div class="pop-row" style="color:#94a3b8;font-size:12px">'+(km!=null?(km.toFixed(km<10?1:0)+' km from '+esc(s.name)+' · '):'')+'<a href="'+u+'" target="_blank" rel="noopener">🧭 Directions</a></div>'; }
function popGmap(o,fallback,label){ var q=hasCoord(o)?(num(o.lat)+','+num(o.lng)):encodeURIComponent((fallback||'')+', Amsterdam'); return '<div class="pop-row"><a href="https://www.google.com/maps/search/?api=1&query='+q+'" target="_blank" rel="noopener">↗ '+(label||'Open in Maps')+'</a></div>'; }
function popStay(s){ var when=s.checkInDate?('🏨 '+esc(fmtD(s.checkInDate))+(s.checkOutDate?' → '+esc(fmtD(s.checkOutDate)):'')):'🏨 Where we\'re staying'; var h=popHead(s.name,when); if(s.address) h+='<div class="pop-row" style="color:#94a3b8;font-size:12px">'+esc(s.address)+'</div>'; h+=popGmap(s,s.address||s.name); return h; }
function popShow(a,tk){ var when='🎵 '+esc(DAYLBL[a.day]||a.day)+(a.startTime?(' · '+esc(fmtT(a.startTime))):''); var h=popHead(a.name,when); if(a.venue) h+='<div class="pop-row" style="color:#94a3b8;font-size:12px">📍 '+esc(a.venue)+'</div>'; if(tk) h+='<div class="pop-row" style="font-size:12px;color:'+(tk.status==="personalize"?"#fcd34d":"#6ee7b7")+'">'+esc(ticketLineText(tk))+'</div>'; h+=popDir(a); var tu=tk?ticketUrl(tk.orderUrl):''; if(tu) h+='<div class="pop-row"><a href="'+esc(tu)+'" target="_blank" rel="noopener">🎫 Open order</a></div>'; h+=popGmap(a,a.venue); return h; }
function popPlace(p,bk){ var when=''; if(bk){ var isRes=bkType(bk)==="reservation"; var dt=bk.eventDate?(' · '+esc(fmtD(bk.eventDate))):''; var tm=bk.eventTime?(' · '+esc(fmtT(bk.eventTime))):''; when=(isRes?'🍽 Reserved':'🎫 Ticket')+dt+tm; }
  var h=popHead(p.name,when);
  h+='<div class="pop-row" style="color:#94a3b8;font-size:12px">'+esc((PCAT[p.category]||PCAT.other)[0])+(p.area?(' · '+esc(p.area)):'')+'</div>';
  if(bk){ var line=[]; if(bk.quantity) line.push((bkType(bk)==="reservation"?'party of ':'')+esc(bk.quantity)); if(bk.orderNumber) line.push('#'+esc(bk.orderNumber)); if(line.length) h+='<div class="pop-row" style="font-size:12px;color:#cbd5e1">'+line.join(' · ')+'</div>'; }
  h+=popDir(p);
  if(bk){ var bu=ticketUrl(bk.orderUrl); if(bu) h+='<div class="pop-row"><a href="'+esc(bu)+'" target="_blank" rel="noopener">'+(bkType(bk)==="reservation"?'🍽 Open reservation':'🎫 Open order')+'</a></div>'; }
  h+=popGmap(p,p.address||p.name);
  return h;
}
// short human line describing a booking's state
function ticketLineText(tk){
  if(bkType(tk)==="reservation"){
    if(tk.status==="confirmed") return "\u2713 Reservation confirmed";
    if(tk.status==="used") return "Reservation done";
    return "\uD83C\uDF7D Reserved";
  }
  var av=ticketAvail(tk);
  if(tk.status==="personalize") return "\u26A0 Personalize to unlock"+(av?(av.future?" \u00B7 available after "+av.label:" \u00B7 available since "+av.label):"");
  if(av&&av.future) return "\u23F3 Tickets available after "+av.label;
  if(tk.status==="ready") return "\uD83C\uDFAB Ticket ready";
  if(tk.status==="used") return "Ticket used";
  if(av) return "\u2713 Available since "+av.label;
  return "\uD83C\uDFAB Ticket saved";
}
// after a ticket is saved or deleted, keep the linked sets' ticketStatus honest
function syncArtistsForTicket(prevIds, newIds, selfId){
  newIds.forEach(function(aid){ var a=DATA.artists.filter(function(x){return x.id===aid;})[0]; if(a&&a.ticketStatus!=="have") save("Artists", Object.assign({}, a, {ticketStatus:"have"})); });
  prevIds.filter(function(id){return newIds.indexOf(id)===-1;}).forEach(function(aid){
    var coveredElse=DATA.tickets.some(function(t){return t.id!==selfId&&splitIds(t.artistIds).indexOf(aid)!==-1;});
    if(!coveredElse){ var a=DATA.artists.filter(function(x){return x.id===aid;})[0]; if(a&&a.ticketStatus==="have") save("Artists", Object.assign({}, a, {ticketStatus:"need"})); }
  });
}
function ticketsPanel(){
  var order={personalize:0,purchased:1,booked:1,ready:2,confirmed:2,used:3};
  var h='<div class="card" style="font-size:12px;color:#94a3b8">Every booking in one place — concert and event <b>tickets</b> plus restaurant and activity <b>reservations</b>. Order links, confirmation numbers and any "available after / personalize" notes live here. Linking a booking to a set or place lights it up across the Timeline, Map and Places.</div>';
  h+='<div class="filterbar">'+[["all","All"]].concat(Object.keys(TSTAT).map(function(s){return [s,TSTAT[s][0]];})).map(function(c){return '<button class="chip '+(ticketFilter===c[0]?"active":"")+'" onclick="setTFilter(\''+c[0]+'\')">'+c[1]+'</button>';}).join("")+'</div>';
  var list=DATA.tickets.filter(function(t){return ticketFilter==="all"||t.status===ticketFilter;}).slice().sort(function(a,b){
    return ((order[a.status]==null?9:order[a.status])-(order[b.status]==null?9:order[b.status])) || String(a.eventDate||"9999").localeCompare(String(b.eventDate||"9999")) || String(a.event||"").localeCompare(String(b.event||""));
  });
  if(!list.length) return h+'<div class="empty">No bookings yet. Tap <b>+</b> to add a ticket or a reservation — pick the type at the top, then link it to the set or place it\'s for.</div>';
  list.forEach(function(t){
    var isRes=bkType(t)==="reservation";
    var st=TSTAT[t.status]||(isRes?TSTAT.booked:TSTAT.purchased), used=t.status==="used", url=ticketUrl(t.orderUrl);
    h+='<div class="card"'+(used?' style="opacity:.6"':'')+'><div class="row"><div style="min-width:0;flex:1"><div class="name">'+(isRes?"🍽 ":"")+esc(t.event)+'</div>';
    var sub=[]; if(t.venue) sub.push("📍 "+esc(t.venue)); if(t.eventDate) sub.push("📅 "+esc(fmtD(t.eventDate))+(t.eventTime?(" · "+esc(fmtT(t.eventTime))):""));
    if(sub.length) h+='<div class="tiny" style="margin-top:2px">'+sub.join("&nbsp;&nbsp;")+'</div>';
    h+='</div><div style="flex:none">'+editDel("Tickets",t.id)+'</div></div>';
    var typeBadge='<span class="badge" style="background:'+KSOFT[isRes?"reservation":"ticket"]+';color:'+KACC[isRes?"reservation":"ticket"]+';border-color:'+KACC[isRes?"reservation":"ticket"]+'">'+BTYPE[isRes?"reservation":"ticket"][0]+'</span>';
    var qtyBadge=t.quantity?'<span class="badge" style="background:rgba(100,116,139,.2);color:#cbd5e1;border-color:rgba(100,116,139,.4)">'+(isRes?("👥 party of "+esc(t.quantity)):("🎟 "+esc(t.quantity)+(parseInt(t.quantity,10)===1?" ticket":" tickets")))+'</span>':'';
    h+='<div style="margin-top:6px">'+typeBadge+badge(st)+(t.holder&&THOLD[t.holder]?badge(THOLD[t.holder]):'')+qtyBadge+'</div>';
    var covers=artistNames(t.artistIds), pcovers=placeNames(t.placeIds);
    if(covers.length) h+='<div class="muted" style="margin-top:8px;cursor:pointer" onclick="go(\'artists\')">🎵 Covers: '+esc(covers.join(", "))+'</div>';
    if(pcovers.length) h+='<div class="muted" style="margin-top:6px;cursor:pointer" onclick="go(\'places\')">📍 '+(isRes?"Table at":"For")+': '+esc(pcovers.join(", "))+'</div>';
    // ticket-only availability messaging (reservations don't unlock/personalize)
    if(!isRes){
      var av=ticketAvail(t);
      if(t.status==="personalize"){
        var msg="⚠ Personalize your tickets to unlock them"+(av?(av.future?" — available after "+av.label:" — available since "+av.label):"");
        h+='<div class="muted" style="margin-top:8px;color:#fcd34d">'+esc(msg)+'.</div>';
      } else if(av&&av.future){ h+='<div class="muted" style="margin-top:8px">⏳ Available after '+esc(av.label)+'</div>'; }
      else if(av){ h+='<div class="muted" style="margin-top:8px">✓ Available since '+esc(av.label)+'</div>'; }
    }
    if(t.orderNumber) h+='<div class="tiny" style="margin-top:6px">'+(isRes?"Confirmation":"Order")+' #'+esc(t.orderNumber)+(t.vendor?" · "+esc(t.vendor):"")+'</div>';
    else if(t.vendor) h+='<div class="tiny" style="margin-top:6px">'+esc(t.vendor)+'</div>';
    if(t.price) h+='<div class="tiny" style="margin-top:2px">'+esc(t.price)+'</div>';
    if(t.notes) h+='<div class="muted" style="margin-top:6px;white-space:pre-wrap">'+linkify(t.notes)+'</div>';
    var bkObj=splitIds(t.placeIds).map(function(id){return DATA.places.filter(function(x){return x.id===id;})[0];}).filter(function(p){return p&&hasCoord(p);})[0];
    if(bkObj) h+=distLine(bkObj);
    if(url) h+='<div><a class="linkbtn" href="'+esc(url)+'" target="_blank" rel="noopener">'+(isRes?"🍽 Open reservation":"🎫 Open order")+' ↗</a></div>';
    h+='</div>';
  });
  return h;
}

function editDel(tab,id){ return '<button class="iconbtn" onclick="openModal(\''+tab+'\',\''+id+'\')">✎</button><button class="iconbtn" onclick="del(\''+tab+'\',\''+id+'\')">🗑</button>'; }

function openModal(tab,id){
  var rec={}; if(id){ rec=DATA[tab.toLowerCase()].filter(function(x){return x.id===id;})[0]||{}; } else { rec=Object.assign({},DEFAULTS[tab]||{}); }
  if(tab==="Costs"&&!id&&!rec.paidBy) rec.paidBy=USER||"";
  modalState={tab:tab,id:id||null};
  var flds=fldsFor(tab);
  var body=flds.map(function(f){
    var k=f[0],lbl=f[1],type=f[2],opts=f[4],val=rec[k]==null?"":rec[k];
    var inp;
    if(type==="select") inp='<select id="f_'+k+'">'+opts.map(function(o){return '<option value="'+o[0]+'"'+(String(val)===o[0]?" selected":"")+'>'+o[1]+'</option>';}).join("")+'</select>';
    else if(type==="textarea") inp='<textarea id="f_'+k+'" rows="2">'+esc(val)+'</textarea>';
    else if(type==="artists") inp=artistPicker(val);
    else if(type==="places") inp=placePicker(val);
    else inp='<input id="f_'+k+'" type="'+type+'" value="'+esc(val)+'">';
    return '<div class="fldwrap"><label class="fld">'+lbl+'</label>'+inp+'</div>';
  });
  // pair up time/date fields side by side where consecutive
  var bodyHtml=body.join("");
  var m='<div class="modal-bg" onclick="closeModal(event)"><div class="modal" onclick="event.stopPropagation()">'
    +'<h3>'+(id?"Edit ":"Add ")+tab.replace(/s$/,"").toLowerCase()+'</h3><div class="body">'+bodyHtml+'</div>'
    +'<div class="foot"><button class="btn" onclick="closeModal()">Cancel</button><button class="btn primary" onclick="saveModal()">Save</button></div></div></div>';
  document.getElementById("modalRoot").innerHTML=m;
}
function closeModal(e){ if(e&&e.target&&!e.target.classList.contains("modal-bg")) return; document.getElementById("modalRoot").innerHTML=""; modalState=null; }
function artistPicker(val){
  var sel=splitIds(val);
  var arts=DATA.artists.slice().sort(function(x,y){ return (DAYIDX[x.day]-DAYIDX[y.day])||((tmin(x.startTime)==null?9999:tmin(x.startTime))-(tmin(y.startTime)==null?9999:tmin(y.startTime)))||String(x.name).localeCompare(y.name); });
  if(!arts.length) return '<div class="tiny" style="padding:10px;border:1px dashed #334155;border-radius:9px">No sets in your Artists tab yet — add them there first, then link them here.</div>';
  return '<div class="artpick">'+arts.map(function(a){
    var meta=DAYLBL[a.day]||a.day; if(a.startTime) meta+=" · "+fmtT(a.startTime);
    return '<label class="artrow"><input type="checkbox" class="artchk" value="'+a.id+'"'+(sel.indexOf(a.id)!==-1?" checked":"")+'><span class="artrow-t"><b>'+esc(a.name)+'</b><span class="tiny"> '+esc(meta)+(a.venue?(" · "+esc(a.venue)):"")+'</span></span></label>';
  }).join("")+'</div>';
}
function placePicker(val){
  var sel=splitIds(val);
  var pls=DATA.places.slice().sort(function(x,y){ return String((PCAT[x.category]||PCAT.other)[0]).localeCompare(String((PCAT[y.category]||PCAT.other)[0]))||String(x.name).localeCompare(y.name); });
  if(!pls.length) return '<div class="tiny" style="padding:10px;border:1px dashed #334155;border-radius:9px">No places in your Places tab yet — add the restaurant / museum there first, then link it here.</div>';
  return '<div class="artpick">'+pls.map(function(p){
    var meta=(PCAT[p.category]||PCAT.other)[0]; if(p.area) meta+=" · "+esc(p.area);
    return '<label class="artrow plcrow"><input type="checkbox" class="plcchk" value="'+p.id+'"'+(sel.indexOf(p.id)!==-1?" checked":"")+'><span class="artrow-t"><b>'+esc(p.name)+'</b><span class="tiny"> '+meta+'</span></span></label>';
  }).join("")+'</div>';
}
// Local mode hides flight logistics: the flight-number field disappears from the
// Timeline form (its slot is the NS-disruptions link on transit cards instead).
function fldsFor(tab){ return FIELDS[tab].filter(function(f){ return !(isLocal()&&tab==="Timeline"&&f[0]==="flightNumber"); }); }
function saveModal(){
  var tab=modalState.tab, flds=fldsFor(tab), rec={};
  if(modalState.id) rec.id=modalState.id;
  var reqMissing=false;
  flds.forEach(function(f){
    var v;
    if(f[2]==="artists") v=Array.prototype.slice.call(document.querySelectorAll(".artchk:checked")).map(function(c){return c.value;}).join(",");
    else if(f[2]==="places") v=Array.prototype.slice.call(document.querySelectorAll(".plcchk:checked")).map(function(c){return c.value;}).join(",");
    else { var el=document.getElementById("f_"+f[0]); v=el?el.value:""; }
    if(f[3]&&!String(v).trim()) reqMissing=true; rec[f[0]]=v;
  });
  if(reqMissing){ alert("Please fill the required field."); return; }
  // preserve done flag on edit (and the hidden flight number in local mode)
  if((tab==="Timeline"||tab==="Todos")&&modalState.id){ var ex=DATA[tab.toLowerCase()].filter(function(x){return x.id===modalState.id;})[0]; if(ex){ rec.done=ex.done; if(tab==="Timeline"&&rec.flightNumber==null) rec.flightNumber=ex.flightNumber; } }
  // preserve fields that don't appear in the form (hearts, import source)
  if(tab==="Places"&&modalState.id){ var exp=DATA.places.filter(function(x){return x.id===modalState.id;})[0]; if(exp){ rec.hearts=exp.hearts; rec.sourceList=exp.sourceList; } }
  // keep linked sets in sync (flip to / from Have ticket)
  if(tab==="Tickets"){
    var prevTk=modalState.id?(DATA.tickets.filter(function(x){return x.id===modalState.id;})[0]||{}):{};
    var prevIds=splitIds(prevTk.artistIds), newIds=splitIds(rec.artistIds);
    document.getElementById("modalRoot").innerHTML=""; modalState=null;
    save(tab, rec);
    syncArtistsForTicket(prevIds, newIds, rec.id||(prevTk&&prevTk.id)||"");
    return;
  }
  document.getElementById("modalRoot").innerHTML=""; modalState=null;
  save(tab, rec);
}

setInterval(function(){ if(TAB==="overview") render(); }, 60000);
load();
</script>
</body>
</html>

That's everything. Paste, deploy, pick your mode — see you at the front. 🎛

JotBird Logo
Published with JotBird