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) ¶
- Make a Sheet — go to sheets.new, name it anything (e.g. ADE 2026).
- Open Apps Script — in the Sheet: Extensions → Apps Script.
- Paste both files:
- Select everything in the default
Code.gsand paste over it with 📄 File 1: Code.gs (below). - Click + → HTML, name it exactly
Indexand 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).
- Select everything in the default
- Deploy — Deploy → New deployment → ⚙ Web app → Execute 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.
- 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.
- Open script.google.com → your ADE project.
- Replace both files' contents with the new versions below (select all → paste over
Code.gs, then overIndex). - Deploy → Manage deployments → ✏️ → Version: New version → Deploy. Same link as before.
- Re-open the app and approve the permission prompt if asked (same free Google routing service — new scope).
- 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, "&").replace(/</g, "<").replace(/>/g, ">"); }
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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,"""); }
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 & 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 & 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 & 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 & 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 & 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(" ")+'</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?' <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>'
+' · <a href="'+URL_9292+'" target="_blank" rel="noopener" style="color:#7dd3fc">9292</a>'
+' · <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"> </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?' <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?' ':'')+'<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 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:'© OpenStreetMap © 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(" ")+'</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. 🎛