Movie selector: groups, join codes and coin-flip bracket tournaments #1

Closed
garlic wants to merge 0 commits from movie-selector-app into main
Collaborator

Builds the movie selector described in PLAN.md.

What it does

Sign up with an email and password, create a group, and you get a permanent
join code to share. Everyone in the group adds movies (plain text names, no
lookups). The group owner starts a tournament, which locks the movie list, and
then plays the bracket one round at a time. Each matchup is a 50/50 coin flip
decided on the server; an odd number of movies gives one film a random bye.
Non-owners refresh to follow along, as agreed — no live updates. When the
tournament ends the list unlocks, so a group can run as many as it likes,
reusing the same movies or a fresh set.

Decisions taken

Geoff chose these when I asked:

  • Joining is by shareable code only (no owner-invite-by-email flow).
  • Round-by-round reveal, with the owner advancing each round — rather than
    resolving the whole bracket in one click.
  • Kept the template's cream/olive theme rather than restyling.

Template bugs fixed along the way

These were pre-existing, not introduced here. Two of them blocked the app
outright, so they are separate commits and can be reviewed independently:

  • Signup was broken. The changelog plugin logs each tracked write to a
    record whose changedBy relation is required, but self-registration has no
    authenticated user. The hook failed after the account was committed, so
    signup returned 400 while the account existed, and the natural retry hit
    "email already in use". The plugin and its two collections are removed.
  • make dev served a blank page. @solidpb/ui-kit is excluded from
    optimizeDeps, which also stops Vite crawling its imports, so the CJS-only
    bind-event-listener was never converted to ESM. Fixed via
    optimizeDeps.include. Production builds were always fine.
  • A fresh clone could not build. main.go embeds ui/dist, which is
    gitignored, so make dev on a clean checkout started Vite but never built
    the API. build now bootstraps the frontend when it is missing.
  • make types was broken. include .env only creates make variables, and
    the recipe runs from dist/ where godotenv cannot find the file either, so
    ENV fell back to production and gen-types was never registered.

There is also a GOEXPERIMENT=nojsonv2 pin in the Makefile: Go 1.27 enables
the rewritten encoding/json, which sends pocketbase v0.39.11's
Collection.UnmarshalJSON into infinite recursion. v0.39.11 is the latest
release, so there is nothing upstream to bump to. Go 1.26.5 is unaffected and
ignores the flag, so the same commands work on either toolchain.

One permission was deliberately loosened: the template locks user to
"you can only see yourself", which left group pages unable to name their
members or say who added a movie. migrations/4 widens list/view to accounts
you already share a group with. Email stays hidden regardless.

Structure

Bracket logic lives in core/bracket.go with no pocketbase dependency, so it
is testable on its own; randomness comes in through a Flipper interface that
tests seed. Tournament writes go through core/routes because tournament
and matchup have no write rules and groupMember has no create rule — a
client cannot forge a bracket result or a membership.

Testing

44 tests, make test. Byes appear exactly once and only on odd counts, the
shuffle never drops or duplicates an entrant, the coin is fair over 10k flips,
and every field size from 2 to 33 reduces to exactly one winner. Endpoints are
covered by pocketbase API scenarios against a fixture built fresh from the
migrations, including the permission cases and the pool lock.

Verified by hand too: a non-member gets 404/403 on every group, movie,
tournament and matchup, and cannot fake a membership. The full journey was
clicked through in a browser on a clean database, desktop and mobile, as both
owner and plain member — and re-run from a fresh clone following only the
README.

Running it

cp .env.example .env
cd ui && npm i && cd ..
make dev

Then http://localhost:3000. You will start with an empty database.


Written by Claude (Claude Code) on Geoff's behalf. Happy to adjust anything
here — leave a comment and Geoff can point me at it.

— Claude

Builds the movie selector described in `PLAN.md`. ## What it does Sign up with an email and password, create a group, and you get a permanent join code to share. Everyone in the group adds movies (plain text names, no lookups). The group owner starts a tournament, which locks the movie list, and then plays the bracket one round at a time. Each matchup is a 50/50 coin flip decided on the server; an odd number of movies gives one film a random bye. Non-owners refresh to follow along, as agreed — no live updates. When the tournament ends the list unlocks, so a group can run as many as it likes, reusing the same movies or a fresh set. ## Decisions taken Geoff chose these when I asked: - **Joining is by shareable code only** (no owner-invite-by-email flow). - **Round-by-round reveal**, with the owner advancing each round — rather than resolving the whole bracket in one click. - **Kept the template's cream/olive theme** rather than restyling. ## Template bugs fixed along the way These were pre-existing, not introduced here. Two of them blocked the app outright, so they are separate commits and can be reviewed independently: - **Signup was broken.** The changelog plugin logs each tracked write to a record whose `changedBy` relation is required, but self-registration has no authenticated user. The hook failed *after* the account was committed, so signup returned 400 while the account existed, and the natural retry hit "email already in use". The plugin and its two collections are removed. - **`make dev` served a blank page.** `@solidpb/ui-kit` is excluded from `optimizeDeps`, which also stops Vite crawling its imports, so the CJS-only `bind-event-listener` was never converted to ESM. Fixed via `optimizeDeps.include`. Production builds were always fine. - **A fresh clone could not build.** `main.go` embeds `ui/dist`, which is gitignored, so `make dev` on a clean checkout started Vite but never built the API. `build` now bootstraps the frontend when it is missing. - **`make types` was broken.** `include .env` only creates make variables, and the recipe runs from `dist/` where godotenv cannot find the file either, so `ENV` fell back to `production` and `gen-types` was never registered. There is also a `GOEXPERIMENT=nojsonv2` pin in the Makefile: Go 1.27 enables the rewritten `encoding/json`, which sends pocketbase v0.39.11's `Collection.UnmarshalJSON` into infinite recursion. v0.39.11 is the latest release, so there is nothing upstream to bump to. Go 1.26.5 is unaffected and ignores the flag, so the same commands work on either toolchain. One permission was deliberately loosened: the template locks `user` to "you can only see yourself", which left group pages unable to name their members or say who added a movie. `migrations/4` widens list/view to accounts you already share a group with. Email stays hidden regardless. ## Structure Bracket logic lives in `core/bracket.go` with no pocketbase dependency, so it is testable on its own; randomness comes in through a `Flipper` interface that tests seed. Tournament writes go through `core/routes` because `tournament` and `matchup` have no write rules and `groupMember` has no create rule — a client cannot forge a bracket result or a membership. ## Testing 44 tests, `make test`. Byes appear exactly once and only on odd counts, the shuffle never drops or duplicates an entrant, the coin is fair over 10k flips, and every field size from 2 to 33 reduces to exactly one winner. Endpoints are covered by pocketbase API scenarios against a fixture built fresh from the migrations, including the permission cases and the pool lock. Verified by hand too: a non-member gets 404/403 on every group, movie, tournament and matchup, and cannot fake a membership. The full journey was clicked through in a browser on a clean database, desktop and mobile, as both owner and plain member — and re-run from a fresh clone following only the README. ## Running it ```bash cp .env.example .env cd ui && npm i && cd .. make dev ``` Then <http://localhost:3000>. You will start with an empty database. --- *Written by Claude (Claude Code) on Geoff's behalf. Happy to adjust anything here — leave a comment and Geoff can point me at it.* *— Claude*
The Makefile and migrations read config from .env, which is gitignored,
so a new checkout has nothing to copy from. Adds a documented template
covering ENV, VITE_APP_NAME and the superuser credentials.

Also syncs package-lock.json with the package name (npm i rewrote it).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PLAN.md calls for removing the template's roles and permissions setup.
Drops the `role` and `permission` collections from the snapshot, the
`role` relation on `user`, their seed migrations, the roles plugin, and
the frontend guards that consumed them.

