Review workspace has no next/previous navigation or queue context #95

Closed
opened 2026-07-28 06:03:04 +00:00 by claude-bot · 2 comments

Severity: HIGH - the single most consequential UX gap

The problem

The review workspace has no next or previous control, no queue position, no filmstrip, and no
advance after a decision. After clicking Approve you stay on the same photo with only a badge
change as feedback, then must click "← Back", re-find your place in a grid that has reset its
scroll, and click the next tile.

docs/circa-wireframes.md §3.2 specifies Prev/Next and a queue count. None of it was built. The
only tracked coverage, #45 (keyboard shortcuts), is scheduled in the final milestone and
presupposes navigation targets that nothing creates.

Multiply by thousands of photos over months. This is the difference between a tool that gets used
and one that gets abandoned.

Rather than bolting "Next" onto the grid, define a queue as (filter, sort, cursor) that the
workspace consumes:

  • The browser hands the workspace its current query as router state.
  • The workspace knows its position and can prefetch photo n+1 — detail, evidence, and the image
    itself via new Image().src.
  • "Position 214 of 1,830" falls out naturally.
  • Resume-after-break falls out too: persist the queue and cursor in localStorage.

This same substrate is what the filmstrip, the disputes queue (#23), and the duplicates queue
(#17) all need. Building it once avoids three ad-hoc navigation models.

Scope

  • Queue definition and persistence.
  • Prev/Next controls plus keyboard-agnostic auto-advance after a decision (configurable — some
    reviewers will want to stay and add a comment).
  • Queue position indicator.
  • Prefetch the next photo's data and image.
  • key the DecisionForm by photo.id — its useState-from-props initialization will otherwise
    show stale drafts once in-place navigation exists.
  • Resume where you left off across sessions.

Note the current "queue" — the needs_review filter — reorders as statuses change, so reviewed
photos drop out and the list shifts under you. The persisted cursor must be robust to that.

Done when

  • A reviewer can move to the next photo without returning to the grid
  • Position in the queue is visible
  • The next photo's image and data are already loaded when it appears
  • Closing the browser and returning resumes at the same place
  • Auto-advance after decision works and can be turned off

References

  • frontend/src/pages/ReviewWorkspacePage.tsx (toolbar 452-473, only exit is "← Back" at 441)
  • docs/circa-wireframes.md §3.2

Blocks: #45 (keyboard shortcuts) needs these targets to bind to.

## Severity: HIGH - the single most consequential UX gap ## The problem The review workspace has no next or previous control, no queue position, no filmstrip, and no advance after a decision. After clicking Approve you stay on the same photo with only a badge change as feedback, then must click "← Back", re-find your place in a grid that has reset its scroll, and click the next tile. `docs/circa-wireframes.md` §3.2 specifies Prev/Next and a queue count. None of it was built. The only tracked coverage, #45 (keyboard shortcuts), is scheduled in the final milestone and presupposes navigation targets that nothing creates. Multiply by thousands of photos over months. This is the difference between a tool that gets used and one that gets abandoned. ## Recommended architecture Rather than bolting "Next" onto the grid, define a **queue** as `(filter, sort, cursor)` that the workspace consumes: - The browser hands the workspace its current query as router state. - The workspace knows its position and can prefetch photo n+1 — detail, evidence, and the image itself via `new Image().src`. - "Position 214 of 1,830" falls out naturally. - Resume-after-break falls out too: persist the queue and cursor in localStorage. This same substrate is what the filmstrip, the disputes queue (#23), and the duplicates queue (#17) all need. Building it once avoids three ad-hoc navigation models. ## Scope - Queue definition and persistence. - Prev/Next controls plus keyboard-agnostic auto-advance after a decision (configurable — some reviewers will want to stay and add a comment). - Queue position indicator. - Prefetch the next photo's data and image. - `key` the DecisionForm by `photo.id` — its `useState`-from-props initialization will otherwise show stale drafts once in-place navigation exists. - Resume where you left off across sessions. Note the current "queue" — the needs_review filter — reorders as statuses change, so reviewed photos drop out and the list shifts under you. The persisted cursor must be robust to that. ## Done when - [ ] A reviewer can move to the next photo without returning to the grid - [ ] Position in the queue is visible - [ ] The next photo's image and data are already loaded when it appears - [ ] Closing the browser and returning resumes at the same place - [ ] Auto-advance after decision works and can be turned off ## References - `frontend/src/pages/ReviewWorkspacePage.tsx` (toolbar 452-473, only exit is "← Back" at 441) - `docs/circa-wireframes.md` §3.2 Blocks: #45 (keyboard shortcuts) needs these targets to bind to.
claude-bot added this to the v0.3.0 milestone 2026-07-28 06:03:04 +00:00
Author

Picking this up. Probed first; the architecture the issue recommends is right, but two details in it do not survive contact with the API, and both were settled with @rbrooks before starting.

1. "Position 214 of 1,830" cannot be built, and the wireframe does not ask for it. The issue says the position indicator "falls out naturally". It does not: ListMeta has no total, deliberately — #83 removed it because it puts a COUNT over the whole collection on the hot list path, and its docstring says so. GET /photos/stats counts by status only, so it cannot answer the date-range, undated or flag filters #94 just added, which is exactly what a queue is now defined by.

docs/circa-wireframes.md §3.2 — the reference this issue itself cites — says Queue: 32 left, not a position and total. That is both cheaper and the more useful thing for someone working a session, so the wireframe wins and no count endpoint is being added.

Where the queue is longer than the snapshot, the count is rendered N+ left. The + is the point: the software has not established the true remainder, and printing a number it cannot stand behind is the failure #78 is about.

2. The queue is a snapshot, taken on entry. The issue flags that "the current queue — the needs_review filter — reorders as statuses change, so reviewed photos drop out and the list shifts under you", and asks for a cursor robust to that. A live re-query cannot be made robust to it: approving photo 214 removes it from its own queue, so every subsequent photo renumbers and Next either skips or repeats depending on timing.

So the queue is a frozen ordered list of ids captured when the reviewer enters the workspace. A photo just decided stays in place, marked done, rather than vanishing underneath the reviewer. Position never jumps, prefetch is trivial, and resume-across-sessions is just persisting the list. The cost, stated plainly: photos ingested mid-session do not appear until the queue is rebuilt.

The snapshot is bounded rather than exhaustive. Building it by paging a 30,000-photo queue would be 150 requests at the API's limit ceiling of 200, which is not a page load. It is capped, and capped is what turns 32 left into 32+ left.

Scope being built — the issue's list, unchanged except as above:

  • Queue as (filter, sort) plus a frozen id list, handed from the browser as router state and persisted for resume.
  • Prev/Next, and auto-advance after a decision. Default on, because the milestone's exit criterion is a decision costing one or two interactions instead of seven; switchable off and remembered, since a reviewer adding a comment wants to stay.
  • N left / N+ left.
  • Prefetch of photo n+1 — detail, evidence, and the review-size image via new Image().
  • key the DecisionForm by photo.id. The issue is right that this is load-bearing and it is worth being explicit about why: the form seeds useState from props, and today the workspace is only ever entered by a fresh mount, so nothing has exposed it. In-place navigation reuses the component instance, and without the key the previous photograph's draft dates would sit in the form over the next photograph — a reviewer could approve one photo with another's date and nothing on screen would say so.

Deliberately not in scope: the filmstrip and histogram in §3.2's wireframe, which are their own work, and keyboard shortcuts, which are #45 and are blocked on exactly the navigation targets this creates.

Picking this up. Probed first; the architecture the issue recommends is right, but two details in it do not survive contact with the API, and both were settled with @rbrooks before starting. **1. "Position 214 of 1,830" cannot be built, and the wireframe does not ask for it.** The issue says the position indicator "falls out naturally". It does not: `ListMeta` has **no total**, deliberately — #83 removed it because it puts a `COUNT` over the whole collection on the hot list path, and its docstring says so. `GET /photos/stats` counts by *status only*, so it cannot answer the date-range, undated or flag filters #94 just added, which is exactly what a queue is now defined by. `docs/circa-wireframes.md` §3.2 — the reference this issue itself cites — says **`Queue: 32 left`**, not a position and total. That is both cheaper and the more useful thing for someone working a session, so the wireframe wins and **no count endpoint is being added**. Where the queue is longer than the snapshot, the count is rendered **`N+ left`**. The `+` is the point: the software has not established the true remainder, and printing a number it cannot stand behind is the failure #78 is about. **2. The queue is a snapshot, taken on entry.** The issue flags that "the current queue — the `needs_review` filter — reorders as statuses change, so reviewed photos drop out and the list shifts under you", and asks for a cursor robust to that. A live re-query cannot be made robust to it: approving photo 214 removes it from its own queue, so every subsequent photo renumbers and `Next` either skips or repeats depending on timing. So the queue is a frozen ordered list of ids captured when the reviewer enters the workspace. A photo just decided **stays in place**, marked done, rather than vanishing underneath the reviewer. Position never jumps, prefetch is trivial, and resume-across-sessions is just persisting the list. The cost, stated plainly: photos ingested mid-session do not appear until the queue is rebuilt. The snapshot is bounded rather than exhaustive. Building it by paging a 30,000-photo queue would be 150 requests at the API's `limit` ceiling of 200, which is not a page load. It is capped, and `capped` is what turns `32 left` into `32+ left`. **Scope being built** — the issue's list, unchanged except as above: - Queue as `(filter, sort)` plus a frozen id list, handed from the browser as router state and persisted for resume. - Prev/Next, and auto-advance after a decision. Default **on**, because the milestone's exit criterion is a decision costing one or two interactions instead of seven; switchable off and remembered, since a reviewer adding a comment wants to stay. - `N left` / `N+ left`. - Prefetch of photo n+1 — detail, evidence, and the review-size image via `new Image()`. - `key` the `DecisionForm` by `photo.id`. The issue is right that this is load-bearing and it is worth being explicit about why: the form seeds `useState` from props, and today the workspace is only ever entered by a fresh mount, so nothing has exposed it. In-place navigation reuses the component instance, and without the key the *previous* photograph's draft dates would sit in the form over the next photograph — a reviewer could approve one photo with another's date and nothing on screen would say so. Deliberately **not** in scope: the filmstrip and histogram in §3.2's wireframe, which are their own work, and keyboard shortcuts, which are #45 and are blocked on exactly the navigation targets this creates.
Author

Done in 0afccbf (auto-closed by the commit trailer). CI run #57 green on all three jobs.

Done when:

  • A reviewer can move to the next photo without returning to the grid
  • Position in the queue is visible — as N left per the wireframe, not 214 of 1,830; see my earlier comment for why the total is not buildable
  • The next photo's image and data are already loaded when it appears
  • Closing the browser and returning resumes at the same place
  • Auto-advance after decision works and can be turned off

121 frontend tests (was 87) and 7 e2e journeys.

The key={photo.id} fix deserves a note, because the test for it was wrong first and that is the interesting part.

The issue predicted this defect exactly, and it is real. What neither of us anticipated is that prefetching is what makes it reachable. When the reviewer moves to the next photograph, the workspace's useQuery for the new id has no cached data, so the page early-returns Loading photo… — which unmounts DecisionForm and gives it fresh state whether it is keyed or not. The stale draft cannot occur.

Prefetch removes that. With the next photograph already in the cache there is no loading state, the component instance is reused, and the previous photograph's dates sit in the form. So the two halves of this issue's scope interact: the prefetch requested in one bullet is what arms the bug named in another.

The first version of the test moved between photographs without seeding the cache, so it passed with key={photo.id} deleted — a test asserting the right thing about the wrong path. It now seeds the query cache the way the prefetch does, and fails correctly when the key is removed. Three other mutations were checked and each failed exactly the test written for it: capped ignoring the overshoot case, auto-advance ignoring its own preference, and advancing past a decision the server refused.

One limit worth stating rather than leaving to be discovered. The snapshot is capped at 500 ids, so a queue longer than that reports N+ left and Next stops at the 500th. Reaching the end means going back to the grid to take a fresh snapshot. That is a deliberate trade — the alternative was 150 round trips before first render on a 30,000-photo queue — but it is a real edge a reviewer working a very long session will hit, and if it becomes annoying the fix is to extend the snapshot in the background rather than to raise the cap.

Three existing photo-browser tests had to change: navigate now takes queue state as a second argument. Not a regression, but worth knowing the call signature moved. I added an assertion that the actual current filter travels, rather than only that some state does.

#45 (keyboard shortcuts) is now unblocked — previous and next in useReviewQueue are the targets it needs to bind to.

Done in 0afccbf (auto-closed by the commit trailer). CI run [#57](https://git.rhoving.com/rbrooks/Circa/actions/runs/57) green on all three jobs. **Done when:** - [x] A reviewer can move to the next photo without returning to the grid - [x] Position in the queue is visible — as `N left` per the wireframe, not `214 of 1,830`; see my earlier comment for why the total is not buildable - [x] The next photo's image and data are already loaded when it appears - [x] Closing the browser and returning resumes at the same place - [x] Auto-advance after decision works and can be turned off 121 frontend tests (was 87) and 7 e2e journeys. **The `key={photo.id}` fix deserves a note, because the test for it was wrong first and that is the interesting part.** The issue predicted this defect exactly, and it is real. What neither of us anticipated is that **prefetching is what makes it reachable**. When the reviewer moves to the next photograph, the workspace's `useQuery` for the new id has no cached data, so the page early-returns `Loading photo…` — which unmounts `DecisionForm` and gives it fresh state whether it is keyed or not. The stale draft cannot occur. Prefetch removes that. With the next photograph already in the cache there is no loading state, the component instance is reused, and the previous photograph's dates sit in the form. So the two halves of this issue's scope interact: the prefetch requested in one bullet is what arms the bug named in another. The first version of the test moved between photographs without seeding the cache, so it **passed with `key={photo.id}` deleted** — a test asserting the right thing about the wrong path. It now seeds the query cache the way the prefetch does, and fails correctly when the key is removed. Three other mutations were checked and each failed exactly the test written for it: `capped` ignoring the overshoot case, auto-advance ignoring its own preference, and advancing past a decision the server refused. **One limit worth stating rather than leaving to be discovered.** The snapshot is capped at 500 ids, so a queue longer than that reports `N+ left` and Next stops at the 500th. Reaching the end means going back to the grid to take a fresh snapshot. That is a deliberate trade — the alternative was 150 round trips before first render on a 30,000-photo queue — but it is a real edge a reviewer working a very long session will hit, and if it becomes annoying the fix is to extend the snapshot in the background rather than to raise the cap. Three existing photo-browser tests had to change: `navigate` now takes queue state as a second argument. Not a regression, but worth knowing the call signature moved. I added an assertion that the *actual current filter* travels, rather than only that some state does. #45 (keyboard shortcuts) is now unblocked — `previous` and `next` in `useReviewQueue` are the targets it needs to bind to.
Sign in to join this conversation.
No description provided.