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>
92 lines
8.2 KiB
Markdown
92 lines
8.2 KiB
Markdown
# 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.
|