Also fixes template bugs uncovered on the way:
  - Provider/hooks referenced a "users" collection that is named "user",
    and imported types from modules that don't exist in this repo.
  - The changelog plugin was configured against the same wrong name.
  - Auth is email/password only now, so the OAuth path is gone.

The Makefile pins GOEXPERIMENT=nojsonv2: Go 1.27 turns on the rewritten
encoding/json, and pocketbase v0.39.11's Collection.UnmarshalJSON then
recurses until the stack overflows, so migrate/serve die on startup.
v0.39.11 is the newest release, so there is nothing upstream to bump to.

skipLibCheck lets tsc run at all (a @kobalte/core .d.ts does not compile),
so there is now a `make typecheck-ui` that passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five collections covering the whole feature set:

  group        name, owner, and a joinCode that is fixed for the life of
               the group
  groupMember  join table; the owner gets a row too, so one membership
               check covers everyone
  movie        a plain text name scoped to a group
  tournament   status, the round waiting to be played, and the winner
  matchup      one bracket pairing; empty movieB is a bye, empty winner
               means not played yet

Rules are applied in a second pass because group's own rules reference
groupMember, which does not exist yet while group is being created.

tournament and matchup have nil write rules and groupMember has no create
rule, so the coin flips and the join-by-code check can only happen in
server-side code -- a client cannot forge a bracket result or fabricate a
membership by guessing at collection endpoints.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pure functions with no pocketbase dependency, so the interesting part of
the tournament is testable on its own. Randomness comes in through a
Flipper interface: production uses the global math/rand/v2 source, tests
inject a seeded one and replay a whole bracket exactly.

PairEntrants shuffles before pairing, which makes the odd-count bye fall
on a uniformly random entrant without a separate draw.

Covered by tests: byes appear exactly once and only on odd counts, the
shuffle never drops or duplicates an entrant, the input is not mutated,
the coin lands ~50/50 over 10k flips, and every entrant count from 2 to
33 shrinks to exactly one winner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three endpoints, all requiring auth:

  POST /api/groups/join         join by code
  POST /api/tournaments/start   lock the pool and lay out round 1
  POST /api/tournaments/advance play the waiting round

Joining cannot be a plain collection create: the caller is not a member
yet, so the group's list rule hides it from them, and an open create rule
on groupMember would let anyone join any group by guessing an id. The
handler answers "no group found" identically for malformed and unknown
codes so that probing does not reveal which codes exist.

Codes are drawn from crypto/rand over an alphabet with the confusable
characters removed (no O/0, I/1, or S), and typed codes are normalised,
so "abc-234" finds the group stored as ABC234.

Tournaments follow the round-by-round reveal: start lays out round 1
unplayed, and each advance flips every matchup in the waiting round, then
either lays out the next round or records the winner. Both run in a
transaction. Only the group owner can do either.

Hooks cover what API rules cannot: the owner is stamped onto a new group
along with its code and their own membership, the movie pool is frozen
while a tournament runs, and the owner cannot leave their own group and
strand it.

44 tests, 71% statement coverage over core and the handlers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The template locks `user` to "you can only see yourself", so a group page
could not name its members or say who added a movie -- every expand on a
relation to another user came back empty.

Widens list/view to accounts you already share a group with, walking from
the target user through their memberships to everyone in those groups.
Email stays hidden regardless, since pocketbase gates it behind the
account's own emailVisibility flag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The plugin logged tracked writes to a changelog record whose changedBy
relation is required. Self-registration has no authenticated user, so the
hook failed -- but only after the account was already committed. Signup
answered 400 while the account existed, and the obvious retry then hit
"email already in use", with no way forward from the sign-in card.

Nothing in a movie selector wants an audit trail of user renames, so the
plugin and its two collections go rather than being patched around.

Hook rejections now return apis.NewBadRequestError. Plain errors get
collapsed into "Failed to create record.", which told a user nothing
about why adding a movie during a tournament was refused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three screens on top of the existing shell:

  /               your groups, plus create-a-group and join-by-code
  /groups/:id     join code, movie list, tournaments, members
  /tournaments/:id the bracket, round by round

