Skip to content

feat(web): edit an entry's section — type it, drag it, or capture it from the player - #306

Open
Sawtaytoes wants to merge 8 commits into
mainfrom
feat/section-ui
Open

feat(web): edit an entry's section — type it, drag it, or capture it from the player#306
Sawtaytoes wants to merge 8 commits into
mainfrom
feat/section-ui

Conversation

@Sawtaytoes

Copy link
Copy Markdown
Owner

Builds the part a person actually touches. start.position_ms and end.position_ms have
been stored (#302) and played (#304) since last week; until now nothing on screen said so.

Design and decisions: #299. The duplicate-line id this leans on: #300.

Before

An entry that already carried a section wore a tag reading "Start set" — a label that
names a setting without naming the setting — and its entry sheet offered no way to reach the
value at all.

The wall The entry sheet

After

The tag reads differently in each state, and says nothing when there is none

All four states of the decision's table are reachable and clearable: neither, start only,
end only, both. An entry with no section wears no tag, which is the rule every tag here
follows.

The entry sheet gains a Section row

A summary against the runtime, a Change… that closes the panel and opens the editor, and
the clear right there in the row.

The editor — three routes over one value

Nothing set Typed Dragged
Captured from the player Refused Unknown runtime

Typed is TimecodeInput in isRange mode, bounded by the runtime. It owns its own echo
and its own refusals, and a refused value stays exactly as typed.

Dragged is RangeSlider over 0..duration. onChange paints and onChangeEnd commits,
so a drag does not remount the fields dozens of times.

Captured reads the live playback position. The buttons are disabled rather than absent
this is a passing state, not a missing capability — and the note under them names which of
three reasons is in force: nothing is playing, something else is playing, or no position has
been reported yet. When it can capture it shows the position it would take.

Two decisions worth calling out

An unknown runtime (duration: 0) drops the bar rather than clamping to zero. A slider
needs a scale, and inventing one would draw a proportion that is not true of the item. Passing
durationMs={0} to a TimecodeInput would clamp every typed mark to zero, so 0 becomes
undefined and the field takes no upper bound. Typing and capture still work; the modal says
why the bar is gone.

A backend that cannot play a section shows no control at all — absent, never disabled. The
gate is the provider's declared plays_sections, never kind === 'plex'. A reading queue and
a board-game queue get no tag and no row.

A second section of the same film is reachable

"In this queue" is the right refusal for the ordinary case — an accidental second copy of a
film is a bug, and that badge is what caught it. A demo reel that plays three windows of one
film is the case where a second line is the point, so the refusal keeps its badge and gains a
door: Add another sends allow_duplicate: true, paints the line the server minted, and
opens its settings straight away.

The door …and the second line opens

Light scheme, and the Narrow View

Light Narrow — the tag Narrow — the editor

The tag wraps rather than truncating, because its meaning is entirely in two timecodes and
Section 01:01:00–01:… says only which end was lost. Every other tag keeps truncating, which
is right when the text is a word. The two hh:mm:ss.mmm fields take a smaller type size at
390px so the value reads in full — a type-size change, not a layout change, because the
two-across arrangement belongs to TimecodeInput.

A data-loss bug found on the way

PATCH …/items/<key>/start replaces the whole mapping, and StartModal.readForm() returned
only the unit fields. Changing an entry's episode would have deleted its section's
position_ms
with nothing on screen to notice. withUnit and withPositionMs split the
shared mapping so each writer carries the other's field through, and section.test.ts pins
both directions.

Two more that came out of the same seam:

  • A refused window answers {ok: false, error} at HTTP 200, so nothing throws. commitSection
    reads the body, or it would report "Saved" over a file it did not change.
  • Moving a window later and writing start first lands it against the stored end and is
    refused. isEndFirst() picks the safe order, and only the end that actually moved is written.

Housekeeping

  • One timecode printer. tileFace.clock and NowPlayingBar.toClock were two hand-rolled
    copies in this repo; both are gone, replaced by formatTimecode from the library.
  • One client-side seconds→milliseconds converter. The now-playing payload speaks seconds and
    everything else speaks milliseconds. nowPlaying.nowPlayingPositionMs() is the browser's
    single extrapolator, matching finished.ts nowPlayingMs() on the server — the scrubber and
    the capture buttons ask the same question, and two that drifted would put a mark somewhere
    the bar never showed.
  • hasOverrides() and the toolbar state filter know about a section, or the "has overrides"
    filter lies. The filter gains a "Plays a section only" option, offered only where a section
    can be played.

Server

No server change was needed, and no field was missing. queueTile already sends both
start and end, and ProviderInfo.plays_sections was already on /api/providers. The only
join the browser had to make itself was RegistrySet.provider_kind → the provider list →
plays_sections, which is what the new state/capabilities.ts store does, in one request for
the life of the page.

Gates

yarn install --immutable                       ok
yarn typecheck                                 clean
yarn test                                      web 24 files / 345 tests, server 29 files / 371 tests
yarn workspace queuepilot-web run lint:biome   0 errors, 77 warnings (all pre-existing useExhaustiveDependencies)
yarn workspace queuepilot-web run build        built in 4.84s
yarn workspace queuepilot-server run build     4023.7 KB
e2e/section-ui-test.ts                         section-ui OK   (27 checks, new)
e2e/provider-cache-test.ts                     all passed
e2e/pick-contract-test.ts                      contract holds
e2e/priority-lane-test.ts                      ALL PASS
e2e/skipped-items-test.ts                      all passed
e2e/collection-reorder-test.ts                 all passed
e2e/tile-lane-test.ts                          all passed
e2e/per-entry-shuffle-ui-test.ts               per-entry-shuffle-ui OK

e2e/section-ui-test.ts is a browser gate with no Plex, so it runs on every pull request.
Its PATCHes continue through to the server the harness spawned, so the YAML on disk is the
record of what the app wrote.

Already failing on the merge base and untouched here — all five report
[store] YAML import failed: unable to open database file, identically on 630701f:
topup-test, session-profile-gate-test, rotation-length-test, sse-test,
binding-token-test. tile-menu-test is flaky on the merge base too (3 fails in 4 runs there,
2 in 3 here) — its optimistic lane paint has to survive an SSE-triggered refresh inside a
1200 ms window.

Not done here

The TimecodeInput echo restates the start field whenever it holds text, even when the end
is the field being edited, because the component derives it from texts[activeEndpoint] and
that defaults to "start". It is the library's behaviour and reads as a stray timecode under
the fields. Left alone deliberately: a fix belongs in Charcuterie, not in a local
reimplementation of a component that already owns its echo.

🤖 Generated with Claude Code

Sawtaytoes and others added 8 commits September 2, 2026 02:54
…here

`lib/section.ts` is the pure half of the section editor: it reads
`start.position_ms` and `end.position_ms` off an entry as one `Section`, and
it prints the three tag readings and the panel summary.

Two rules run through the whole file and each has already cost something
somewhere:

* `0` is a real offset — a section that begins on the first frame — so every
  test is `!= null` and never truthiness. `Number(null)` is `0`, which is how
  a cleared window reads as a section at offset zero; the server's own
  `hasSection` was written the wrong way first for exactly this.
* An open end is the ABSENCE of a choice, not `0` and not the runtime. Four
  states, all of them real.

`withUnit` / `withPositionMs` split the shared `start` mapping in two.
`PATCH …/start` replaces that mapping whole, and the entry sheet edits WHICH
unit plays and WHERE IN IT in separate rows, so each writer has to carry the
other's field through. Without it the start picker deleted a section every
time somebody changed the episode, and nothing on screen would have said so.

`startLabel` now answers "" for a start that names no unit. A film section is
exactly that — a position and nothing else, because a film has no season and
no episode — and the tag used to read "Start set", which names nothing.

`nowPlayingPositionMs` is the browser's ONE converter from the now-playing
payload's seconds to the milliseconds everything else speaks; `finished.ts
nowPlayingMs()` is the server's. The Now-playing bar's scrubber and the
section editor's capture buttons ask the same question, and two extrapolators
that drift would put a mark somewhere the bar never showed.

`tileFace.clock` and `NowPlayingBar.toClock` are now `formatTimecode` from
`@charcuterie/ui` — two of the five hand-rolled printers the library counted
across the fleet. The one visible change is a padded minute below an hour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A queue's registry row carries `provider_kind`, not what that backend can
actually do, so the browser has to join the two. `GET /api/providers` already
answers `plays_sections` per provider — reported without instantiating one, so
it works for a backend whose token was never configured.

One fetch for the life of the page, in a module store like `overlays.ts`: the
answer outlives a view, several unrelated components ask for it, and it is not
part of the payload `revalidate()` swaps in and out. A per-tile request would
put an uncached read back on a page that took a lot of work to get to zero.

`usePlaysSections` answers FALSE while the list is in flight and false for a
kind this build has not heard of. No control beats a control nothing serves.
The check is the declared capability and never `kind === "plex"` — a UI that
branches on a backend's name has to be edited again for every future backend.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The control the section feature was missing. `start.position_ms` and
`end.position_ms` have been stored, served and played since #302 and #304;
this is where a person sets them.

A TAG on the tile, reading differently in each of the three states worth
showing and showing nothing at all when there is no section — the rule every
tag here follows. Beside the start tag rather than instead of it: they answer
two different questions, WHICH unit plays and WHERE IN IT, and an episode
entry can carry both.

A ROW in the entry sheet, shaped like the start-point row: a summary, a
Choose…/Change… that opens the modal, and a clear right there in the row.

A MODAL with all three ways in, over one pair of numbers:

* type it — a `TimecodeInput` in `isRange` mode, bounded by the runtime;
* drag it — a `RangeSlider` over `0..duration`, `onChange` to paint and
  `onChangeEnd` to commit;
* capture it — "Start here" / "End here", reading the live playback position.

So a drag moves the fields and a typed value moves the thumbs. `valueMs`
SEEDS, so the fields are re-keyed on the other writers and never on their own
value — keying on the value would remount the control under a live caret.

Four judgements worth naming, three of which are traps:

* AN UNKNOWN RUNTIME DROPS THE BAR, and must. The wire sends `0` for "not
  known", and `durationMs={0}` on a `TimecodeInput` clamps every mark to zero.
  So it becomes `undefined`, the field takes no upper bound, the slider is
  replaced by a line saying why, and the two routes that need no scale still
  work.
* DRAGGING ONE THUMB MUST NOT CLOSE THE OTHER END. An open end has to be drawn
  somewhere, so a `RangeSlider` reports `start: 0` while you drag the end of a
  start-open section. Each end is adopted only when it left where it was drawn.
* THE CAPTURE BUTTONS ARE DISABLED, NOT ABSENT, and say which of three reasons
  is in force. This is a transient state, not a missing capability: the buttons
  come alive the moment this entry is on screen, and a control that is simply
  gone teaches nobody that playing the item would bring it back.
* WHICH WRITE GOES FIRST IS NOT A STYLE QUESTION. `start` and `end` are two
  routes and the pair rule lives on the writers, so moving a window later and
  writing the start first lands it against the stored end and is refused.
  `end` first is safe whenever the new end sits after the stored start; when
  it does not, the window moved earlier and `start` first is safe. Only the
  end that actually moved is written.

A refused window comes back as `{ok: false, error}` at HTTP 200, so `api()`
resolves and nothing throws — the commit reads the body, or it would report
"Saved" over a file it did not change.

The modal names the consequence a reader hits first: a windowed entry on
`watch_history: provider` never completes and replays its section every
sitting, because Plex judges a part-played item as unwatched. Deliberate, and
changed one step away in the entry sheet.

The whole thing is gated on `plays_sections`, so a reading queue and a
board-game queue show no section control at all — not a disabled one.

`#sectionmodal` is named in every `app.css` modal-chrome list. `.subhint`,
`.field` and `.idnote` are each scoped to a modal id in this file, so a new
modal that is not named renders unstyled while looking styled in the source.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two gaps the section editor leaves open on its own.

THE FILTER LIED. `hasOverrides()` read `Boolean(it.start)`, and an entry that
only says where to STOP carries no `start` at all — so "Has overrides" hid it
while claiming to show every entry that deviates from its queue. It reads the
section now, both ends. A "Plays a section only" filter joins the toolbar's
state list, offered only where a section can exist.

"IN THIS QUEUE" WAS A DEAD END. That refusal is right for the ordinary case —
an accidental second copy of a film in a watch queue is a bug, and the badge
is what caught it — but the demo reel this feature exists for plays three
windows of one film, which is three lines. So the badge keeps its place and
gains an explicit "Add another" beside it, which is the one call site that
sends `allow_duplicate: true`.

It paints after the answer rather than before it, and that is forced rather
than chosen: the server MINTS an `id` for the new line and that id is what
keys it, so the browser cannot name the tile before it asks. The same round
trip is what makes the next step work — a second line exists to carry a second
section, so the entry sheet opens straight onto it rather than leaving
somebody to work out which of two identical tiles is new.

Offered only where a section can be played, because that is the whole reason
to want a second line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Twenty-seven checks over the tag, the panel row, the modal and the write. Every one is
either one of the four optionality states or one of the two ways `null` gets confused
with `0`, which is the whole class of bug this feature can have.

The tiles are synthetic — this repo is public, so the gate and its screenshots show
invented films and never a real library. The PATCHes are real: they continue through to
the server the harness spawned, so the YAML on disk is the record of what the app wrote.

Browser, but no Plex, so it runs on every pull request.
Three defects the screenshots found, none of which a unit test could have.

1. "Plays Plays the whole item." The default state returned a whole sentence while the
   other three returned a fragment, and the modal puts its own verb in front — "Will play"
   for a draft, "Plays" for what is stored. `sectionSummary` now returns a fragment in all
   four states and both call sites supply the verb.

2. The tag was cut off in the Narrow View. `Section 01:01:00–01:…` names which end was
   lost and nothing about where the section stops, and the numbers are the whole meaning
   of this tag. It wraps instead, through `Badge`'s own `overflow` prop. Every other tag
   keeps truncating, which is right when the text is a word.

3. Two `hh:mm:ss.mmm` fields side by side did not fit a 390px modal, so the value read
   `01:01:00.00`. A smaller type size in the Narrow View, not a layout change: the
   two-across arrangement belongs to `TimecodeInput`.

The browser gate also polls for the cleared tag rather than sleeping. A clear is two
writes and a refresh, and a fixed wait passed most of the time.
The dragged bar, a mark captured from a live player, the "Add another" door, the light
scheme and the Narrow View — nine screenshots, each driven to its state by a real pointer
or a real click rather than seeded.

`Locator.press` joins the hand-written Playwright slice: `Enter` is what commits a
`TimecodeInput`, which is the only field in the app where typing alone deliberately does
not write.

AGENTS.md gains the new gate and records `.addanother` as a third DOM handle that carries
no rule, beside `.playbtn`.
Eighteen captures, each driven to its state in a real browser. The films are invented and
the queue is the harness fixture — this repo is public, so no screenshot may show the real
library.

The two "before" images are shot on the merge base. They are what made the case: an entry
that already carried `start.position_ms` wore a tag reading "Start set", which names a
setting without naming the setting, and its entry sheet had no way to reach the value at
all.

A pull request outlives the session that opened it, so these live in the branch rather
than behind a temporary share.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant