feat(404): weighted-distribution SPA — per-source weight inputs + WeightedShuffle order #462

Merged
timothy merged 2 commits from feat/404-weight-ui into main 2026-07-19 12:01:41 +02:00
Owner

The UI half of #70 (backend + API shipped in PR #402). Pure SPA + docs — no new endpoint/DTO, no server-state effect (weight was already on MultiCollectionItemRequest/…ResponseModel, and WeightedShuffle already in the PlaybackOrder enum).

What landed

  • Multi-collection editor (/app/multi-collections): per-source weight input (1..1000, mirroring the API's MultiCollectionItemWeight validator), a computed % share (3:1 → 75% / 25%), and a "Reset to fair share" action. Weight round-trips through the editor draft (read in itemsFromMultiCollection, written in toItemRequest) so the replace-all PUT never silently resets it — the documented #404 gotcha.
  • Classic schedule editor: WeightedShuffle offered as a Playback Order only for MultiCollection sources (itemRules.ts MULTI_COLLECTION_ORDERS). It needs per-source weights and the classic write path rejects it elsewhere (playlist/block). Also excluded from fillWithGroup like ShuffleInOrder — PlayoutBuilder schedules fill-groups per-group, incompatible with WeightedShuffle's whole-collection per-source airtime share.
  • Input gained min/max/inputMode/onBlur passthroughs for bounded numeric fields (reusable by #425). Weight held as a string for smooth editing; clamped on blur and again at save so an out-of-range value never surfaces a raw 400.

Design decisions

  • Fair-share is a reset button, not a stateful toggle — fair-share = WeightedShuffle with all weights at the default 1 (decisions.md 2026-07-17); there is no stored representation to toggle.

Tests / verification

  • itemRules.test.ts: WeightedShuffle offered only on MultiCollection; excluded from fillWithGroup.
  • MultiCollectionsScreen.test.tsx: weight round-trip (no silent reset), out-of-range clamp, fair-share reset, updated PUT/POST payloads.
  • Full web suite green (860 tests), lint clean, typecheck clean, production build OK.
  • Live-E2E (set weights → build a playout → confirm rundown matches ratio): pending, will post before merge.

Docs (same PR)

  • docs/domain-model.md (Playback order + MultiCollection rows), docs/spa-conventions.md §4 (replace-all round-trip trap + bounded-numeric-input pattern), docs/decisions.md (new entry). No API change → no OpenAPI regen.

fixes #404

🤖 Generated with Claude Code

The UI half of #70 (backend + API shipped in PR #402). **Pure SPA + docs** — no new endpoint/DTO, no server-state effect (`weight` was already on `MultiCollectionItemRequest`/`…ResponseModel`, and `WeightedShuffle` already in the `PlaybackOrder` enum). ## What landed - **Multi-collection editor** (`/app/multi-collections`): per-source **weight input** (1..1000, mirroring the API's `MultiCollectionItemWeight` validator), a computed **% share** (3:1 → 75% / 25%), and a **"Reset to fair share"** action. Weight **round-trips** through the editor draft (read in `itemsFromMultiCollection`, written in `toItemRequest`) so the replace-all PUT never silently resets it — the documented #404 gotcha. - **Classic schedule editor**: `WeightedShuffle` offered as a Playback Order **only** for `MultiCollection` sources (`itemRules.ts` `MULTI_COLLECTION_ORDERS`). It needs per-source weights and the classic write path rejects it elsewhere (playlist/block). Also excluded from `fillWithGroup` like `ShuffleInOrder` — `PlayoutBuilder` schedules fill-groups per-group, incompatible with WeightedShuffle's whole-collection per-source airtime share. - **`Input`** gained `min`/`max`/`inputMode`/`onBlur` passthroughs for bounded numeric fields (reusable by #425). Weight held as a string for smooth editing; clamped on blur and again at save so an out-of-range value never surfaces a raw 400. ## Design decisions - **Fair-share is a reset button, not a stateful toggle** — fair-share = `WeightedShuffle` with all weights at the default 1 (decisions.md 2026-07-17); there is no stored representation to toggle. ## Tests / verification - `itemRules.test.ts`: WeightedShuffle offered only on MultiCollection; excluded from fillWithGroup. - `MultiCollectionsScreen.test.tsx`: weight round-trip (no silent reset), out-of-range clamp, fair-share reset, updated PUT/POST payloads. - Full web suite green (860 tests), lint clean, typecheck clean, production build OK. - **Live-E2E** (set weights → build a playout → confirm rundown matches ratio): pending, will post before merge. ## Docs (same PR) - `docs/domain-model.md` (Playback order + MultiCollection rows), `docs/spa-conventions.md` §4 (replace-all round-trip trap + bounded-numeric-input pattern), `docs/decisions.md` (new entry). No API change → no OpenAPI regen. fixes #404 🤖 Generated with [Claude Code](https://claude.com/claude-code)
timothy added 1 commit 2026-07-19 11:28:35 +02:00
feat(404): weighted-distribution SPA — per-source weight inputs + WeightedShuffle order
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 11s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 7s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m39s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 16m37s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 21m41s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 24m50s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
d639946b5c
The UI half of #70 (backend + API shipped in PR #402). Pure SPA + docs — no new
endpoint/DTO (`weight` was already on MultiCollectionItemRequest/Response, and
`WeightedShuffle` already in the PlaybackOrder enum).

- Multi-collection editor (`/app/multi-collections`): per-source weight input
  (1..1000, mirroring the API validator), a computed % share (3:1 shows 75/25),
  and a "Reset to fair share" action (fair-share = all weights 1, decisions.md
  2026-07-17 — a reset, not a separate mode). Weight round-trips through the
  draft (read in itemsFromMultiCollection, written in toItemRequest) so the
  replace-all PUT never silently resets it.
- Classic schedule editor: `WeightedShuffle` offered as a Playback Order ONLY
  for MultiCollection sources (itemRules `MULTI_COLLECTION_ORDERS`) — it needs
  per-source weights and the write path rejects it elsewhere. Excluded from
  fillWithGroup like ShuffleInOrder (PlayoutBuilder schedules fill-groups
  per-group, incompatible with whole-collection weighted share).
- `Input` gained min/max/inputMode/onBlur passthroughs for bounded numeric
  fields (reusable by #425's weight UI); weight held as a string for smooth
  editing, clamped on blur and at save so an out-of-range value never 400s.
- Docs: domain-model + spa-conventions (replace-all round-trip trap, bounded
  numeric input pattern) + decisions.md entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
timothy added 1 commit 2026-07-19 11:39:32 +02:00
test/docs(404): add unrelated-edit round-trip test; correct MultiCollection-only rationale
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 7s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 12s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 23s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m20s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 18m48s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 15m10s
d2d678aae8
Review + live-E2E follow-ups (no behavior change):
- Add a test proving the canonical silent-reset trap directly: a rename-only save
  round-trips both weights untouched (the prior test only edited the weight it
  asserted). Cold review nit.
- Correct the rationale in itemRules.ts + decisions.md: WeightedShuffle is
  MultiCollection-only in the SPA for *meaningfulness* (per-source weights need
  2+ sources), NOT because the classic write path rejects it — live-E2E confirmed
  the classic engine ACCEPTS it on a plain Collection (200) and degrades to
  fair-share. The rejection is on the separate playlist/block write paths, whose
  editors keep their own order lists and already omit it.

Live-E2E (real API): weighted multi-collection create + read round-trips weights;
rename-only PUT preserves them (no silent reset); WeightedShuffle persists on a
classic MultiCollection schedule item. Ratio itself is pinned by the existing
PlayoutBuildGoldenTests.Classic_weighted (3:1), untouched by this SPA change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
Owner

Cold adversarial review + live-E2E — MERGEABLE

Independent cold review (fresh agent, review-only) of the diff: MERGEABLE-WITH-NITS — no Blocker/High/Medium. clampWeight edge cases ('', NaN, negative, decimals, 1e3, huge) all verified to snap into [1,1000]; weight round-trips through load/save/412-reload; WeightedShuffle correctly scoped to MultiCollection only; Input passthroughs are new optional props (no caller regression). The one substantive nit — the round-trip test edited the weight it asserted rather than the unrelated-edit trap — is now fixed with a dedicated rename-only test.

Live-E2E (real local instance, real API — not mocks):

  • Create weighted multi-collection (3:1, exact SPA toItemRequest shape) → GET back: weights persist [('E2E Heavy', 3), ('E2E Light', 1)]. ✅
  • Silent-reset trap: rename-only PUT (If-Match, items resent as the SPA does) → GET back: name changed, weights still 3 and 1 (not reset). ✅
  • WeightedShuffle on a classic MultiCollection schedule item → PUT /schedules/{id}/items 200, persisted + round-tripped. ✅
  • Block & Playlist editors keep their own order lists and do not offer WeightedShuffle (issue's "not on those surfaces"). ✅
  • Ratio / build-a-playout is pinned end-to-end by the existing PlayoutBuildGoldenTests.Classic_weighted (3:1 golden through MultiCollection→repository→enumerator→playout), which this SPA change does not touch.

Accuracy correction the E2E surfaced (folded into the follow-up commit): the classic write path does not reject WeightedShuffle on a plain Collection (it accepts it, degrading to fair-share) — so MultiCollection-only in the SPA is a meaningfulness choice, not a write-path mirror. The rejection is on the separate playlist/block paths. Corrected in itemRules.ts comment + decisions.md.

Full web suite green (861 tests), lint/typecheck clean, production build OK.

The follow-up commit (d2d678aa) over the reviewed d639946b is test + docs + a comment-only edit — no production logic change.

Review-verdict: MERGEABLE @ d2d678aae8

## Cold adversarial review + live-E2E — MERGEABLE **Independent cold review** (fresh agent, review-only) of the diff: **MERGEABLE-WITH-NITS** — no Blocker/High/Medium. `clampWeight` edge cases (`''`, `NaN`, negative, decimals, `1e3`, huge) all verified to snap into [1,1000]; weight round-trips through load/save/412-reload; `WeightedShuffle` correctly scoped to MultiCollection only; `Input` passthroughs are new optional props (no caller regression). The one substantive nit — the round-trip test edited the weight it asserted rather than the *unrelated-edit* trap — is now fixed with a dedicated rename-only test. **Live-E2E (real local instance, real API — not mocks):** - Create weighted multi-collection (3:1, exact SPA `toItemRequest` shape) → GET back: weights persist `[('E2E Heavy', 3), ('E2E Light', 1)]`. ✅ - **Silent-reset trap**: rename-only PUT (`If-Match`, items resent as the SPA does) → GET back: name changed, weights **still 3 and 1** (not reset). ✅ - `WeightedShuffle` on a classic **MultiCollection** schedule item → `PUT /schedules/{id}/items` 200, persisted + round-tripped. ✅ - Block & Playlist editors keep their own order lists and **do not** offer `WeightedShuffle` (issue's "not on those surfaces"). ✅ - **Ratio / build-a-playout** is pinned end-to-end by the existing `PlayoutBuildGoldenTests.Classic_weighted` (3:1 golden through MultiCollection→repository→enumerator→playout), which this SPA change does not touch. **Accuracy correction the E2E surfaced** (folded into the follow-up commit): the classic write path does **not** reject `WeightedShuffle` on a plain Collection (it accepts it, degrading to fair-share) — so MultiCollection-only in the SPA is a *meaningfulness* choice, not a write-path mirror. The rejection is on the separate playlist/block paths. Corrected in `itemRules.ts` comment + `decisions.md`. Full web suite green (861 tests), lint/typecheck clean, production build OK. The follow-up commit (`d2d678aa`) over the reviewed `d639946b` is **test + docs + a comment-only edit — no production logic change**. Review-verdict: MERGEABLE @ d2d678aae8d30a1db5e4b2e97b39ee23ee86dfa3
timothy merged commit 86cbf79b48 into main 2026-07-19 12:01:41 +02:00
timothy deleted branch feat/404-weight-ui 2026-07-19 12:01:44 +02:00
Sign in to join this conversation.