Files
robertoandClaude Sonnet 5 ddd2db2a52 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>
2026-07-03 22:58:15 +02:00

517 lines
20 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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();
})();