WI-043: Who was at the table
WI-043: Who was at the table
WI-035 built participants and WI-036 let the log form record them. Nothing has ever shown them back, and nothing could change them afterwards — which matters more here than it sounds, because one tap is a complete record by design (ADR-0007), so the common play arrives with nobody attached.
A plays list that cannot say who you played with is half a list.
Carried on the session, not fetched per row
listSessions now returns participants. The obvious implementation — joining them into the existing query — returns a row per participant and makes limit mean something other than “sessions”. Instead it is a second query over the page already selected: two round trips regardless of how many plays, rather than one per row.
A winner picker you can find
The log form hides “who won” behind a long-press, with a line of text explaining it. On the web build this project deploys, nobody holds the mouse down on a name to see what happens — and it was almost untestable, which is usually the same problem wearing a different hat.
The editor uses a visible Who won row instead, offering only the people actually at the table, plus Nobody won for co-ops. The log form still long-presses; bringing it in line is a small follow-up, and shipping one discoverable control beats shipping two hidden ones.
The dangerous version of this screen
If the roster fails to load, no chips render — so the chosen set looks empty, and saving clears everybody off the play.
It does not, because chosen is initialised from the session rather than from the roster, and participants are only written when the set actually differs. That comparison is also what stops every save being a needless delete-and-reinsert, since setParticipants replaces the whole set.
Definition of done
- Every session on the list carries its participants; a play with nobody gets
[]. - One extra query for the page, not one per session.
- The list names who played, and the winner when there was one.
- Attendance is editable, including adding somebody new.
- A winner can be set, cleared, and is dropped if that player leaves the table.
- An unchanged attendance is not rewritten.
- A roster that fails to load cannot wipe the players.
- Each check proven able to fail.
- Gate green.
Verification
pnpm --filter @tabletop/api testpnpm --filter @tabletop/mobile testpnpm gateSeeded failures
| Seed | Bit |
|---|---|
| Attendance grouped by nothing — everyone on every session | 1 |
| Participants dropped from the response | 2 |
| The editor writes participants every time | 2 |
| Chosen starts empty instead of from the session | 3 |
| A departing player keeps the trophy | 1 |
| The roster is fetched per row rather than on open | 1 |
attendanceKey ignores the winner | 1 |
attendanceKey is order-sensitive | 1 |
A false isWinner counts as a win | 2 |
Nine seeded, nine bit.
A link check that could not fail
Proving the new spec was actually checked turned up something worse than a stale number: @tabletop/docs#build declared no inputs, so turbo hashed apps/docs only. Every spec and ADR lives at the repo root, outside that directory, so editing one never busted the cache and the build — including the link check — replayed a previous pass. Locally it was reporting on 69 files when there were 78.
This is also the real reason WI-039 shipped a broken link. The stated cause, running the gate before writing the spec, was true but not sufficient: running it afterwards would have replayed the cache and passed too. CI caught it only because CI has no cache.
turbo.json now declares ../../docs/** as an input to the docs build and typecheck. Proven by seeding a broken link into this spec and watching the gate go from a cache hit to a failure.
Sweeping for the rest of them
A second instance, and worse. apps/api/scripts/shelf-statuses.test.ts reads apps/mobile/src/api.ts to stop the duplicated SHELF_STATUSES drifting — a duplicate that exists on purpose, because packages/db must never reach a client bundle (ADR-0022). apps/api does not depend on apps/mobile, so turbo never saw the file.
Deleting a status from mobile produced cache hit, 100 passed. The guard written for exactly that drift did not run.
scripts/check-turbo-inputs.mjs is the general case, and runs first in pnpm gate. It scans each package for relative literals that escape it, skipping anything inside a declared workspace dependency — turbo does hash those, which was measured rather than assumed — and reports what is left.
The first version cried wolf on a dozen innocent lines: import specifiers, URL fragments, and path.resolve(dir, "../..") used to find the workspace root. A check somebody turns off is worse than no check, so it now only considers files that actually read the filesystem, and only targets that exist on disk.
Five seeds: both known bugs reinstated, one new undeclared read, and two negative controls — a read into a declared dependency, and a ../ literal that is not a read — which must stay quiet, and do.
Not done here
- Scores. The column and the API accept them; no screen offers one, because a number per player per game needs a design rather than a text field.
- The log form’s long-press winner picker.