Static Cloudflare Pages site + KV-backed Pages Function for jointly planning Tina's Barcelona visit (7 Jul - 18 Aug 2026). List and calendar views share one client state; dated evening events are radio groups to prevent double-booking, multi-night shows live once in TRIP_DATA.spanning instead of being duplicated per day, and undated collections get optionally date-tagged when picked from the calendar view. Includes a client-side-only password gate and an instructions page. Live at https://summeroffun.hortal.me. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
517 lines
20 KiB
JavaScript
517 lines
20 KiB
JavaScript
// 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();
|
||
})();
|