Initial commit: Tina Barcelona trip picker

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>
This commit is contained in:
robertoandClaude Sonnet 5 committed 2026-07-03 22:58:15 +02:00
commit ddd2db2a52
10 files changed
+1366

No files matched your search

+516
View File
@@ -0,0 +1,516 @@
// 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();
})();