# BGG "Add to Collection" flow — recon notes for the upload stage

Recorded 2026-08-01 by walking the flow manually on boardgamegeek.com (Wingspan,
id 266192) while logged in. Dialog opened, inspected, and **cancelled without
saving**. These notes are the selector documentation the spec requires before
automating Stage 5.

## Entry point

- Game page has **two** "Add To" buttons (one in the game header module, one
  lower on the page) — target the first, but match by accessible name, not
  position. Button's accessible name is "Add To" with adjacent text
  "Collection".
- Clicking opens a `role="dialog"` containing a `<form>`. Dialog heading shows
  a "Loading..." span before content settles — **wait for the game-name
  heading** (e.g. `getByRole('heading', {name: gameName})`) before
  interacting.

## Main dialog structure

- **Status checkboxes**, each wrapped in a `<label>`: Own, Prev. Owned,
  For Trade, Want to Play, Want in Trade, Want to Buy, Pre-ordered, Wishlist.
  → `dialog.getByLabel('Own')` and check it. Nothing is pre-checked.
- Rating: a 1–10 slider plus a "Rating" text input. We do not set ratings.
- Comment (public) textbox — unused by us.
- "Advanced (private info, parts exchange)" expander: Price Paid, Current
  Price, Quantity, Acquisition Date, Acquired From, Inventory Date/Location,
  Private Comment, Want/Has Parts. All unused (we don't track provenance).
- "Customize Item Info (title, image)" expander: Custom Title, Custom Image
  Id, plus **manual version-override fields** (Publisher Id, Language select,
  Year, Other, Barcode). These are for defining a custom version — do NOT use
  them; always pick a cataloged version instead (or none).
- Footer: `Save` (`type="submit"`) and `Cancel` buttons.

## Version picker ("Set version/edition")

- Button labeled **"Set version/edition"** near the top of the dialog swaps
  the dialog content to a "Versions" sub-view (same `role="dialog"`).
- The sub-view is a **paginated list with NO search/filter box**. Each
  listitem's text is the full canonical version name + year, e.g.
  "Flügelschlag (German fifth edition) (2024)" — these names match the
  version names returned by `/thing?id=X&versions=1`.
- Selection strategy: resolve the target version NAME from the XML API,
  then page through the list matching listitem text
  (`getByRole('listitem').filter({hasText: versionName})`). Newest years
  appear first.
- The sub-view has its own **Cancel** that returns to the main dialog — it
  is a different button from the main dialog's Cancel. Two-level dismissal.

## Automation gotchas observed

1. **Element references go stale constantly.** The page re-renders after
   load and after every dialog transition; clicks on cached handles silently
   miss. Playwright's auto-waiting role/label locators handle this — never
   cache element handles across a dialog state change.
2. **Dialog persists in the DOM after cancel**, just hidden
   (`offsetParent === null`). "Is the dialog gone" checks must test
   visibility, not existence. Same applies when verifying a save completed.
3. First click on "Add To" right after page load can no-op (hydration race).
   Wait for network-idle or the button's stable state before clicking.
4. Verify saves via the collection API (`--verify`), not by UI state.

## Playwright locator sketch

```python
page.get_by_role("button", name="Add To").first.click()
dialog = page.get_by_role("dialog")
dialog.get_by_role("heading", name=game_name).wait_for()
dialog.get_by_label("Own").check()
if version_name:
    dialog.get_by_role("button", name="Set version/edition").click()
    # page through listitems until version_name matches, then click it
dialog.get_by_role("button", name="Save").click()
```

Unverified so far (needs a real, sacrificial save on one game before batch
runs): pagination controls in the version sub-view, exact post-save behavior
(toast? dialog close? redirect?), and how the dialog differs when the game is
ALREADY in the collection (second-copy flow must create a new entry, not edit
the existing one).

## Login page (recon 2026-08-01, anonymous probe via Playwright)

- **Cloudflare Turnstile blocks headless browsers outright**: the headless
  shell never gets past "Just a moment..." (`cf-turnstile-response` hidden
  input, no form). A normal **headed** Chromium passed the check without
  interaction. Hence `bggpipe upload` runs headed by default; `--headless`
  exists but expect login to fail there. A first login in headed mode may
  still need one human click on the challenge widget; the session then
  persists via `storage_state.json` (gitignored).
- **BGG pages never reach Playwright's `networkidle`** — ad/analytics
  requests poll forever. Navigate with `wait_until="domcontentloaded"` and
  rely on element-level auto-waiting.
- Verified form selectors at `/login`: `#inputUsername` (name=`username`,
  formcontrolname=`username`), `#inputPassword`, and a button with
  accessible name **"Sign In"** (`type="button"` — Angular handles submit,
  so click the button rather than pressing Enter and hoping for a form
  submit). Labels "Username"/"Password" point at those ids. Cookie-consent
  checkboxes (Essential, Performance Analytics, ...) render on the same
  page but did not overlay the form in the probe.
- Logged-in detection heuristic (unverified): the header shows a "Sign In"
  link only when logged out.


## Verified against the live site (2026-08-06, first real uploads)

The add flow works end to end; every failure on the way was in code the
earlier walkthrough had marked *verified*, and the parts marked
*unverified* were mostly right. Corrections:

- **Sign In is an `<a class="btn">` with no `href`.** It therefore has no
  implicit `link` role: `get_by_role("link", name="Sign In")` matches
  nothing in any state. The header also hydrates after
  `domcontentloaded`, so for a moment neither Sign In nor Sign Out
  exists — a check resting on one absence silently concludes "signed in"
  and browses anonymously. Poll until one control or the other proves the
  state; treat "neither, after 30s" as an error.
- **`get_by_label("Own")` also matches "Prev. Owned."** Use
  `get_by_role("checkbox", name="Own", exact=True)`.
- **Version rows** are the `<li>`s carrying a thumbnail:
  `li:has(.summary-item-thumbnail)`. Plain `listitem` also catches the
  paging `<li>`s ("First", "Prev", "1", "…").
- **Paging is an AngularJS `<ul class="pagination">` of anchors**, not
  buttons: `a[title="Next Page"]`, with the parent `<li>` gaining
  `disabled` at the end. Paging is client-side over an already-loaded
  list — no request per page.
- **Every paging control renders TWICE**: a desktop set and a mobile set
  inside `<li class="visible-xs-*">`. A selector matches both, and
  `.first` may be the hidden one — Playwright then waits for it to become
  visible until it times out. Always click the first *visible* match.
  First/Prev may be unclickable on a desktop viewport (mobile-only
  variant). To return to page 1, click the visible numbered **"1"**
  anchor: the sub-view's paging state SURVIVES closing and reopening it
  (Angular keeps the scope), so a reopen lands wherever it was left, not
  on page 1.
- **Match row text in Python, not with `has_text`.** Playwright's
  `has_text` regex tests raw `textContent`, which carries the markup's
  tabs and newlines; a whitespace-normalized capture will never equal it.
  Normalize both sides yourself and click the row by index.
- **Row text is `<game name> (<version name>) (<year>)`**, and the game
  name is localized (a Czech edition's row starts "Spící bohové"). Match
  the version name inside its parentheses.
- **The API's version name is not always the picker's string.** BGG's
  XML gives e.g. `English edition 2018-2` where the picker shows
  `(English edition) (2018)`. Match the full name first, then retry with
  a trailing year/printing qualifier stripped — and if that relaxed match
  hits more than one row, refuse and add version-less (never guess).
- **An owned game's page has no "Add To" button**; it reads
  "In Collections (Own…)". That is the update flow's entry point.

### The update flow (verified 2026-08-06)

Do **not** go through the game page's dialog for an existing entry — it
cannot say which copy it edits. The collection table can:

1. `/collection/user/<user>?objectid=<bgg_id>&own=1`.
2. The version cell carries its own collid:
   `td.collection_version[onclick*="<collid>"]`. Clicking it opens an
   inline editor (the cell shows *Editing* meanwhile).
3. The editor is a radio list whose **values are version ids**:
   `form[id^="form_version"] input[type="radio"][value="<version_id>"]`.
   Both the copy and the edition are therefore addressed exactly — no
   name matching, no pagination.
4. Clicking the radio fires `CE_SaveData(cellid, collid, 'version')`
   itself. **There is no Save button.** The save has landed when the
   cell's text stops reading *Editing*.

This is strictly additive: it sets one field on one collid and cannot
create a duplicate entry.

### BGG's collection export lags the site

After a successful update the website shows the new version immediately
(the row's version cell reads e.g. "English first edition Year: 2012",
and reopening the editor shows that radio checked), but the XML API's
`/collection` export can still report the entry with no version — even
with a cache-busting re-request. `diff` reads the API, so it will
re-queue work that has already landed.

Consequences to keep in mind rather than "fix":

- The upload log is the authority on what this tool did; the API is the
  authority on what BGG has published. They disagree for a while.
- `build_queue` skipping a job whose log says done is CORRECT here — the
  work is applied, and re-running it would be a no-op at best.
- Anything user-facing should count PENDING jobs (queue rows minus what
  the log completed), never raw queue rows, or settled work reads as
  outstanding until the next diff.
