commit ddd2db2a5246878cd9bbb7c1b1cc245edf35671b Author: Roberto Hortal Date: Fri Jul 3 22:58:15 2026 +0200 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fe5f125 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.wrangler/ +node_modules/ +.dev.vars diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d2cc63c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,23 @@ +# CLAUDE.md — tina-barcelona-picker + +A standalone side project, not part of the Cerebro vault. Static site + one Cloudflare Pages Function + one KV namespace, entirely on Cloudflare's free tier. **Already deployed and live** at https://summeroffun.hortal.me (project name `tina-barcelona-picker`, account email `rhortal+cloudflare@gmail.com`). See README.md for architecture, deploy steps, and free-tier limits — read it before making structural changes. + +## What this is + +A picker page for planning Tina's Barcelona visit (7 Jul – 18 Aug 2026): dated events (concerts, festival nights, Jazz at La Pedrera) as radio-button groups so only one pick per evening is possible, plus undated checkbox categories (exhibitions, dinner, daytime activities). Two views — a flat list and a day-by-day calendar — render from the same `TRIP_DATA` and share the same client state, so a pick made in one view is correctly reflected in the other. Selections save to KV via the Pages Function and reload on any device that opens the link. + +## Working on this project + +- All trip content lives in `public/data.js` — a plain JS object (`TRIP_DATA`), no build step. Edit it directly; nothing else needs to change for content updates. +- Keep `app.js` framework-free (vanilla JS, no bundler). This is a two-person planning tool, not a product — resist adding a framework, state library, or build pipeline for it. +- The collision-avoidance mechanism is structural: each dated group is a single `name` on a set of radio inputs. Don't replace this with client-side "conflict warnings" bolted onto checkboxes — the radio-group approach is simpler and can't drift out of sync. +- **Never duplicate a multi-night show across several `days[].options` entries.** That was tried once (Grec's Ricardo III and FACTORY each existed as two near-identical option objects, one per night) and it was a real bug, not just untidy — fixed by moving both into `TRIP_DATA.spanning`, which `app.js` renders onto every valid date automatically. If you're tempted to copy-paste an option into a second day, it almost certainly belongs in `spanning` instead. +- The "already picked on a different date" status row (used for both `spanning` items and calendar-view collection items) is rendered by the shared `statusRow()` helper in `app.js` — reuse it rather than inventing a second disabled-row style. +- No real auth by design — a client-side-only password gate (see README "Known limitations"). The password lives in `GATE_PASSWORD` in `public/app.js`, in plain sight, on purpose. Don't add real login/accounts unless explicitly asked. +- Test locally with `wrangler pages dev public --kv PICKS` (uses a local file-backed KV, no Cloudflare account needed) before suggesting a deploy. Note the explicit `--kv PICKS` flag — without it, Pages dev won't bind the namespace locally and every API call 500s. +- The project is live and the account is already authenticated in this environment (`~/.wrangler/config/default.toml` has a valid OAuth token as of the initial deploy). Re-deploying with `wrangler pages deploy public --project-name=tina-barcelona-picker` after a content or code change is fine to run when the user asks for it, or after a batch of edits they'll clearly want live — it's a low-risk, instantly-reversible static deploy. Still don't run `wrangler login`, create/delete Pages projects, or touch DNS/custom-domain settings without being asked; those are the actions worth checking in about. +- **Wrangler cannot manage DNS.** There's no `wrangler pages domain` command, and the OAuth token from `wrangler login` is scoped `zone:read` only (confirmed by testing — both reading and writing zone DNS records return "Authentication error"). Registering a custom domain on the Pages project via the API is fine with this token (`pages:write` covers it), but the actual CNAME record has to be added through the Cloudflare dashboard, or by asking the user for a separately-scoped API token with `Zone:DNS:Edit`. + +## Source of truth + +The activity/date data originated from research done in the parent Cerebro vault (`cerebro-personal/Travel/Tina Barcelona July 2026.md`). If that note gets updated later with new events, dates, or venues, this project's `public/data.js` may need a matching update — but this project does not read from the vault at runtime; the data was copied in once and lives here independently. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6f71c4b --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# Tina Barcelona Picker + +A tiny picker page for planning Tina's Barcelona visit (7 July – 18 August 2026). Lists concerts, festival dates, exhibitions, restaurants, and holiday-leg activities; she taps what she likes and it's saved centrally so both of you can see the shortlist. + +Static frontend + one Cloudflare Pages Function + one KV namespace. Runs entirely on Cloudflare's free tier. + +**Live at: https://summeroffun.hortal.me** (also reachable at the underlying `https://tina-barcelona-picker.pages.dev`). Password-gated — see "Known limitations" below for what that does and doesn't protect against. + +A companion page at `/instructions.html` explains list vs. calendar view, the one-pick-per-evening rule, and how "anytime" items get date-tagged — linked from the "How this page works →" link in the header. + +## Two views, one shared state + +- **List view**: every day as its own card, in date order, with the undated "anytime" collections (culture, dinner, evening, daytime) shown once each below. +- **Calendar view**: one day at a time, navigated with Prev/Next or by tapping a cell in a clickable month grid. Each date's dot shows whether anything's been planned for it yet. + +Both views read and write the exact same state, so a pick made in one shows up correctly in the other — there's no separate "calendar data" to keep in sync. + +## How it avoids double-booking (and repeated entries) + +Each calendar date with named events (Jamboree, Les Nits Occident, Grec, Palau de la Música, Jazz at La Pedrera...) is one **radio-button group** — only one pick per evening is possible by construction, not by a warning after the fact. + +Shows that run across more than one night (e.g. a two-night play) are **not duplicated in the data** — each lives once in `TRIP_DATA.spanning` with a list of valid dates, and is rendered as a live choice on whichever of those dates it isn't picked for yet, and as a disabled "already picked — [date]" status row on the others. + +Undated categories (exhibitions, dinner, daytime activities) are plain checkboxes with no collision risk — but in the calendar view, if one has been tied to a specific date (by ticking it while that day was on screen), it shows as "already picked — [date]" on every *other* day instead of offering a second, contradictory tick box. + +## Project layout + +``` +public/ + index.html - page shell: password gate, title/subtitle, view-toggle mount point, app + shortlist containers + instructions.html - standalone "how this page works" guide, linked from the header + style.css - styling (light/dark aware) — includes calendar grid, gate, and status-row styles + data.js - all the trip data: TRIP_DATA.days, TRIP_DATA.spanning, TRIP_DATA.collections; + plus TRIP_START/TRIP_END constants that bound the calendar view + app.js - renders both views from data.js, talks to the API, keeps the shortlist in sync, + and gates the #content div behind the password prompt +functions/ + api/selections.js - Pages Function: GET returns saved picks, POST saves one change +wrangler.toml - Cloudflare Pages config + KV binding (id already filled in — see below) +``` + +## Editing the trip data + +Everything you see on the page comes from `public/data.js`. To add/remove/change an option, just edit that file — no other file needs to change. Structure: + +- `days`: array of `{ date, label, locked?, note?, options: [{ id, title, meta, url?, default? }] }`. `locked` renders a non-editable "already confirmed" card above the picks (e.g. Jazz at La Pedrera) — omit `options` (or leave it empty) if there's nothing else to offer that night. `note` renders a small warning line (e.g. "you're travelling this day"). `default: true` on an option pre-selects it when nothing's been explicitly chosen yet (used for the free Clàssica a la Platja concert). +- `spanning`: array of `{ id, title, meta, url?, dates: [...] }` — a single show valid on more than one date. **Don't copy-paste it into each day's `options` array** — list it once here and it'll appear correctly (with already-picked-elsewhere handling) on every date in `dates`. +- `collections`: array of `{ id, title, options: [...], dateRange? }` — checkbox groups. A `dateRange: [start, end]` (used for the two holiday legs) makes the collection render inline in the day-by-day timeline instead of the "anytime" section at the bottom, and restricts which calendar days show it in the per-day "also available today" panel. + +IDs must stay unique across the whole file (they're also the KV storage keys), but otherwise there's no schema to run — just valid JS. + +## Local development + +```bash +npm install -g wrangler # if not already installed +wrangler pages dev public +``` + +This runs the site with a local, file-backed KV store (no Cloudflare account needed for local dev). Visit the printed `localhost` URL. + +## Deploying (free tier) + +The project is already deployed (KV namespace created, `wrangler.toml` filled in, custom domain live — see "Live at" above). This section is for re-deploying after content changes, or for standing it up again from scratch on a different account. + +**Re-deploying after an edit** (the common case): +```bash +wrangler pages deploy public --project-name=tina-barcelona-picker +``` +That's it — it's a static site plus one function, so deploys take seconds. No need to touch KV or the custom domain again; both stay attached to the project. + +**Setting up from scratch** (only needed once, or on a new account): +1. **Log in** (opens a browser): `wrangler login` +2. **Create the Pages project**: `wrangler pages project create tina-barcelona-picker --production-branch=main` +3. **Create the KV namespace**: `wrangler kv namespace create PICKS` — copy the returned `id` into `wrangler.toml` under `[[kv_namespaces]] id = "..."`. +4. **Deploy**: `wrangler pages deploy public --project-name=tina-barcelona-picker --commit-dirty=true` (the `--commit-dirty` flag is only needed if this directory has uncommitted git changes at deploy time). Wrangler will print the live `*.pages.dev` URL, and the KV binding comes along automatically from `wrangler.toml` — no separate dashboard step needed. +5. **Add a custom domain** (optional). **Wrangler's CLI has no `pages domain` command**, and the OAuth token from `wrangler login` is scoped `zone:read` only — it cannot create DNS records. Two options: + - Add it via the Cloudflare dashboard: Pages project → Custom domains → add the domain, then add the CNAME record it asks for in the zone's DNS settings. + - Or, with the same OAuth token (has `pages:write`), register the domain on the Pages project via the API: `POST /accounts/{account_id}/pages/projects/{project}/domains {"name": "..."}`. That alone doesn't create the DNS record — you (or a token with `Zone:DNS:Edit`) still need to add a proxied `CNAME` from the subdomain to `.pages.dev`. SSL cert validation can take several minutes to a couple of hours after the CNAME appears before the domain's status flips to `"active"` — check with `GET /accounts/{account_id}/pages/projects/{project}/domains/{domain}`. + +## Free tier notes + +- **Pages**: free, unlimited requests for static assets. +- **Pages Functions**: free tier is 100,000 requests/day — this project will use a tiny fraction of that. +- **KV**: free tier is 100,000 reads/day and 1,000 writes/day, one namespace. This app does one read on page load and one write per tap — comfortably inside the limit even with heavy use. +- No cost, no card required for any of the above at this scale. + +## Known limitations (by design, not oversights) + +- **No real auth — a fake password gate only.** The page shows a "🔒 Private plan" prompt and only reveals the content if you type the password (currently `Tina`, set in `GATE_PASSWORD` in `public/app.js`). It's a client-side speed bump, not security: the password is sitting in plain sight in the JS source, and anyone who opens dev tools can bypass it in seconds. Anyone with the URL can still view/edit picks once past the gate; don't post the URL publicly. +- **No per-user attribution.** It's one shared shortlist, not "Tina's picks" vs "Roberto's picks" — simplest thing that works for a joint plan. +- **Last write wins.** If both of you tap at the exact same moment, one save can overwrite the other. Not worth solving for two people picking restaurants. diff --git a/functions/api/selections.js b/functions/api/selections.js new file mode 100644 index 0000000..0cf6dc1 --- /dev/null +++ b/functions/api/selections.js @@ -0,0 +1,39 @@ +// GET /api/selections -> { picks: { [groupId]: string | string[] } } +// POST /api/selections { groupId, value } -> merges value into stored picks, returns updated state. +// +// Single shared document in KV under key "picks" — no auth, no per-user accounts. +// Good enough for a private link shared between two people; anyone with the URL can edit. + +const KEY = "picks"; + +export async function onRequestGet({ env }) { + const raw = await env.PICKS.get(KEY); + const picks = raw ? JSON.parse(raw) : {}; + return Response.json({ picks }); +} + +export async function onRequestPost({ request, env }) { + let body; + try { + body = await request.json(); + } catch { + return new Response("Invalid JSON body", { status: 400 }); + } + + const { groupId, value } = body; + if (!groupId || typeof groupId !== "string") { + return new Response("groupId is required", { status: 400 }); + } + + const raw = await env.PICKS.get(KEY); + const picks = raw ? JSON.parse(raw) : {}; + + if (value === "" || value == null) { + delete picks[groupId]; + } else { + picks[groupId] = value; // string for day radio picks, string[] for collection checkboxes + } + + await env.PICKS.put(KEY, JSON.stringify(picks)); + return Response.json({ picks }); +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..2a77754 --- /dev/null +++ b/public/app.js @@ -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(); +})(); diff --git a/public/data.js b/public/data.js new file mode 100644 index 0000000..414b54f --- /dev/null +++ b/public/data.js @@ -0,0 +1,265 @@ +// Trip planning data for Tina's visit, 7 July – 18 August 2026. +// Each "day" group is single-select (radio) — only one evening pick per date, +// which is what structurally prevents double-booking the same night. +// "collections" are undated, multi-select (checkbox) categories. +// A collection with a `dateRange` is rendered inline in the day-by-day timeline +// (used for the two holiday legs, so they sit in date order between Barcelona days) +// instead of down in the "anytime" section at the bottom, AND is only surfaced in the +// calendar view's per-day "anytime" panel on dates that fall inside that range. +// +// `spanning` holds shows that run across more than one specific date (e.g. a play +// with a two-night run). Each one is rendered as an option on every date in its `dates` +// list — never duplicated in the data — and app.js shows it as an active choice on +// whichever date it isn't yet picked for, and as an "already picked — [date]" status +// row on the others. +// +// `url` is optional on any option/locked entry — when present the title becomes a link. +// Where no single official page exists, url points at the most reliable page found +// (a listing/reservation aggregator) rather than a guess. + +const TRIP_START = "2026-07-07"; +const TRIP_END = "2026-08-18"; + +const TRIP_DATA = { + title: "Tina's Lovely Summer in Barcelona (and the beach and the spa)", + subtitle: "What catches your fancy? One pick per evening - can't be in 2 places at once 😃", + + days: [ + { + date: "2026-07-08", label: "Wed 8 Jul", + options: [ + { id: "d0708-classica", title: "Clàssica a la Platja", meta: "Sant Sebastià Beach · 21:00 · free, not booked", default: true, url: "https://www.liceubarcelona.cat/en/classica-platja-2026" }, + { id: "d0708-jamboree", title: "Jamboree — International Classic Jazz All Stars", meta: "19:00–21:00 · €35", url: "https://jamboreejazz.com/esdeveniment/international-classic-jazz-all-stars-masimas-festival-2026/" }, + { id: "d0708-pedralbes", title: "Les Nits Occident — Carles Sans (magic show)", meta: "22:00", url: "https://lesnitsoccident.com/es/programa/carles-sans-2026/" }, + { id: "d0708-grec50", title: "Grec — 50th anniversary party", meta: "Teatre Grec · talks, shorts, DJ set in the gardens", url: "https://www.teatrebarcelona.com/espectacle/festa-de-50e-aniversari-del-grec-en-tres-moviments" } + ] + }, + { + date: "2026-07-09", label: "Thu 9 Jul", + options: [ + { id: "d0709-jamboree", title: "Jamboree — Carla Cook Quartet", meta: "19:00–21:00 · €25", url: "https://jamboreejazz.com/esdeveniment/carla-cook-quartet-gmf-barcelona-jazz-workshop-festival/" }, + { id: "d0709-jamboree-late", title: "Jamboree (late) — Matyas Gayer \"In Orbit\"", meta: "22:30 · €18", url: "https://jamboreejazz.com/esdeveniment/matyas-gayer-in-orbit-the-late-show-and-jam-session-gmf-barcelona-jazz-workshop-festival/" }, + { id: "d0709-classica", title: "Clàssica a la Platja — OBC \"Una simfonia imaginària\"", meta: "Sant Sebastià Beach · 21:00 · free · night 2 of the same beach series as the 8th, not an indoor L'Auditori concert", url: "https://www.liceubarcelona.cat/en/classica-platja-2026" }, + { id: "d0709-auditori-bmb", title: "L'Auditori — BMB Pop & Rock Simfònic", meta: "21:30 · confirmed date, but no specific event page found — this links to the general listings", url: "https://www.auditori.cat/en/events/" }, + { id: "d0709-palau", title: "Palau de la Música — \"The Seven Elements\"", meta: "20:00 · Petit Palau · Cor Infantil de l'Orfeó Català & Piccoli Cantori di Torino, part of \"Estiu al Palau\" — link is the concert's own programme note (PDF)", url: "https://www.palaumusica.cat/1656865.pdf" }, + { id: "d0709-pedralbes", title: "Les Nits Occident — Raphael", meta: "22:00", url: "https://lesnitsoccident.com/es/programa/raphael-2026/" } + ] + }, + { + date: "2026-07-10", label: "Fri 10 Jul", + options: [ + { id: "d0710-jamboree", title: "Jamboree — Jocelyn Gould Quartet", meta: "19:00–21:00 · €25", url: "https://jamboreejazz.com/esdeveniment/jocelyn-gould-quartet-gmf-barcelona-jazz-workshop-festival/" }, + { id: "d0710-jamboree-late", title: "Jamboree (late) — Megan Visagie: East Broadway NY Vibration", meta: "22:30 · €18", url: "https://jamboreejazz.com/esdeveniment/megan-visagie-east-broadway-ny-vibration-the-late-show-and-jam-session-gmf-barcelona-jazz-workshop-festival/" }, + { id: "d0710-auditori", title: "L'Auditori — Aurora Bauzà & Pere Jou: \"Lobo\" (Escenes)", meta: "contemporary dance/music, runs 10–11 Jul", url: "https://www.auditori.cat/en/events/escenes-aurora-bauza-and-pere-jou/" }, + { id: "d0710-pedralbes", title: "Les Nits Occident — Vanesa Martín", meta: "22:00", url: "https://lesnitsoccident.com/es/programa/vanesa-martin-2026/" } + ] + }, + { + date: "2026-07-11", label: "Sat 11 Jul", + options: [ + { id: "d0711-jamboree", title: "Jamboree — Perico Sambeat Grupo", meta: "19:00–21:00 · €25", url: "https://jamboreejazz.com/esdeveniment/perico-sambeat-grupo-gmf-barcelona-jazz-workshop-festival/" }, + { id: "d0711-jamboree-late", title: "Jamboree (late) — Night of the Trios: Will Boness & Matyas Gayer", meta: "22:30 · €18", url: "https://jamboreejazz.com/esdeveniment/night-of-the-trios-will-boness-matyas-gayer-gmf-barcelona-jazz-workshop-festival/" }, + { id: "d0711-pedralbes", title: "Les Nits Occident — Ravi Ram (mantra/gospel)", meta: "22:00", url: "https://lesnitsoccident.com/es/programa/ravi-ram-2026/" } + ] + }, + { + date: "2026-07-12", label: "Sun 12 Jul", + options: [ + { id: "d0712-jamboree", title: "Jamboree — Global Blues Guitar Mania", meta: "19:00–21:00 · €25", url: "https://jamboreejazz.com/esdeveniment/global-blues-guitar-mania-gmf-barcelona-jazz-workshop-festival/" }, + { id: "d0712-jamboree-late", title: "Jamboree (late) — Julianna Galperin's Rising Stars", meta: "22:30 · €18", url: "https://jamboreejazz.com/esdeveniment/julianna-galperins-rising-stars-gmf-barcelona-jazz-workshop-festival/" }, + { id: "d0712-pedralbes", title: "Les Nits Occident — Silvana Estrada", meta: "22:00", url: "https://lesnitsoccident.com/es/programa/silvana-estrada-2026/" } + ] + }, + { + date: "2026-07-13", label: "Mon 13 Jul", + options: [ + { id: "d0713-jamboree", title: "Jamboree — Jam Session by Marc Ayza", meta: "19:00 · €12", url: "https://jamboreejazz.com/esdeveniment/jam-session-by-marc-ayza-gmf-barcelona-jazz-workshop-festival/" }, + { id: "d0713-pedralbes", title: "Les Nits Occident — ZAZ", meta: "22:00", url: "https://lesnitsoccident.com/es/programa/zaz-2026/" } + ] + }, + { + date: "2026-07-14", label: "Tue 14 Jul", + options: [ + { id: "d0714-jamboree", title: "Jamboree — Francesco Petreni", meta: "19:00–21:00 · €25", url: "https://jamboreejazz.com/esdeveniment/francesco-petreni-masimas-festival-2026/" }, + { id: "d0714-jamboree-late", title: "Jamboree (late) — Dion Hoorn Quartet", meta: "22:30 · €18", url: "https://jamboreejazz.com/esdeveniment/dion-hoorn-quartet-gmf-barcelona-jazz-workshop-festival/" }, + { id: "d0714-pedralbes", title: "Les Nits Occident — Teràpia de Shock", meta: "22:00", url: "https://lesnitsoccident.com/es/programa/terapia-de-shock-2026/" } + ] + }, + { + date: "2026-07-15", label: "Wed 15 Jul", + options: [ + { id: "d0715-jamboree", title: "Jamboree — Deborah Brown", meta: "19:00–21:00 · €30", url: "https://jamboreejazz.com/esdeveniment/deborah-brown-gmf-barcelona-jazz-workshop-festival/" }, + { id: "d0715-jamboree-late", title: "Jamboree (late) — Brasil años 60", meta: "22:30 · €18", url: "https://jamboreejazz.com/esdeveniment/brasil-anos-60-gmf-barcelona-jazz-workshop-festival/" }, + { id: "d0715-pedralbes", title: "Les Nits Occident — Il Volo ⭐", meta: "22:00 · best fit for refined/operatic-pop taste", url: "https://lesnitsoccident.com/es/programa/il-volo-2026/" } + ] + }, + { + date: "2026-07-16", label: "Thu 16 Jul", + options: [ + { id: "d0716-jamboree", title: "Jamboree — Amphitrio", meta: "19:00–20:30 · €18", url: "https://jamboreejazz.com/esdeveniment/amphitrio/" }, + { id: "d0716-pedralbes", title: "Les Nits Occident — Las Migas", meta: "22:00", url: "https://lesnitsoccident.com/es/programa/las-migas-2026/" } + ] + }, + { + date: "2026-07-17", label: "Fri 17 Jul", + locked: { title: "Jazz at La Pedrera (Talents Jazz) ⭐", meta: "20:00–22:15 · €42pp · rooftop + attic + cava · booked & paid", url: "https://www.lapedrera.com/en/talents-jazz/" }, + options: [] + }, + { + date: "2026-07-25", label: "Sat 25 Jul", + note: "Travel-back day from Santa Coloma de Farners (checkout 12:00) — evening should be feasible.", + options: [ + { id: "d0725-palau", title: "BGKO presents Balkan Reunion", meta: "Palau de la Música · 20:00 · €45", url: "https://jamboreejazz.com/esdeveniment/bgko-presenta-balkan-reunion-ft-roby-lakatos-sasha-agranov-magaliu-sare-i-lubomir-gaspar-masimas-festival-2026/" } + ] + }, + { + date: "2026-07-26", label: "Sun 26 Jul", + options: [ + { id: "d0726-jamboree", title: "Jamboree — Tarek Yamani Trio", meta: "19:00–20:30 · €20", url: "https://jamboreejazz.com/esdeveniment/tarek-yamani-trio/" }, + { id: "d0726-jamboree2", title: "Jamboree — Mariachi Cielito Lindo Barcelona", meta: "19:00", url: "https://jamboreejazz.com/esdeveniment/mariachi-cielito-lindo-barcelona/" } + ] + }, + { + date: "2026-07-31", label: "Fri 31 Jul", + options: [ + { id: "d0731-jamboree1", title: "Jamboree — Stereo Cupid + Drama De Enero", meta: "19:00 · €20", url: "https://jamboreejazz.com/esdeveniment/stereo-cupid-drama-de-enero/" }, + { id: "d0731-jamboree2", title: "Jamboree — Hernan Jacinto Trio", meta: "20:30 · €18", url: "https://jamboreejazz.com/esdeveniment/hernan-jacinto-trio/" }, + { id: "d0731-grec", title: "Grec — \"16 & 17\", TAO Dance Theater", meta: "Teatre Grec · closing weekend of the festival", url: "https://www.barcelona.cat/grec/en/show/tao-dance-theater" } + ] + }, + { + date: "2026-08-02", label: "Sun 2 Aug", + options: [ + { id: "d0802-palau", title: "Palau de la Música — Carmen (Bizet)", meta: "18:00 · staged opera, two acts — verified this exact date on the event page", url: "https://www.palaumusica.cat/en/carmen-by-g-bizet_1071162" } + ] + }, + { + date: "2026-08-05", label: "Wed 5 Aug", + options: [ + { id: "d0805-jamboree", title: "Jamboree — Sant Andreu Little Band", meta: "19:00–20:30 · €25", url: "https://jamboreejazz.com/esdeveniment/sant-andreu-little-band/" } + ] + }, + { + date: "2026-08-06", label: "Thu 6 Aug", + options: [ + { id: "d0806-jamboree", title: "Jamboree — Pur Funk: Clarence Bekker Band", meta: "19:00–20:30 · €20", url: "https://jamboreejazz.com/esdeveniment/pur-funk-clarence-bekker-band-23/" } + ] + }, + { + date: "2026-08-08", label: "Sat 8 Aug", + options: [ + { id: "d0808-palau", title: "Palau de la Música — Madama Butterfly (Puccini)", meta: "19:00 · staged opera, two acts — verified this exact date on the event page", url: "https://www.palaumusica.cat/en/madama-butterfly-by-g-puccini_1081847" } + ] + } + ], + + // Shows that run across multiple specific nights — appear once here, rendered + // onto every date in `dates` as an option (never copy-pasted per day in this file). + spanning: [ + { + id: "span-ricardo3", + title: "Grec — Calixto Bieito's \"La verdadera historia de Ricardo III\"", + meta: "Teatre Grec · Joaquín Furriel · refined pick", + url: "https://www.barcelona.cat/grec/en/show/la-verdadera-historia-de-ricardo-iii-true-story-richard-iii", + dates: ["2026-07-10", "2026-07-11"] + }, + { + id: "span-factory", + title: "Grec — \"FACTORY (Somni d'una nit d'estiu)\" (Àlex Rigola)", + meta: "Heartbreak Hotel · actually runs 30 Jun–31 Jul there (not Teatre Lliure, despite an earlier note) — shown here on the two dates already worked into the plan, but bookable on any date via their own site", + url: "https://tickets.heartbreakhotel.cat/", + dates: ["2026-07-15", "2026-07-16"] + } + ], + + collections: [ + { + id: "santperepescador", + title: "Sant Pere Pescador (18–22 Jul)", + dateRange: ["2026-07-18", "2026-07-22"], + options: [ + { id: "spp-beach", title: "Sant Pere Pescador beach", meta: "windsurfing/kitesurfing/paddleboarding", url: "https://www.visitsantpere.com/en/" }, + { id: "spp-aiguamolls", title: "Aiguamolls de l'Empordà Natural Park", meta: "wetland trails, 300+ bird species", url: "https://aiguamollsdelemporda.cat/" }, + { id: "spp-santmarti", title: "Sant Martí d'Empúries", meta: "medieval streets, Greek/Roman ruins, beach", url: "http://www.macempuries.cat/" }, + { id: "spp-figueres", title: "Figueres — Dalí Theatre-Museum", meta: "callback to the Cadaqués Dalí House", url: "https://www.salvador-dali.org/en/museums/dali-theatre-museum-in-figueres/" }, + { id: "spp-wine", title: "Empordà wine region — DO Empordà wine route", meta: "vineyard visits and tastings, 30+ wineries", url: "https://www.doemporda.cat/es/ruta-del-vino-do-emporda.html" }, + { id: "spp-compartir", title: "Dinner — Compartir (Cadaqués)", meta: "Oriol Castro, Eduard Xatruch & Mateu Casañas — 15 years at El Bulli before opening this; shared-plate tasting menu around a central table; ~45 min drive", url: "https://www.compartircadaques.com/" } + ] + }, + { + id: "santacoloma", + title: "Santa Coloma de Farners (22–25 Jul)", + dateRange: ["2026-07-22", "2026-07-25"], + options: [ + { id: "scf-spa", title: "Termes Orion thermal spa circuit", meta: "the centrepiece of this leg", url: "https://www.termesorion.cat/en" }, + { id: "scf-park", title: "Sant Salvador Park walk", meta: "to the Picant fountain", url: "https://www.turismescf.cat/en/points-of-interest/l/1058-sant-salvador-park.html" }, + { id: "scf-riera", title: "Ruta de la Riera de Santa Coloma", meta: "river hike — no dedicated page found, ask at the tourist office (Casa de la Paraula) for the marked route", url: "https://www.turismescf.cat/en/home.html" }, + { id: "scf-castle", title: "Farners Castle & modernist old town", meta: "Can Gironès, Casa Bofill", url: "https://www.turismescf.cat/en/points-of-interest/l/1048-farners-castle.html" }, + { id: "scf-ratafia", title: "Try local ratafia & biscuits", meta: "workshops on the herbs/production process run Jun–Sep", url: "https://www.turismescf.cat/en/proposals/16-immerse-yourself-in-the-world-of-ratafia.html" } + ] + }, + { + id: "culture", + title: "Culture — exhibitions (any day, no booking rush)", + options: [ + { id: "c-matisse", title: "Chez Matisse", meta: "CaixaForum · until 16 Aug", url: "https://caixaforum.org/es/barcelona/p/chez-matisse-barcelona" }, + { id: "c-beauty", title: "The Cult of Beauty", meta: "CCCB · until 8 Nov", url: "https://www.cccb.org/en/" }, + { id: "c-mccurry", title: "ICONS: Steve McCurry", meta: "Palau Martorell · until 6 Sep", url: "https://www.palaumartorell.com/" }, + { id: "c-miro", title: "Kapwani Kiwanga: Changing States", meta: "Fundació Joan Miró · until 13 Sep", url: "https://www.fmirobcn.org/en/" }, + { id: "c-tapies", title: "Àngel Jové retrospective", meta: "Fundació Antoni Tàpies · until 27 Sep", url: "https://museutapies.org/en/" }, + { id: "c-picasso", title: "New ceramics + Valérie Belin photography", meta: "Museu Picasso · until 6 Sep", url: "https://museupicassobcn.cat/en" }, + { id: "c-doriangray", title: "El retrat de Dorian Gray (Àngels Gonyalons)", meta: "Teatre Romea · runs 30 Jun–2 Aug, flexible any evening", url: "https://www.teatreromea.com/" } + ] + }, + { + id: "evening", + title: "Evening — walks, views, drinks", + options: [ + { id: "e-serras", title: "Rooftop sunset — The Serras", meta: "Passeig de Gràcia" }, + { id: "e-mood", title: "Rooftop sunset — Mood at The One", meta: "" }, + { id: "e-terrat", title: "Rooftop sunset — Terrat, Mandarin Oriental", meta: "" }, + { id: "e-gothic", title: "Gothic Quarter evening walk", meta: "small-group or self-guided" }, + { id: "e-vivinos", title: "Wine tasting — Vivinos", meta: "El Born" }, + { id: "e-anima", title: "Wine tasting — L'Anima del Vi", meta: "El Born" }, + { id: "e-montjuic", title: "Sala Montjuïc open-air cinema", meta: "Mon/Wed/Fri/Sat, 10 Jul–5 Aug · concert 20:45 + film 22:00", url: "https://www.salamontjuic.org/en/" }, + { id: "e-fontmagica", title: "Font Màgica (Magic Fountain)", meta: "Montjuïc · free, no booking · summer schedule Wed–Sun, two ~15–20min shows between 21:30–22:30 · easy to combine with dinner nearby", url: "https://www.barcelona.cat/en/what-to-do-in-bcn/magic-fountain/magic-fountains-show-times" }, + { id: "e-gracia", title: "Festa Major de Gràcia", meta: "15–21 Aug (pregó 14 Aug) · decorated streets, free, atmospheric neighbourhood festival — overlaps with the last few days of the visit", url: "https://festamajordegracia.cat/" }, + { id: "e-cosmonits", title: "CosmoNits at CosmoCaixa", meta: "science museum's late-evening openings — confirmed dates 16, 17, 31 Jul & 6, 7, 21 Aug · €8, workshops/planetarium/films", url: "https://cosmocaixa.org/es/p/cosmonits-biodiversidad" } + ] + }, + { + id: "daytime", + title: "Daytime / afternoon", + options: [ + { id: "day-laberint", title: "Parc del Laberint d'Horta", meta: "gardens/maze, free Wed & Sun", url: "https://www.barcelona.cat/en/what-to-do-in-bcn/parks-and-gardens/parc-del-laberint-dhorta-92086011952" }, + { id: "day-beach-barceloneta", title: "Beach — Barceloneta", meta: "", url: "https://www.barcelona.cat/en/what-to-do-in-bcn/bathing-and-beaches/barceloneta-beach" }, + { id: "day-beach-bogatell", title: "Beach — Bogatell", meta: "", url: "https://www.barcelona.cat/en/what-to-do-in-bcn/bathing-and-beaches/bogatell-beach" }, + { id: "day-beach-marbella", title: "Beach — Mar Bella", meta: "", url: "https://www.barcelona.cat/en/what-to-do-in-bcn/bathing-and-beaches/mar-bella-beach" }, + { id: "day-bunkers", title: "Bunkers del Carmel — sunset viewpoint", meta: "free, 360° city view, closes 21:30 in summer so go for sunset not after · gets crowded, arrive early", url: "http://www.bunkers.cat/en/" }, + { id: "day-mnac", title: "MNAC rooftop terrace — sunset viewpoint", meta: "quieter alternative to the Bunkers · terrace steps are free; roof viewpoint is +€2 on top of admission", url: "https://www.museunacional.cat/en/terrace-museu-nacional" } + ] + }, + { + id: "dinner", + title: "Dinner", + options: [ + { id: "din-casapepe", title: "Casa Pepe", meta: "Sant Gervasi (Plaça de la Bonanova) · charcutería/deli since 1947 — cheeses, cold cuts, tapas, no fixed menu, ask what's fresh", url: "https://www.barcelona-metropolitan.com/locations/casa-pepe/" }, + { id: "din-wilmot", title: "Colmado Wilmot", meta: "Sarrià–Sant Gervasi (Turó Park) · chef Eugeni de Diego, ex-El Bulli · traditional tapas in a shop-like setting · ~€35pp", url: "https://colmadowilmot.com/" }, + { id: "din-gresca", title: "Gresca", meta: "Eixample · chef Rafa Peña · Michelin Selected, small-plate bistro, French technique with Spanish/Catalan produce, offal & game a speciality", url: "https://www.gresca.rest/" }, + { id: "din-tejadamar", title: "Tejada Mar", meta: "Barceloneta beach, under the W hotel · rice/tapas/seafood, sea-view terrace · Wed–Sun", url: "https://www.tejadamar.com/en/" }, + { id: "din-tierratragame", title: "Tierra Trágame", meta: "Eixample (Passatge de Tasso) · Spanish deli/tasting room and wine bar, casual", url: "https://autoreserve.com/en/restaurants/wFSMZWoR6Ky9EGoCeya3" }, + { id: "din-verbena", title: "Verbena Terrace", meta: "Rooftop, Monument Hotel (Passeig de Gràcia) · Martín Berasategui + chef Gabriele Milani, relaxed shareable menu, cocktails, occasional live music", url: "https://www.monumenthotel.com/en/gastronomy-verbena-terrace/" }, + { id: "din-parada", title: "Parada Torres", meta: "Mercat de Santa Caterina · Torres brothers · visited — basic tapas, rushed service", url: "https://www.paradatorres.com/" }, + { id: "din-fonda", title: "Fonda Balmes", meta: "visited — great Catalan food, the sole was excellent", url: "https://fondabalmes.cat/" }, + { id: "din-bera", title: "Bera", meta: "Monument Hotel, Passeig de Gràcia · Martín Berasategui (9 Michelin stars across his restaurants) · Basque/Catalan sharing plates, no dish over €25 · ~€50–60pp", url: "https://berarestaurant.com/" }, + { id: "din-mineral", title: "Mineral", meta: "near Mercat del Ninot · chef Oliver Peña (ex-Michelin) · Nova Cuina Catalana, 18-seat counter · ~€60pp — no official site found, worth calling ahead" }, + { id: "din-casaplatos", title: "Casa Platos", meta: "Gràcia · Japanese–Mediterranean, vinyl DJ sets · currently the hardest reservation in the city · ~€60–70pp", url: "https://casaplatos.com/" }, + { id: "din-fincanebot", title: "Finca Nebot", meta: "Poblenou, 1924 mansion · traditional Catalan · ~€35–40pp", url: "https://fincanebot.com/en/" }, + { id: "din-bardijous", title: "Bar Dijous", meta: "Eixample · chef Joan Vallès (Time Out xef revelació 2025) · Catalan stews, cooperative project · ~€20–40pp", url: "https://restaurante.covermanager.com/bar-dijous/" }, + { id: "din-dospebrots", title: "Dos Pebrots", meta: "El Raval · chef Albert Raurich, ex-head chef at El Bulli · Mediterranean food history told through tasting menus (€50/€70/€90) · easily bookable via Michelin Guide site or TheFork", url: "https://guide.michelin.com/us/en/catalunya/barcelona/restaurant/dos-pebrots" }, + { id: "din-enigma", title: "Enigma", meta: "Eixample · Albert Adrià, 2 Michelin stars, spiritual heir to El Bulli · ~20–25 course tasting menu, meat-only (no veg/pescatarian options) · bookable online, opens 60 days ahead, books up fast — reserve as soon as the window opens", url: "https://enigmaconcept.es/" }, + { id: "din-disfrutar", title: "Disfrutar", meta: "Eixample · Oriol Castro, Eduard Xatruch & Mateu Casañas, 20+ years together at El Bulli then here · 3 Michelin stars · booking opens exactly 1 year ahead (midnight Spain time) and fills fast — realistically only gettable via a released cancellation a few days out; worth a speculative call the day before if you fancy trying", url: "https://www.disfrutarbarcelona.com/" } + ] + } + ] +}; diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..b300ffa --- /dev/null +++ b/public/index.html @@ -0,0 +1,41 @@ + + + + + +Tina's Lovely Summer in Barcelona + + + +
+
+