The bracket reflects the agreed reveal: a round is laid out with both
sides level and marked "not played yet", and only after the owner plays
it does the winner light up and the loser get struck through. Byes say so
in words rather than showing an empty slot. Non-owners get a refresh
button and a note about who they are waiting on, since PLAN.md rules out
live updates.

The group page hides the add-movie form and the delete buttons while a
tournament is running, so the locked pool is visible in the UI rather
than only being discovered by getting an error back.

errorMessage() pulls the real sentence out of a ClientResponseError.
PocketBase puts it on response.message and only sometimes mirrors it to
message, so a plain err.message often shows the generic "Something went
wrong" instead of the reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Opening /groups/:id or /tournaments/:id while signed out fell through to
the catch-all and rendered a bare "You appear to be lost" with no way to
sign in. That is the common case for this app: the whole point of a join
code is that links get passed around to people who have not signed up.

Signed out, every path now shows the sign-in card, and signing in sends
you on to the path you originally asked for instead of the dashboard. The
not-found page moves inside the signed-in routes, where it makes sense.

Also names the app in the tab title and manifest (both still said "App"),
matches the browser theme colour to the cream theme instead of black, and
puts the group owner at the top of the members list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dev server loaded to an empty page and a console SyntaxError:
"bind-event-listener does not provide an export named 'bind'".

@solidpb/ui-kit is excluded from optimizeDeps because it ships source
that has to go through the solid plugin. Excluding it also stops vite
crawling what it imports, so bind-event-listener -- CJS-only, pulled in
by the pragmatic-drag-and-drop packages -- never got converted to ESM.
Naming it in optimizeDeps.include pre-bundles it and dev boots.

Only dev was affected; rollup does its own interop, which is why the
production build has worked throughout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The README still described the solidpb template. Replaces it with what
the app does, how to run it, the collections and endpoints, and what was
deliberately left out.

`make types` was broken: `include .env` only creates make variables, and
the recipe runs from dist/, where godotenv cannot find .env either. ENV
fell back to "production", where gen-types is never registered, so the
target died with "unknown command". A bare `export` passes the values
through, which also makes serve/serve-prod honour .env.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`make dev` on a clean checkout started vite but never started the API:
main.go embeds ui/dist, ui/dist is gitignored, so the go build died with
"pattern ui/dist/*: no matching files found". The frontend came up on
3000 and every request to it failed, which looks like a broken app rather
than a missing build step. PLAN.md flagged this as a known trap; now the
build target just handles it.

`build` depends on ui/dist and builds the frontend if it is absent. Once
it exists the rule is a no-op, so backend-only rebuilds stay fast and
`make build-ui` still forces a refresh.

build-ui and typecheck-ui now say "run cd ui && npm i" when node_modules
is missing, instead of failing with "vite: command not found".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claimed GOEXPERIMENT=nojsonv2 was "a no-op on Go < 1.27". Verified by
building against each toolchain: 1.26.5 and 1.25 accept and ignore it,
but 1.24 rejects it with "unknown GOEXPERIMENT jsonv2". go.mod requires
1.26.5 so the broken case is unreachable, but the note said otherwise.

Also confirms 1.26.5 does not hit the recursion bug at all, so there is
no reason to pin an older toolchain -- the same commands work on both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Swaps the template's cream/olive palette for a dark room: near-black
bases with a trace of warmth so they read as dimmed rather than cold
slate, warm off-white text, and projector-gold as the accent. Gold is
reserved for winning, so nothing else competes with it; curtain red
carries secondary and destructive actions.

Browser chrome and the PWA manifest follow the new background.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three changes to the part of the app people actually watch.

The bracket now reads as a bracket. Rounds are columns that narrow left
to right, each spreading its matchups so one sits between the two it came
from. Rounds the server has not created yet are drawn as dashed ghost
columns, so the funnel down to the final is visible from the first
screen instead of appearing a column at a time.

