ADE 2026 Planner — Google Sheets + Web App ¶
Update: adds a Map tab, a unified Bookings vault (tickets + reservations), and a booking-aware Timeline. See "What's new in this version" below.
A private web app for you and a partner, hosted by Google at a permanent URL. Data lives in a Google Sheet; the app is the front end. I can update the app code and hand you new data rows anytime — without ever changing your link.
Setup (one time, ~30 min) ¶
- Create the Sheet. Go to sheets.new and rename it
ADE 2026 Planner. - Open the script editor. In that Sheet: Extensions → Apps Script. A new tab opens.
- Add Code.gs. Delete whatever is in the default
Code.gs, then paste in FILE 1 below. - Add Index.html. Click the + next to "Files" → HTML → name it exactly
Index(it becomesIndex.html). Delete its contents and paste in FILE 2 below. - Lock it to the two of you + point it at the Sheet. Near the top of Code.gs, fill in
ALLOWED_EMAILSwith your two Gmail addresses. (Leave it empty only if you want anyone with the link + a Google account to get in.) Also setSHEET_IDto your Sheet's ID — the long string in the Sheet's URL between/d/and/edit. (This is essential if you created the script at script.google.com rather than via Extensions → Apps Script; it's harmless to set either way.) - Build the tabs + seed data. In the editor's toolbar, choose the function
setupfrom the dropdown and click Run. The first run pops an authorization prompt → approve it. ⚠️ You may see a scary "Google hasn't verified this app" screen — that's normal for your own scripts. Click Advanced → Go to ADE 2026 Planner (unsafe) → Allow. This creates the Artists/Timeline/Places/Stays/Todos tabs and fills in your restaurants, activities, and travel scaffold. - Deploy as a web app. Click Deploy → New deployment → ⚙ (Select type) → Web app. Set:
- Execute as: User accessing the web app
- Who has access: Anyone with a Google Account
- Click Deploy, approve again, and copy the
/execURL. That's your permanent app link.
- Share with the second person. Back in the Sheet, click Share and add their Gmail as an Editor. Send them the
/execlink. (They'll approve the same auth prompt on their first visit.)
Bookmark the /exec URL on both your phones (Add to Home Screen) and it behaves like an app.
Updating later (this is the part that fixes your pain) ¶
When I give you new app code or you want changes:
- Paste the new code into the editor.
- Deploy → Manage deployments → ✏️ (pencil) → Version: New version → Deploy.
This updates the app on the same URL — no new link, ever. Only use "New deployment" if you deliberately want a second copy.
Adding data (e.g., a newly announced set): either add it in the app's + button, or paste a row I give you into the matching Sheet tab.
What's new in this version — Map, Bookings & a smarter Timeline ¶
Three things landed: a Map tab, the Tickets vault grew into a Bookings vault (tickets and reservations), and the Timeline now highlights what's booked and mutes what isn't.
What you do: exactly the usual — paste both files, Deploy → Manage deployments → ✏️ → New version → Deploy. Same URL. Then do the one-time Locate pins step below so the map has coordinates.
One-time: Locate pins. Open the new Map tab and tap 📍 Locate pins on the map. It reads the addresses you already have on Places and Stays (and the venues of your ticketed sets) and looks up their coordinates, caching them back into the Sheet so each spot is only looked up once. It uses Apps Script's built-in map lookup — no API key, no billing, nothing to enable — and is safe to re-run anytime you add a place (it skips anything already located). If one address can't be placed, open that Place/Stay and paste a lat/lng by hand (right-click the spot in Google Maps → the first line copies the coordinates); there are now optional Latitude/Longitude fields on the Places and Stays forms for exactly that.
No rebuild. As always, the new columns add themselves on next load — lat/lng on Artists, Places and Stays, and type/placeIds/eventTime on the bookings sheet. Order numbers and times stay text so nothing gets mangled.
A note on the Sheet tab name. Under the hood the bookings sheet is still called Tickets (so none of your existing rows move); the app just labels it Bookings now and adds a Type field — 🎫 Ticket or 🍽 Reservation — to tell the two apart.
The Map tab ¶
A real map of your trip with five toggle layers, each a colored pin:
- 🏨 Stays, 🎵 Shows (your ticketed sets, at their venue), 🎫 Tickets (ticketed museums/activities), 🍽 Reservations (restaurants you've booked) — all on by default.
- 💡 Ideas — places you've saved but not booked yet. Hidden by default; tap the chip to reveal them (faint dashed pins, colored by category) when you want to weigh up options near where you'll already be.
Tap any pin for its date and time, the distance from where you're staying, and a 🧭 Directions link that opens a live transit route in Google Maps. Distances and directions are measured from the first Stay that has an address, so fill in your hotel on the Stays tab and Locate pins to switch them on.
Bookings (was Tickets) — now holds reservations too ¶
This answers the "where do reservations go?" question: into the vault, alongside tickets — not as timeline-only notes. The reason is that a reservation is the same kind of thing as a ticket (a held booking with a date, time, party size, confirmation number and a link), and keeping both in one place means each one can light up the set or place it's attached to across the whole app. A timeline-only note couldn't do that.
When you add a booking you now pick a Type first. Reservations skip the ticket-only "personalize / unlocks-on" machinery and instead use Booked → Confirmed → Done, with party size in place of ticket quantity. Link a booking to a place (museum, restaurant, activity) the same way you already link sets — there's a "Which place is this for?" checklist next to the sets one — and that place now shows a highlighted 🍽 Reserved · Fri, Oct 23 · 8:30 PM (or 🎫 Ticket) line on its card, a pin in the right map layer, and a dated, highlighted stop on the Timeline.
Timeline: booked things shout, un-ticketed sets whisper ¶
The Timeline/Calendar now keys loudness off whether something is actually booked:
- A set with a ticket is loud; a set you're still considering (no ticket yet) is muted but still listed, so it doesn't compete with the things that are locked in.
- Reservations and ticketed entries drop onto the schedule on their date/time, highlighted, with an Open reservation / Open order link. (Concert tickets don't double up — their set already appears.)
- Between consecutive located stops on a day, a small 🧭 x km to next — directions hop appears, so "how do we get from this show to the next one" is one tap away.
Flight status links (built in) ¶
Each timeline item now has an optional Flight number field. Type a flight number (e.g. KL602, DL134) and that card shows a ✈ See flight status ↗ link. It opens the live page for exactly that flight on Flightradar24 — free, no login, works on a phone — showing status, delays, gate, and actual times. Use it for your outbound, layover, and return flights.
(If a particular flight ever doesn't resolve there, a Google search for "<flight number> flight status" also shows a status card with no login.)
Already ran setup before? No rebuild needed — paste the new code, redeploy, and the app adds the new flightNumber column to your Timeline tab automatically the next time it loads.
Timeline & Calendar views (new) ¶
The Timeline tab now has a toggle at the top: 📋 Timeline and 🗓 Calendar, plus a layer filter (All · Travel · Sets).
Time-proportional, not evenly spaced. Both views space things by when they actually happen instead of giving every item the same gap. In the Timeline view, two things an hour apart sit close together while a multi-day gap opens up real vertical space (with a +5h 45m / +2d label and a faint "— nothing planned —" row for empty days). In the Calendar view, each day is a column on a real time axis (6 AM → 5 AM), so same-day clustering and late-night runs are visible at a glance. Overlapping sets split into side-by-side lanes.
Importance is built in. Every item has a Type that controls how loud it looks:
- ✈ Flight and 🎵 Set / Concert — boldest (bright accent bar, larger title, subtle glow). Must-see sets get a ★.
- 🏨 Stay, 🚆 Transit, • Other — normal.
- ⏱ Layover and ✅ Prep — quieted (dimmer, smaller) so they don't compete with the flights. "Maybe" sets are quieted too.
You don't have to label anything: leave Type on Auto-detect and the app guesses from the title/flight number (a layover reads as a layover, a flight as a flight). Set it explicitly anytime you want to override — e.g. to promote a particular set.
Your sets show up here too. The view folds your Artists sets onto the same timeline/calendar (on their ADE day + start time, with venue and ticket status), so you see flights, check-ins, and the sets you're racing between in one place. Flip the layer filter to Travel for pure logistics or Sets for just the music. Tap any set to edit it (it still lives in the Artists tab). Late-night sets (after midnight) appear at the bottom of their date's column.
Already ran setup before? Same deal — paste the new code, redeploy, and the new kind column is added to your Timeline tab automatically on next load. Nothing to rebuild.
Bookings vault (originally "Tickets") ¶
(Background on how the vault and set-linking work — now extended to reservations, see the section above.)
A new Tickets tab keeps every purchase in one place — the order link, order number, seller, price, who it's for, and any "available after / personalize" note straight from the ticketing site.
For each order you can store: what it's for, a Status (Purchased → ⚠ Personalize → Ready → Used), the order link (a big 🎫 Open order ↗ button on the card), the order number, seller/platform, who holds it, quantity, price, an available/unlocks-on date, the event date, and a free notes box (paste text like "Tickets will be available after 21-10-2026, once you've personalized them" verbatim).
It does a little thinking for you:
- Set status to ⚠ Personalize and the card shows a clear "personalize to unlock" line. If you also set the unlock date, it reads "available after 21 Oct."
- The Overview screen surfaces a tap-through banner — "1 ticket order still needs personalizing" — so it doesn't slip past you, plus a running ticket orders count.
- An unlock date in the future shows ⏳ Available after …; once it passes it flips to ✓ Available since ….
- Filter chips at the top let you jump to just the orders that need personalizing, or the ones that are ready.
The tab self-creates on next load (it's in TABS), so there's nothing to rebuild — paste, redeploy, done. Order numbers are stored as text so long numbers never get mangled into scientific notation.
For your example, you'd add one order: event = the night/pass it's for, status = ⚠ Personalize, paste the order link, drop in the order number, set available to 2026-10-21, and paste that availability sentence into notes. It'll show the Personalize warning on both the Tickets card and the Overview until you switch it to Ready.
It's wired to your sets ¶
When you add or edit an order, there's a "Which sets does this cover?" checklist of everything in your Artists tab. Tick the sets this order gets you in, and the whole app stays in sync:
- Those sets immediately read Have ticket on the Artists tab (no more manually flipping each one), with the order's status right underneath — e.g. "⚠ Personalize to unlock · available after Wed, Oct 21" — plus 🎫 Open order ↗ and 🔗 Ticket details buttons on the set itself.
- The same status line and order link show on each set in the Timeline and Calendar.
- Your Timeline/Calendar also gets a dated 🎫 milestone for each order that still needs action — "Personalize & download…" on the unlock date — so "when do my tickets become available / need personalizing" is right there on the trip schedule. (Orders that already unlocked and are just Ready don't clutter the timeline; they only show on their sets.)
- The Tickets card lists which sets it Covers, and Overview's "tickets needed" count automatically ignores any set an order already covers.
Unticking a set (or deleting the order) flips it back to Need ticket — unless another order still covers it. One order can cover many sets (a night, a day pass, a full-week pass), and a set can be covered by an order even if you bought it as part of a bundle.
FILE 1 — Code.gs ¶
// ===== 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",
// "second-person@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"],
Places: ["id","name","category","area","address","priority","notes","lat","lng"],
Stays: ["id","name","address","checkInDate","checkInTime","checkOutDate","checkOutTime","confirmation","notes","lat","lng"],
Todos: ["id","text","category","done"],
// "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"]
};
const TEXT_COLS = {
Artists: ["startTime","endTime"],
Timeline: ["date","time"],
Stays: ["checkInDate","checkInTime","checkOutDate","checkOutTime"],
Tickets: ["orderNumber","availableDate","eventDate","eventTime"]
};
// 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");
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" };
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");
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();
var label = nameC !== -1 ? String(row[nameC] || "").trim() : base;
if (!base) continue;
scanned++;
var query = base + ", Amsterdam, Netherlands";
try {
var res = geocoder.geocode(query);
if (res && res.status === "OK" && res.results && res.results.length) {
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 {
failed.push(label || base);
}
} catch (err) {
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 };
}
// 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);
if (readTab_("Places").length === 0) SEED_PLACES.forEach(function(p){ saveRecord("Places", p); });
if (readTab_("Timeline").length === 0) 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:"Pet / house sitter arrives (if needed)",date:"",time:"",kind:"prep",details:"Confirm arrival, key/lockbox, any care schedule, emergency 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. Sitter handoff if needed.",done:false}
];
FILE 2 — Index.html ¶
<!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; }
.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; }
</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:[]};
var TAB="overview", dayFilter="all", placeCat="all", expanded=null, modalState=null;
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)"],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"};
// 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)"};
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"],["details","Details","textarea"]],
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"],["notes","Notes","textarea"],["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]];})]],
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"}};
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();
return (((h+11)%12)+1)+":"+m+" "+(h>=12?"PM":"AM");
}
function fmtD(d){ if(!d) return ""; var dt=new Date(d+"T00:00:00"); if(isNaN(dt)) return d; return dt.toLocaleDateString(undefined,{weekday:"short",month:"short",day:"numeric"}); }
function mapsLink(p){ 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(undefined,{weekday:"short"}):d; }
function dmName(d){ var dt=dParts(d); return dt?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});
});
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});
}
});
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||[]}; render(); }).withFailureHandler(onErr).getAllData(); }
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 = 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"]];
var 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"}[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();
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="";
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>';
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>';
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>'; }
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(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(){
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">'+["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];
h+='<div class="dayhdr"><span class="dow">'+esc(dowName(d))+'</span><span>'+esc(dmName(d))+'</span><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
if(prevEv&&hasCoord(prevEv)&&hasCoord(e)&&!(num(prevEv.lat)===num(e.lat)&&num(prevEv.lng)===num(e.lng))){
var hu=dirUrl(num(prevEv.lat),num(prevEv.lng),num(e.lat),num(e.lng),"transit");
var hk=haversineKm(num(prevEv.lat),num(prevEv.lng),num(e.lat),num(e.lng));
h+='<div class="hop"><a href="'+hu+'" target="_blank" rel="noopener">🧭 '+hk.toFixed(hk<10?1:0)+' km to next — directions</a></div>';
}
h+=evCardHtml(e,space);
if(mins!=null) prev=mins;
if(hasCoord(e)) prevEv=e;
});
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(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(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.</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>'+editDel("Places",p.id)+'</div></div>';
h+='<div style="margin-top:4px">'+badge(PCAT[p.category]||PCAT.other)+badge(PPRI[p.priority]||PPRI.want)+'</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 '<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=refStay(); 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=refStay(); 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>':''; }
// ===== Map tab =====
var MAP=null, MAPGROUPS={}, mapOn={stays:true,shows:true,tickets:true,reservations:true,ideas:false};
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);});
var anyCoord = coordStays.some(hasCoord) || coordPlaces.some(hasCoord) || DATA.artists.some(hasCoord);
var LYS=[['stays','🏨 Stays','#34d399'],['shows','🎵 Shows','#e879f9'],['tickets','🎫 Tickets','#fb923c'],['reservations','🍽 Reservations','#fb7185'],['ideas','💡 Ideas','#94a3b8']];
var h='<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>';
h+='<div id="map"></div>';
h+='<div class="maphint">Tap a pin for the date, time, distance and directions. Solid pins are booked; faint dashed pins are ideas — toggle 💡 Ideas to weigh them up.</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(!refStay()) h+='<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'); } if(MAP&&MAPGROUPS[k]){ mapOn[k]?MAP.addLayer(MAPGROUPS[k]):MAP.removeLayer(MAPGROUPS[k]); } }
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; }
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);
MAPGROUPS={stays:L.layerGroup(),shows:L.layerGroup(),tickets:L.layerGroup(),reservations:L.layerGroup(),ideas:L.layerGroup()};
var bounds=[];
mapPoints().forEach(function(pt){
var mk=L.circleMarker([pt.lat,pt.lng],{radius:pt.booked?9:7,color:pt.color,weight:pt.booked?2:1.5,opacity:pt.booked?1:.85,fillColor:pt.color,fillOpacity:pt.booked?.9:.35,dashArray:pt.booked?null:'3'});
mk.bindPopup(pt.popup,{maxWidth:260});
mk.addTo(MAPGROUPS[pt.layer]); bounds.push([pt.lat,pt.lng]);
});
Object.keys(MAPGROUPS).forEach(function(k){ if(mapOn[k]) MAPGROUPS[k].addTo(MAP); });
if(bounds.length) MAP.fitBounds(bounds,{padding:[40,40],maxZoom:15});
setTimeout(function(){ if(MAP) MAP.invalidateSize(); },80);
}
function mapPoints(){
var pts=[];
DATA.stays.forEach(function(s){ if(hasCoord(s)) pts.push({lat:num(s.lat),lng:num(s.lng),layer:'stays',color:'#34d399',booked:true,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,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,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,popup:popPlace(p,null)}); }
});
return pts;
}
function popHead(name,when){ return '<div class="pop-name">'+esc(name)+'</div>'+(when?'<div class="pop-when">'+when+'</div>':''); }
function popDir(o){ var s=refStay(); 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]||{}); }
modalState={tab:tab,id:id||null};
var flds=FIELDS[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>';
}
function saveModal(){
var tab=modalState.tab, flds=FIELDS[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
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; }
// 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>
Migrating your current planner data ¶
If you'd added artists, stays, or to-dos in the old artifact, open its Backup → Copy, paste that JSON to me in chat, and I'll convert it into ready-to-paste Sheet rows so nothing is lost.