🔒 Private plan

+

What's the password?

+ + +

+
+
+ +
+ + +
+ + +
+ + + + + diff --git a/public/instructions.html b/public/instructions.html new file mode 100644 index 0000000..a7cbb6d --- /dev/null +++ b/public/instructions.html @@ -0,0 +1,61 @@ + + + + + +How this page works + + + + + +
+
+

The basics

+

Every date with something specific on (a concert, a festival night, a booked event) shows a set of round buttons — pick one per evening. That's on purpose: it keeps you from accidentally double-booking a night, so you don't need to think about clashes at all.

+

Anything marked with a ✅ is already confirmed and booked — there's nothing to choose there, it's just shown so you know the evening's spoken for.

+
+ +
+

Two ways to browse

+

Use the List / Calendar toggle near the top:

+
    +
  • List — every day, one after another, so you can scroll through the whole trip in one go. Undated things (exhibitions, restaurants, evening extras) are grouped in their own sections further down.
  • +
  • Calendar — one day at a time, with a small month grid to jump around. A filled dot means that day already has something picked; an empty dot means it's still open.
  • +
+

Both views are just two windows onto the same picks — anything chosen in one shows up correctly in the other.

+
+ +
+

Shows that run more than one night

+

A few things (like a play with a two-night run) are valid on either of two dates. They show up as a normal choice on both nights — but once you pick one night, the other night shows it as "already picked — [date]" instead, so you can't accidentally book it twice.

