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:
commit
ddd2db2a52
10 files changed
+1366
No files matched your search
@@ -0,0 +1,3 @@
|
|||||||
|
.wrangler/
|
||||||
|
node_modules/
|
||||||
|
.dev.vars
|
||||||
@@ -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.
|
||||||
@@ -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 `<project>.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.
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
+516
@@ -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();
|
||||||
|
})();
|
||||||
+265
@@ -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/" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title id="page-title">Tina's Lovely Summer in Barcelona</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="gate" class="gate">
|
||||||
|
<div class="gate-box">
|
||||||
|
<h1>🔒 Private plan</h1>
|
||||||
|
<p>What's the password?</p>
|
||||||
|
<input id="gate-input" class="gate-input" type="password" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||||
|
<button id="gate-submit" class="gate-submit">Enter</button>
|
||||||
|
<p id="gate-error" class="gate-error"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="content" class="gate-hidden">
|
||||||
|
<header class="page-header">
|
||||||
|
<h1 id="title"></h1>
|
||||||
|
<p id="subtitle" class="subtitle"></p>
|
||||||
|
<div id="status" class="status" aria-live="polite"></div>
|
||||||
|
<div id="view-toggle" class="view-toggle"></div>
|
||||||
|
<a href="instructions.html" class="how-it-works">How this page works →</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="app"></main>
|
||||||
|
|
||||||
|
<aside id="shortlist" class="shortlist">
|
||||||
|
<h2>Your shortlist</h2>
|
||||||
|
<ul id="shortlist-list"></ul>
|
||||||
|
<p id="shortlist-empty" class="muted">Nothing picked yet — tap anything above.</p>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="data.js"></script>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>How this page works</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="page-header">
|
||||||
|
<h1>How this page works</h1>
|
||||||
|
<p class="subtitle">A quick guide to picking your way through Tina's lovely summer in Barcelona.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="instructions">
|
||||||
|
<section class="instr-section">
|
||||||
|
<h2>The basics</h2>
|
||||||
|
<p>Every date with something specific on (a concert, a festival night, a booked event) shows a set of round buttons — pick <strong>one</strong> 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.</p>
|
||||||
|
<p>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.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="instr-section">
|
||||||
|
<h2>Two ways to browse</h2>
|
||||||
|
<p>Use the <strong>List / Calendar</strong> toggle near the top:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>List</strong> — 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.</li>
|
||||||
|
<li><strong>Calendar</strong> — 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.</li>
|
||||||
|
</ul>
|
||||||
|
<p>Both views are just two windows onto the same picks — anything chosen in one shows up correctly in the other.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="instr-section">
|
||||||
|
<h2>Shows that run more than one night</h2>
|
||||||
|
<p>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 <em>"already picked — [date]"</em> instead, so you can't accidentally book it twice.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="instr-section">
|
||||||
|
<h2>Exhibitions, restaurants, and other "anytime" things</h2>
|
||||||
|
<p>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.</p>
|
||||||
|
<p>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.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="instr-section">
|
||||||
|
<h2>The ⓘ icon</h2>
|
||||||
|
<p>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.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="instr-section">
|
||||||
|
<h2>Your shortlist</h2>
|
||||||
|
<p>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.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="instr-section">
|
||||||
|
<h2>Sharing</h2>
|
||||||
|
<p>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.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p><a href="index.html" class="how-it-works">← Back to the picker</a></p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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; }
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
name = "tina-barcelona-picker"
|
||||||
|
pages_build_output_dir = "public"
|
||||||
|
compatibility_date = "2026-07-01"
|
||||||
|
|
||||||
|
[[kv_namespaces]]
|
||||||
|
binding = "PICKS"
|
||||||
|
id = "ffdcc68ebcda41bdacefabe7ced31a19"
|
||||||
Reference in new issue
Block a user