# 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.