A round's results arrive from the server together; they are now held back
and revealed one matchup at a time, 700ms apart. The hold starts before
the request goes out, so results never flash in fully decided on the way.
Winners land in gold with a short settle animation, losers strike through.

The winner gets a real moment: a full-width banner with a slow projector
glow behind the title, held back until the last flip of the final has
landed rather than appearing the instant the API responds.

Rounds are named -- "the final", "the semi-finals" -- wherever the count
makes that obvious. All animation is dropped under prefers-reduced-motion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Numbers the movie list and gives each row a little more room, puts the
join code in gold monospace, replaces the bare "You appear to be lost"
page (which rendered outside the app shell with an empty button) with a
proper card, and moves opacity-based greys onto theme tokens so they hold
up against the dark bases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drops one ball per movie through a staggered peg board and records every
position at 60fps. The whole race runs on the server: physics in each
browser would give every viewer a different winner, since floating point
and frame timing drift apart, and would put the result within reach of
whoever is watching.

Getting it fair took three goes, each caught by the slot-distribution
test:

  - Pegs originally ran the full width, so one sat on each wall and balls
    wedged in the corner and never finished.
  - Laying rows out left-to-right left the leftover space as a wider gap
    on the right; balls poured through it and the rightmost lane won 55%
    of races. Rows are now built outwards from the centre.
  - Starting the balls in lanes across the width handed the outer lanes
    the wall channel, which is wider than the gaps between pegs, and they
    took 83% between them. They now drop together from a central hopper,
    with hopper slots shuffled so a ball's start does not depend on its
    index. Wins by slot over 600 races went from [251 31 19 10 42 247] to
    [113 90 97 97 102 101].

Also: a floor, so finishers pile up instead of falling out of the world;
wall friction, because a frictionless wall was a free slide past every
peg; and a nudge off peg apexes, where a balanced ball would otherwise
sit forever.

Recording stops about a second after the winner lands, though the
simulation runs on to place everyone -- that keeps the watch around 8
seconds and the payload between 26KB and 210KB.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
POST /api/tournaments/start now takes a format: "bracket" (the default,
unchanged) or "race". A race runs the whole simulation up front and is
complete the moment it is created -- there are no rounds and no matchups,
so the group's pool is free again immediately.

GET /api/tournaments/race?tournamentId= returns the board, every ball's
position at 60fps, and the movie names to label them with, for members of
the group only. Non-members get the same "not found" a bad id gets, so
the endpoint does not confirm which tournaments exist.

Only the seed and the ball order are stored. The simulation is
deterministic, so those two are the whole race and it is rebuilt per
request in a few milliseconds -- much better than keeping a few hundred
KB of positions per tournament in the database. A test asserts two
replays of the same tournament are frame-for-frame identical, and that
the replay's winner is the one recorded on the tournament.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Starting a tournament now asks how it should be decided: bracket, or a
race where every movie is a ball dropped through the peg board at once.

The race screen replays the server's recording on a canvas at 60fps, with
a legend that reorders live as balls overtake each other and the winner
announced as its ball crosses the line rather than the moment the page
loads. "Watch again" replays it.

Playback runs off the wall clock rather than counting frames, so a slow
device plays the race at the right speed instead of in slow motion. It
also pauses when the tab is hidden -- requestAnimationFrame stops there,
and without pausing the race would jump most of the way to the finish the
moment you came back and you would miss it.

The board is capped at two thirds of the viewport height: it is half
again as tall as it is wide, so sizing purely by width pushed the finish
line off the bottom of a phone screen mid-race.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Describes both formats, why the physics runs on the server rather than in
each browser, and what the race tests actually assert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Owner

Already merged into main

Already merged into main
davenh99 closed this pull request 2026-08-23 07:17:34 +10:00

Pull request closed

Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
davenh99/movie-selector!1
No description provided.