C1 moved the optimistic flip onto the key the page reads — and so made visible that nothing corrects
it: the resync only invalidated `plex-library`, so a failed toggle stayed "watched" beside its own
error toast, and the collection's other cached sort orders stayed stale after a good one. The
resync now takes this page's `detail` prefix too, as the Discover and person pages do for theirs.
The C1 test holds the request open, so it still pins the optimistic write on its own.
Deep-review R3. When 889d2acf put the sort into the collection detail's query key, two things on
the page did not follow it:
- The watched toggle's optimistic write kept the key's old spelling. setQueryData matches exactly,
so it landed on an entry nothing reads, and its resync only invalidates the library root — the
click succeeded server-side and changed nothing on screen (badge, icon, aria-label) until a
reload; a second click toggled it back. One `detailKey` now serves the read and the write.
- Every sort change was a key with no data yet, so the whole page — hero, grid, and the Sort control
with its open panel — was swapped for a spinner, and focus fell to <body>: key and direction could
not be set in one visit. The previous page now stays on screen while the new order loads, for the
SAME collection only (stepping to another must not show this one's hero meanwhile).
Verified on localdev (Rocky Collection): the toggle flips the poster; IMDb rating then Descending set
in one open panel, the hero never blanked, order reversed. 3 mutations, each red on its test.
Two halves of the same report ("Collectionben nem működik a rendezés?"). The pasted browse URL
sorts correctly — that part was working. What did not:
The Collection INFO page had no sort control at all and a hardcoded release-year-ascending order.
It now shows the browse grid's Sort control, with the browse grid's vocabulary, because a second
sort vocabulary on a neighbouring surface is exactly the kind of drift this repo keeps paying for:
`PLEX_SORT_KEYS` is now one tuple in `plex/query.py`, and a test pins BOTH implementations to it —
browse's key→column map (extracted from the handler to be reachable) and the collection page's
key→member map. A key added to the vocabulary without teaching one of them fails the gate instead
of putting a dead option in the menu.
Ordering a collection cannot be a single ORDER BY: its two legs are different tables, so the merged
member list is sorted in Python. Members with no value for the key sink to the END in both
directions (a movie with no rating is not "the worst rated" — the browse grid's `nullslast()`), and
ties always resolve title-ascending, so flipping the direction reverses the ranking rather than the
alphabet inside it. `lastwatched` needed the show leg's `resumed_at`, so the page now takes ONE
rollup pass that yields both the badge status and the recency instead of the status alone — without
it that menu entry would have ordered every show as never-watched. The default stays release-year
ascending: a franchise reads in release order, and that is what the page did before.
The other half: a shared Plex link carries the collection filter as bare rating_keys, so a link
opened cold had no name for them — the sidebar token rendered `17045` and the chip could only say
"Collection". `GET /api/plex/collections` takes a `keys=` CSV now, and a display-only hook resolves
just the missing ids. It deliberately does NOT write them into `collectionTitles`: that map is
hashed into the Plex query keys, so storing a resolved label there would split the cache from the
identical state and refetch the grid for a piece of text.
Verified in the browser on localdev against the real Plex library: the movie collection's menu
offers Length and the show collection's does not (the same scope rule as browse); rating-ascending
reorders 8.1/7.4/7.2/6.6/6.5 correctly; `lastwatched` on a show collection brings the in-progress
shows up; the choice survives a reload (it is a server pref, on its own key so opening a collection
never re-sorts the library behind it); and the reported URL now reads "Pirates of the Caribbean
Collection" in both the sidebar and the chip.
12 pytest + 8 vitest, mutation-checked against 14 mutations (direction ignored, nulls first, no
stable tiebreak, unknown key untouched, wrong default, show recency dropped, empty `keys=` falling
through to everything, a key missing from either sort map, every id refetched, ids unsorted,
fetched titles unmerged, the chip ignoring them, the hook's result never reaching the builder) —
each red on exactly the test that names it.
S3 wires the shelf's editor. The gesture is the E10 right-click menu, so the
decision "this is comfort media" can be made where the thing is — the feed, a
Plex grid, a channel row, a playlist — rather than only on the page that lists
the shelf.
One shared piece, `components/comfortPin.tsx`: the five pin-kind constructors,
the shelf query, and ONE `ActionMenuItem` that switches Pin/Unpin honestly (and
says "not known yet" with a disabled row until the shelf has answered, since a
confident "Pin" over something already pinned is exactly the quiet lie the house
rules forbid). Surfaces that already have a row menu compose the item in
(`Playlists`, `PlaylistsRail`); the rest mount `ComfortPinMenu`.
Two structural notes:
- The feed's menu lives in `VirtualFeed`, not in `Feed` — that is the list, and
it is also what gives the channel page the gesture for free, since it renders
`<Feed channelScoped>` all the way down.
- Cards take `menuProps` as a FUNCTION of the row, not the built props object.
`triggerProps` is stable across renders while its return value is not, and a
fresh object prop would blow past `VideoCard`'s memo for every card in the
virtualized feed on every parent render.
`DataTable` and `ChannelLayoutGrid` grow a generic `rowProps` so a channel row
answers the same gesture in both of the manager's layouts; the table stays
domain-free and never learns what a menu is.
On Home the shelf tile's menu is the whole editor: four move rows (front / left
/ right / end, reusing the playlist menu's ordering idiom) plus the unpin, over
menu rows rather than drag & drop — dragging inside a horizontal, scrolling
Carousel fights the scroll, and menu rows are keyboard-reachable for free. The
reorder is optimistic on the cached payload and re-asks the server on failure
rather than showing an order that was never accepted.
The exhausted shelf tile (a pinned channel with nothing unwatched left) is
`aria-disabled`, not `disabled`: measured in Chromium, a disabled button still
receives contextmenu and pointerdown, but it loses focus — and with it Shift+F10,
the only keyboard route to the menu it most needs.
Verified end to end in the browser against the live account: all five pin kinds,
the reorder (persisted across a reload), the unpin, and the honest Unpin label on
a resume tile that is already on the shelf.
The app HAS a platform layer -- AnchoredPopover (22 importers, with
arrows/typeahead/Escape/anchor-flip), QueryState (21), Modal (17),
PageShell, DataTable, Tabs, Tooltip and ~26 more -- but it sat flat among
~160 feature components with nothing marking it as one. Several of those
files describe themselves as 'the one place' or 'canonical' and were
still bypassed by hand-rolled markup, because a layer nobody can see is a
layer nobody uses; three separate inventory passes of this codebase
misread it the same way.
Moves the 33 domain-free modules into components/ui/, rewrites the
imports in 89 files, and makes the boundary a lint rule rather than a
convention: inside ui/ a one-level-up import (a feature component) and
any lib/api slice are errors. The rule was verified to fire on both and
to leave generic lib/ helpers alone. README.md states which side of the
line a component belongs on and why.
No behaviour change: 3x tsc, eslint, knip, prettier and 687 vitest tests
all green; no backend file touched.
Extract the show->ordered-episode-keys flatten into one pure, unit-tested
helper (lib/plexQueue.ts) and use it at all three sites — the collection
Play-all (S4) and the show/season play-through pages in plexViews — so the
season-then-episode queue-order invariant lives in one place instead of three
hand-rolled copies. Behaviour-identical (the helper's ?? [] is a safe superset
of the collection site's previously non-null read).
Also: a show-collection whose shows expand to zero episodes had an enabled
Play-all that silently no-op'd — the same 'does nothing' state S4 removed for
the empty-queue case. Surface an honest info notice (plex.collection.playEmpty,
HU+EN) instead.
A collection's Play-all/Shuffle queue held only playable leaves
(movies/episodes), filtering out shows — so on a show-collection the queue
was empty and Play-all was hidden/dead. Build the queue by expanding each
show member into its episodes (season then episode order), concatenated in
member order; leaves still queue as themselves. Shows are fetched lazily on
click (parallel plexShow fetches), not on page load. Gate Play-all/Shuffle
and the poster on "has any playable member" (leaves OR shows), and disable
them while the queue builds. Scope: option A — no member manual-order
migration. Adds plex.collection.playFailed (HU/EN).
The notifyCollectionEditError helper localizes the backend's duplicate-name
409 to plex.collection.nameTaken; it was applied to the New-collection panel
but two sibling collection-write sites still routed 409 through
notifyActionError, surfacing the server's raw English detail under the HU UI:
- PlexCollectionView.doRename (rename collision)
- PlexCollectionEditor.createNew (create-in-editor duplicate name)
Both now use notifyCollectionEditError, matching the create path. Also adds a
direct dom test for the extracted PlexLibraryPicker covering the enabled gate,
emptyHint, results, honest error, and no-results branches.
AddItemPanel (Collection page) and NewCollectionPanel (browse) had near-
duplicate debounced library-search + result-list rendering. Extract it into
one PlexLibraryPicker that owns the query, the loading/error/empty states and
the row markup; callers keep their own chrome and pass term + renderAction.
No behaviour change.
Round of USER-triggered deep-review fixes (11 CONFIRMED + 1 PLAUSIBLE, all
verified) on the 22-commit branch:
- collections: split the mutate-then-resync error surface. A resync READ that
fails after a successful Plex mutation no longer reports a false 422 "Plex
write failed" + rollback; it returns 502 "sync pending" (the next full sync
reconciles the mirror). New _write_or_pending_error helper; delete is exempt
(no Plex read). Covered by new happy-path / 422-rollback / 502 tests.
- New-collection dup-name guard is now advisory only: existingNames is the
search-scoped, all-library union, but the backend enforces uniqueness PER
library — so it no longer blocks a valid same-name collection in another
library, and the backend 409 is localized to nameTaken.
- member-remove now confirms before the write-through (matches delete-collection).
- add-title / new-collection library search render an error state on request
failure instead of a misleading "No matches".
- history: seed navCounter past the current entry's persisted sfScroll on load
so post-reload ids can't collide with back-stack entries and mis-restore scroll.
- PageScroller: LRU-cap the navId-keyed savedScroll map (was unbounded).
- share isIntrinsicReadOnly between the browse tile and the info page so the
lock/edit affordance can't drift.
Clicking a collection tile now opens a dedicated Collection info page (like the Plex-web
one) instead of just applying it as a filter. New GET /collections/{rk} returns the hero
aggregates (year range, most-restrictive member cert, total runtime) + the members ordered
by release year then title; a new PlexCollectionView renders the hero (art/title/meta/
summary+More) and the member grid, with a Play-all and Shuffle over the playable leaves
(reusing the player's existing queue + auto-advance).
For a non-external, admin-enabled collection the page offers rename / delete / add-title
(a search picker) / remove-item, all through the pre-existing admin endpoints that write
THROUGH to Plex (each edit awaits and re-fetches — no optimism, since a Plex write can 422).
An eligible collection shows "Enable editing" first (the DB editable flag). Read-only ones
(smart / imdb / tmdb / … auto-lists) are marked with a lock on both the info page AND the
browse tile, up front.
New `{ kind: "collection" }` sub-view wired through plexSubUrl (bookmarkable ?v=collection:rk),
PlexBrowse (render branch, Backspace back-nav, tile handler), with a round-trip test.
Verified live: tile→page, hero + chronological members (James Bond 1962→2021, PG-13, 55h),
Play/Shuffle, Enable editing → edit controls + remove-X, the add search (marks already-in
titles "Added"), and the read-only lock on external collections. Real Plex mutations wired
but not executed against the live account.
Gate: tsc/lint/knip/prettier clean, 424 vitest; backend ruff clean, 6 collections tests
(+ a detail-hero/ordering assertion).