// Renders TRIP_DATA in two views that share one state, so nothing is ever picked twice: // // - List view: every day as its own card, in date order, plus the undated // "anytime" collections (culture, dinner, evening, daytime) shown once each below. // - Calendar view: one day at a time, with a clickable month-grid to jump around and // see at a glance which dates already have something planned. // // State: // state.dayPicks { [date]: optionId } — the one "main evening" choice per day. // optionId may belong to that day's own // options, or to a TRIP_DATA.spanning show // valid on that date. // state.collectionPicks{ [collectionId]: [ids] } — checkbox picks for undated categories. // state.itemDates { [optionId]: date } — optional "which day did I schedule this // anytime thing for" tag, set when a // collection item is ticked from the // calendar view's per-day panel. // state.view 'list' | 'calendar' — client-only, not persisted. // state.currentDate date — calendar view's currently displayed day. const state = { dayPicks: {}, collectionPicks: {}, itemDates: {}, view: "calendar", currentDate: TRIP_START }; const DATE_KEY_RE = /^\d{4}-\d{2}-\d{2}$/; const WEEKDAY_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; const MONTH_LABELS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; function el(tag, props = {}, children = []) { const node = document.createElement(tag); Object.entries(props).forEach(([k, v]) => { if (k === "class") node.className = v; else if (k === "text") node.textContent = v; else node.setAttribute(k, v); }); children.forEach((c) => c && node.appendChild(c)); return node; } // --- date helpers ----------------------------------------------------------- function parseDate(dateStr) { const [y, m, d] = dateStr.split("-").map(Number); return new Date(y, m - 1, d); } function formatDate(date) { const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, "0"); const d = String(date.getDate()).padStart(2, "0"); return `${y}-${m}-${d}`; } function addDays(dateStr, n) { const d = parseDate(dateStr); d.setDate(d.getDate() + n); return formatDate(d); } function dayLabel(dateStr) { const d = parseDate(dateStr); return `${WEEKDAY_LABELS[(d.getDay() + 6) % 7].slice(0, 3)} ${d.getDate()} ${MONTH_LABELS[d.getMonth()]}`; } function isBeforeOrEqual(a, b) { return a <= b; } function inRange(dateStr, range) { return range ? isBeforeOrEqual(range[0], dateStr) && isBeforeOrEqual(dateStr, range[1]) : true; } // --- data lookups ------------------------------------------------------------ function getDayObject(dateStr) { const found = TRIP_DATA.days.find((d) => d.date === dateStr); if (found) return found; return { date: dateStr, label: dayLabel(dateStr), options: [] }; } function getSpanningForDate(dateStr) { return TRIP_DATA.spanning.filter((s) => s.dates.includes(dateStr)); } function findOptionMeta(optionId) { for (const day of TRIP_DATA.days) { const opt = day.options.find((o) => o.id === optionId); if (opt) return { label: opt.title, when: day.label }; } for (const span of TRIP_DATA.spanning) { if (span.id === optionId) return { label: span.title, when: "multi-night show" }; } for (const col of TRIP_DATA.collections) { const opt = col.options.find((o) => o.id === optionId); if (opt) return { label: opt.title, when: col.title }; } return null; } // --- persistence --------------------------------------------------------- function setStatus(msg, isError) { const s = document.getElementById("status"); s.textContent = msg; s.style.color = isError ? "#b91c1c" : "var(--accent)"; if (msg) setTimeout(() => { if (s.textContent === msg) s.textContent = ""; }, 2500); } async function loadPicks() { let raw = {}; try { const res = await fetch("/api/selections"); if (res.ok) raw = (await res.json()).picks || {}; } catch (e) { console.warn("Could not load saved picks, starting fresh.", e); } for (const [key, value] of Object.entries(raw)) { if (key === "__itemDates__") state.itemDates = value || {}; else if (DATE_KEY_RE.test(key)) state.dayPicks[key] = value; else state.collectionPicks[key] = value || []; } // Apply day defaults (e.g. Clàssica a la Platja) where nothing's been explicitly picked yet. // In-memory only — not written back to KV until the visitor actually changes something. TRIP_DATA.days.forEach((day) => { if (state.dayPicks[day.date] !== undefined) return; const def = day.options.find((o) => o.default); if (def) state.dayPicks[day.date] = def.id; }); } async function savePick(groupId, value) { try { const res = await fetch("/api/selections", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ groupId, value }) }); if (!res.ok) throw new Error("save failed"); setStatus("Saved ✓"); } catch (e) { setStatus("Could not save — check connection", true); } } function saveItemDates() { savePick("__itemDates__", state.itemDates); } // --- shared bits ------------------------------------------------------------ function infoIcon(item) { if (!item.url) return null; const link = el("a", { href: item.url, target: "_blank", rel: "noopener noreferrer", class: "info-icon", "aria-label": `More info: ${item.title}`, title: "More info" }); link.textContent = "ⓘ"; return link; } function statusRow(text) { return el("div", { class: "option-row" }, [ el("div", { class: "option status-row" }, [ el("span", { class: "badge", text: "✓" }), el("span", { class: "option-body" }, [el("div", { class: "option-title muted", text })]) ]) ]); } // One day's "main evening" radio group — day-specific options plus any spanning // shows valid on this date. Shared by list view and calendar view so the exact // same options, same disabled/already-picked logic, render in both places. function renderDayCard(dateStr) { const day = getDayObject(dateStr); const spanningHere = getSpanningForDate(dateStr); if (day.locked && day.options.length === 0 && spanningHere.length === 0) { return el("section", { class: "day" }, [ el("div", { class: "day-label", text: day.label }), el("div", { class: "locked-card" }, [ el("span", { class: "badge", text: "✅" }), el("span", { class: "locked-body" }, [ el("div", { class: "option-title", text: day.locked.title }), el("div", { class: "option-meta", text: day.locked.meta }) ]), infoIcon(day.locked) ]) ]); } const rows = day.options.map((opt) => { const input = el("input", { type: "radio", name: `day-${dateStr}`, value: opt.id }); input.checked = state.dayPicks[dateStr] === opt.id; input.addEventListener("change", () => { state.dayPicks[dateStr] = opt.id; savePick(dateStr, opt.id); rerender(); }); const label = el("label", { class: "option" }, [ input, el("span", { class: "option-body" }, [ el("div", { class: "option-title", text: opt.title }), opt.meta ? el("div", { class: "option-meta", text: opt.meta }) : null ]) ]); return el("div", { class: "option-row" }, [label, infoIcon(opt)]); }); spanningHere.forEach((span) => { const otherDate = span.dates.find((d) => d !== dateStr && state.dayPicks[d] === span.id); if (otherDate) { rows.push(statusRow(`${span.title} — already picked for ${dayLabel(otherDate)}`)); return; } const input = el("input", { type: "radio", name: `day-${dateStr}`, value: span.id }); input.checked = state.dayPicks[dateStr] === span.id; input.addEventListener("change", () => { state.dayPicks[dateStr] = span.id; savePick(dateStr, span.id); rerender(); }); const label = el("label", { class: "option" }, [ input, el("span", { class: "option-body" }, [ el("div", { class: "option-title", text: span.title }), span.meta ? el("div", { class: "option-meta", text: span.meta }) : null ]) ]); rows.push(el("div", { class: "option-row" }, [label, infoIcon(span)])); }); const clearInput = el("input", { type: "radio", name: `day-${dateStr}`, value: "" }); clearInput.checked = !state.dayPicks[dateStr]; clearInput.addEventListener("change", () => { delete state.dayPicks[dateStr]; savePick(dateStr, ""); rerender(); }); const clearLabel = el("label", { class: "option" }, [ clearInput, el("span", { class: "option-body" }, [el("div", { class: "option-title muted", text: "Nothing / free evening" })]) ]); const children = [el("div", { class: "day-label", text: day.label })]; if (day.note) children.push(el("p", { class: "note", text: day.note })); children.push(...rows, el("div", { class: "option-row" }, [clearLabel])); return el("section", { class: "day" }, children); } // A collection rendered as plain checkboxes (list view — no per-day exclusivity, // just an optional "→ scheduled for X" tag if it's been assigned a date via the // calendar view). function renderCollectionList(col) { const options = col.options.map((opt) => { const picked = (state.collectionPicks[col.id] || []).includes(opt.id); const input = el("input", { type: "checkbox", value: opt.id }); input.checked = picked; input.addEventListener("change", () => { const current = new Set(state.collectionPicks[col.id] || []); if (input.checked) current.add(opt.id); else { current.delete(opt.id); delete state.itemDates[opt.id]; saveItemDates(); } state.collectionPicks[col.id] = Array.from(current); savePick(col.id, state.collectionPicks[col.id]); rerender(); }); const tag = picked && state.itemDates[opt.id] ? el("span", { class: "date-tag", text: `→ ${dayLabel(state.itemDates[opt.id])}` }) : null; const label = el("label", { class: "option" }, [ input, el("span", { class: "option-body" }, [ el("div", { class: "option-title" }, [document.createTextNode(opt.title + " "), tag].filter(Boolean)), opt.meta ? el("div", { class: "option-meta", text: opt.meta }) : null ]) ]); return el("div", { class: "option-row" }, [label, infoIcon(opt)]); }); return el("section", { class: "collection" }, [el("div", { class: "collection-title", text: col.title }), ...options]); } // The same collection, but rendered for one specific calendar day: items already // scheduled on a *different* day show as a disabled status row instead of a live // checkbox — this is the "show everywhere, flag if already used elsewhere" rule. function renderCollectionForDate(col, dateStr) { const options = col.options.map((opt) => { const picked = (state.collectionPicks[col.id] || []).includes(opt.id); const assignedDate = state.itemDates[opt.id]; if (picked && assignedDate && assignedDate !== dateStr) { return statusRow(`${opt.title} — already picked for ${dayLabel(assignedDate)}`); } const input = el("input", { type: "checkbox", value: opt.id }); input.checked = picked; input.addEventListener("change", () => { const current = new Set(state.collectionPicks[col.id] || []); if (input.checked) { current.add(opt.id); state.itemDates[opt.id] = dateStr; } else { current.delete(opt.id); delete state.itemDates[opt.id]; } state.collectionPicks[col.id] = Array.from(current); savePick(col.id, state.collectionPicks[col.id]); saveItemDates(); rerender(); }); const label = el("label", { class: "option" }, [ input, el("span", { class: "option-body" }, [ el("div", { class: "option-title", text: opt.title }), opt.meta ? el("div", { class: "option-meta", text: opt.meta }) : null ]) ]); return el("div", { class: "option-row" }, [label, infoIcon(opt)]); }); return el("section", { class: "collection" }, [el("div", { class: "collection-title", text: col.title }), ...options]); } function isDayFilled(dateStr) { const day = getDayObject(dateStr); if (day.locked) return true; if (state.dayPicks[dateStr]) return true; return Object.values(state.itemDates).includes(dateStr); } // --- shortlist (list view only) --------------------------------------------- function renderShortlist() { const list = document.getElementById("shortlist-list"); const empty = document.getElementById("shortlist-empty"); list.innerHTML = ""; const entries = []; TRIP_DATA.days.forEach((day) => { if (day.locked) entries.push({ label: `${day.locked.title} (confirmed)`, when: day.label, sortKey: day.date }); }); for (const [date, optionId] of Object.entries(state.dayPicks)) { if (!optionId) continue; const meta = findOptionMeta(optionId); if (meta) entries.push({ label: meta.label, when: dayLabel(date), sortKey: date }); } for (const [colId, ids] of Object.entries(state.collectionPicks)) { (ids || []).forEach((optionId) => { const meta = findOptionMeta(optionId); if (!meta) return; const assignedDate = state.itemDates[optionId]; entries.push({ label: meta.label, when: assignedDate ? dayLabel(assignedDate) : meta.when, sortKey: assignedDate || null }); }); } // Chronological order — anything without a specific date (an anytime pick made // from list view, never tied to a day) sorts after all the dated entries. entries.sort((a, b) => { if (a.sortKey && b.sortKey) return a.sortKey.localeCompare(b.sortKey); if (a.sortKey) return -1; if (b.sortKey) return 1; return 0; }); if (entries.length === 0) { empty.style.display = "block"; return; } empty.style.display = "none"; entries.forEach(({ label, when }) => { list.appendChild(el("li", {}, [el("span", { class: "when", text: when }), el("span", { text: label })])); }); } // --- list view --------------------------------------------------------------- function renderListView(container) { const timelineCollections = TRIP_DATA.collections.filter((c) => c.dateRange); const topicCollections = TRIP_DATA.collections.filter((c) => !c.dateRange); const timeline = [ ...TRIP_DATA.days.map((day) => ({ sortDate: day.date, render: () => renderDayCard(day.date) })), ...timelineCollections.map((col) => ({ sortDate: col.dateRange[0], render: () => renderCollectionList(col) })) ].sort((a, b) => a.sortDate.localeCompare(b.sortDate)); timeline.forEach((entry) => container.appendChild(entry.render())); topicCollections.forEach((col) => container.appendChild(renderCollectionList(col))); } // --- calendar view ----------------------------------------------------------- function buildCalendarWeeks() { const startWeekday = (parseDate(TRIP_START).getDay() + 6) % 7; // Monday = 0 const endWeekday = (parseDate(TRIP_END).getDay() + 6) % 7; const gridStart = addDays(TRIP_START, -startWeekday); const gridEnd = addDays(TRIP_END, 6 - endWeekday); const weeks = []; let cursor = gridStart; let week = []; while (cursor <= gridEnd) { week.push(cursor); if (week.length === 7) { weeks.push(week); week = []; } cursor = addDays(cursor, 1); } return weeks; } function renderCalendarGrid() { const grid = el("div", { class: "cal-grid" }); WEEKDAY_LABELS.forEach((w) => grid.appendChild(el("div", { class: "cal-weekday", text: w }))); buildCalendarWeeks().forEach((week) => { week.forEach((dateStr) => { const inTrip = dateStr >= TRIP_START && dateStr <= TRIP_END; const cellProps = { class: "cal-cell" + (inTrip ? "" : " cal-cell-outside") }; const cell = el("div", cellProps); if (inTrip) { const dayNum = String(parseInt(dateStr.slice(8), 10)); cell.appendChild(el("span", { class: "cal-daynum", text: dayNum })); cell.appendChild(el("span", { class: `cal-dot ${isDayFilled(dateStr) ? "cal-dot-filled" : "cal-dot-empty"}` })); if (dateStr === state.currentDate) cell.classList.add("cal-cell-current"); cell.addEventListener("click", () => { state.currentDate = dateStr; rerender(); }); } grid.appendChild(cell); }); }); return grid; } function renderCalendarView(container) { const nav = el("div", { class: "cal-nav" }); const prevBtn = el("button", { class: "cal-nav-btn", text: "‹ Prev" }); const nextBtn = el("button", { class: "cal-nav-btn", text: "Next ›" }); prevBtn.disabled = state.currentDate <= TRIP_START; nextBtn.disabled = state.currentDate >= TRIP_END; prevBtn.addEventListener("click", () => { state.currentDate = addDays(state.currentDate, -1); rerender(); }); nextBtn.addEventListener("click", () => { state.currentDate = addDays(state.currentDate, 1); rerender(); }); nav.appendChild(prevBtn); nav.appendChild(el("div", { class: "cal-current-label", text: dayLabel(state.currentDate) })); nav.appendChild(nextBtn); container.appendChild(nav); container.appendChild(renderCalendarGrid()); container.appendChild(renderDayCard(state.currentDate)); const applicable = TRIP_DATA.collections.filter((c) => inRange(state.currentDate, c.dateRange)); if (applicable.length) { container.appendChild(el("div", { class: "cal-anytime-heading", text: "Also available today" })); applicable.forEach((col) => container.appendChild(renderCollectionForDate(col, state.currentDate))); } } // --- top-level render -------------------------------------------------------- function renderViewToggle() { const toggle = document.getElementById("view-toggle"); toggle.innerHTML = ""; [{ id: "calendar", icon: "📅", label: "Calendar" }, { id: "list", icon: "📋", label: "List" }].forEach(({ id, icon, label }) => { const btn = el("button", { class: "view-btn" + (state.view === id ? " view-btn-active" : "") }); btn.append(icon + " " + label); btn.addEventListener("click", () => { state.view = id; rerender(); }); toggle.appendChild(btn); }); } function rerender() { document.getElementById("title").textContent = TRIP_DATA.title; document.getElementById("page-title").textContent = TRIP_DATA.title; document.getElementById("subtitle").textContent = TRIP_DATA.subtitle; renderViewToggle(); const app = document.getElementById("app"); app.innerHTML = ""; if (state.view === "list") renderListView(app); else renderCalendarView(app); renderShortlist(); } // A fake, purely cosmetic gate — not real security, just a small "this is a private // plan" speed bump. The password lives in the client, in plain sight in this file. const GATE_PASSWORD = "Tina"; const GATE_STORAGE_KEY = "tina-picker-unlocked"; function showContent() { document.getElementById("gate").classList.add("gate-hidden"); document.getElementById("content").classList.remove("gate-hidden"); } function setupGate() { if (localStorage.getItem(GATE_STORAGE_KEY) === "1") { showContent(); return; } const input = document.getElementById("gate-input"); const error = document.getElementById("gate-error"); const submit = document.getElementById("gate-submit"); const tryUnlock = () => { if (input.value === GATE_PASSWORD) { localStorage.setItem(GATE_STORAGE_KEY, "1"); showContent(); } else { error.textContent = "Not quite — try again."; input.value = ""; input.focus(); } }; submit.addEventListener("click", tryUnlock); input.addEventListener("keydown", (e) => { if (e.key === "Enter") tryUnlock(); }); input.focus(); } (async function init() { setupGate(); await loadPicks(); rerender(); })();