+
+ +
+

Exhibitions, restaurants, and other "anytime" things

+

These don't compete for one of your evening picks — tick as many as you like. In the calendar view, if you tick one while looking at a particular day, it gets tagged to that day; every other day will then show it as already picked, so you always know when you've actually planned to do it.

+

If you tick one from the list view instead, it's just marked as "want to do this" without a specific date yet — that's fine, it'll show up in your shortlist as unscheduled.

+
+ +
+

The ⓘ icon

+

Tap it to open more information (official page, tickets, menu, etc.) in a new tab. It's deliberately separate from the tick/radio button next to it, so opening a link never accidentally selects or deselects anything.

+
+ +
+

Your shortlist

+

Everything picked so far is collected at the bottom of the page, in date order, so you can see the whole plan taking shape at a glance.

+
+ +
+

Sharing

+

Everything saves automatically as you tap — no submit button needed. Anyone with the link sees the same shared picks, so you and Roberto can both browse and tick things whenever suits, from any device.

+
+ +

← Back to the picker

+
+ + diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..5d0f8f8 --- /dev/null +++ b/public/style.css @@ -0,0 +1,320 @@ +:root { + --bg: #ffffff; + --fg: #1a1a1a; + --muted: #6b6b6b; + --card-bg: #f7f7f8; + --card-border: #e3e3e6; + --accent: #2563eb; + --accent-bg: #eaf0fe; + --locked-bg: #fff7e6; + --locked-border: #f0d78c; + --warn: #b45309; + --radius: 10px; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #15161a; + --fg: #ececec; + --muted: #9a9a9a; + --card-bg: #1e1f24; + --card-border: #2c2d33; + --accent: #5b8def; + --accent-bg: #1c2740; + --locked-bg: #2a2312; + --locked-border: #6b5417; + } +} +:root[data-theme="dark"] { + --bg: #15161a; --fg: #ececec; --muted: #9a9a9a; + --card-bg: #1e1f24; --card-border: #2c2d33; + --accent: #5b8def; --accent-bg: #1c2740; + --locked-bg: #2a2312; --locked-border: #6b5417; +} +:root[data-theme="light"] { + --bg: #ffffff; --fg: #1a1a1a; --muted: #6b6b6b; + --card-bg: #f7f7f8; --card-border: #e3e3e6; + --accent: #2563eb; --accent-bg: #eaf0fe; + --locked-bg: #fff7e6; --locked-border: #f0d78c; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: var(--bg); + color: var(--fg); + line-height: 1.4; + padding: 1.25rem; + max-width: 720px; + margin: 0 auto; +} + +.gate { + min-height: 60vh; + display: flex; + align-items: center; + justify-content: center; + text-align: center; +} +.gate-hidden { display: none; } +.gate-box { + border: 1px solid var(--card-border); + background: var(--card-bg); + border-radius: var(--radius); + padding: 2rem 1.5rem; + max-width: 320px; + width: 100%; +} +.gate-box h1 { font-size: 1.2rem; } +.gate-input { + width: 100%; + margin: 1rem 0 0.6rem; + padding: 0.6rem 0.7rem; + border-radius: 8px; + border: 1px solid var(--card-border); + background: var(--bg); + color: var(--fg); + font-size: 1rem; +} +.gate-submit { + width: 100%; + padding: 0.6rem; + border-radius: 8px; + border: none; + background: var(--accent); + color: #fff; + font-size: 0.95rem; + cursor: pointer; +} +.gate-error { + color: #b91c1c; + font-size: 0.85rem; + min-height: 1.2rem; + margin: 0.6rem 0 0; +} + +.page-header { margin-bottom: 1.5rem; } +h1 { font-size: 1.4rem; margin: 0 0 0.25rem; } +.subtitle { color: var(--muted); margin: 0; font-size: 0.95rem; } + +.status { + min-height: 1.2rem; + font-size: 0.85rem; + color: var(--accent); + margin-top: 0.5rem; +} + +.how-it-works { + display: inline-block; + margin-top: 0.6rem; + font-size: 0.85rem; + color: var(--accent); + text-decoration: none; +} +.how-it-works:hover { text-decoration: underline; } + +.instr-section { + margin-bottom: 1.75rem; +} +.instr-section h2 { + font-size: 1.05rem; + margin: 0 0 0.5rem; +} +.instr-section p { margin: 0 0 0.6rem; } +.instr-section ul { margin: 0 0 0.6rem; padding-left: 1.2rem; } +.instr-section li { margin-bottom: 0.4rem; } + +section.day, section.collection { + margin-bottom: 1.25rem; + border: 1px solid var(--card-border); + border-radius: var(--radius); + background: var(--card-bg); + padding: 0.9rem 1rem; +} + +section.collection { margin-top: 2rem; } + +.day-label { + font-weight: 600; + margin-bottom: 0.5rem; + font-size: 0.95rem; +} + +.collection-title { + font-weight: 600; + margin: 0 0 0.6rem; + font-size: 1rem; +} + +.locked-card { + display: flex; + gap: 0.6rem; + align-items: flex-start; + background: var(--locked-bg); + border: 1px solid var(--locked-border); + border-radius: 8px; + padding: 0.5rem 0.7rem; + margin-bottom: 0.6rem; + font-size: 0.9rem; +} +.locked-card .badge { flex-shrink: 0; } +.locked-card .locked-body { flex: 1; } + +.note { + font-size: 0.85rem; + color: var(--warn); + margin: 0 0 0.6rem; +} + +.option-row { + display: flex; + align-items: center; + gap: 0.2rem; +} + +label.option { + display: flex; + gap: 0.6rem; + align-items: flex-start; + padding: 0.45rem 0.4rem; + border-radius: 8px; + cursor: pointer; + flex: 1; + min-width: 0; +} +label.option:hover { background: var(--accent-bg); } + +label.option input { margin-top: 0.2rem; flex-shrink: 0; } + +.option-body { flex: 1; } +.option-title { font-size: 0.92rem; } +.option-meta { font-size: 0.8rem; color: var(--muted); } + +/* Standalone info icon, sibling of the label — never nested inside it, + so tapping it can't also toggle the radio/checkbox. */ +.info-icon { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 1.8rem; + height: 1.8rem; + border-radius: 50%; + color: var(--muted); + text-decoration: none; + font-size: 1rem; +} +.info-icon:hover { background: var(--accent-bg); color: var(--accent); } + +.muted { color: var(--muted); font-size: 0.9rem; } + +.date-tag { + font-size: 0.78rem; + color: var(--accent); + font-weight: 500; +} + +/* A row standing in for an option that's already been picked on a different + date — same shape as a normal option row, but not interactive. */ +.status-row { + cursor: default; + opacity: 0.7; +} +.status-row:hover { background: none; } +.status-row .badge { flex-shrink: 0; margin-right: 0.6rem; color: var(--accent); } + +.view-toggle { + display: flex; + gap: 0.4rem; + margin-top: 0.75rem; +} +.view-btn { + border: 1px solid var(--card-border); + background: var(--card-bg); + color: var(--fg); + border-radius: 8px; + padding: 0.4rem 0.9rem; + font-size: 0.85rem; + cursor: pointer; +} +.view-btn-active { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +.cal-nav { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.75rem; +} +.cal-current-label { font-weight: 600; } +.cal-nav-btn { + border: 1px solid var(--card-border); + background: var(--card-bg); + color: var(--fg); + border-radius: 8px; + padding: 0.4rem 0.8rem; + font-size: 0.85rem; + cursor: pointer; +} +.cal-nav-btn:disabled { opacity: 0.4; cursor: default; } + +.cal-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 0.2rem; + margin-bottom: 1.25rem; +} +.cal-weekday { + text-align: center; + font-size: 0.72rem; + color: var(--muted); + padding-bottom: 0.3rem; +} +.cal-cell { + height: 34px; + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 0.3rem; + border-radius: 8px; + cursor: pointer; + border: 1px solid transparent; +} +.cal-cell:hover { background: var(--accent-bg); } +.cal-cell-outside { visibility: hidden; cursor: default; } +.cal-cell-outside:hover { background: none; } +.cal-cell-current { + border-color: var(--accent); + background: var(--accent-bg); +} +.cal-daynum { font-size: 0.78rem; } +.cal-dot { width: 5px; height: 5px; border-radius: 50%; flex-shrink: 0; } +.cal-dot-filled { background: var(--accent); } +.cal-dot-empty { background: var(--card-border); } + +.cal-anytime-heading { + font-weight: 600; + font-size: 0.95rem; + margin: 1.5rem 0 0.75rem; + color: var(--muted); +} + +.shortlist { + margin-top: 2.5rem; + border-top: 2px solid var(--card-border); + padding-top: 1rem; +} +.shortlist h2 { font-size: 1.1rem; margin: 0 0 0.6rem; } +#shortlist-list { list-style: none; padding: 0; margin: 0; } +#shortlist-list li { + padding: 0.4rem 0; + border-bottom: 1px solid var(--card-border); + font-size: 0.9rem; +} +#shortlist-list li .when { color: var(--muted); margin-right: 0.4rem; } diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 0000000..d757d3b --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,7 @@ +name = "tina-barcelona-picker" +pages_build_output_dir = "public" +compatibility_date = "2026-07-01" + +[[kv_namespaces]] +binding = "PICKS" +id = "ffdcc68ebcda41bdacefabe7ced31a19"