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.tsMULTI_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.
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.
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)
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>
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>
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):
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.
## 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 main2026-07-19 12:01:41 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
The UI half of #70 (backend + API shipped in PR #402). Pure SPA + docs — no new endpoint/DTO, no server-state effect (
weightwas already onMultiCollectionItemRequest/…ResponseModel, andWeightedShufflealready in thePlaybackOrderenum).What landed
/app/multi-collections): per-source weight input (1..1000, mirroring the API'sMultiCollectionItemWeightvalidator), a computed % share (3:1 → 75% / 25%), and a "Reset to fair share" action. Weight round-trips through the editor draft (read initemsFromMultiCollection, written intoItemRequest) so the replace-all PUT never silently resets it — the documented #404 gotcha.WeightedShuffleoffered as a Playback Order only forMultiCollectionsources (itemRules.tsMULTI_COLLECTION_ORDERS). It needs per-source weights and the classic write path rejects it elsewhere (playlist/block). Also excluded fromfillWithGrouplikeShuffleInOrder—PlayoutBuilderschedules fill-groups per-group, incompatible with WeightedShuffle's whole-collection per-source airtime share.Inputgainedmin/max/inputMode/onBlurpassthroughs 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
WeightedShufflewith 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.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
Cold adversarial review + live-E2E — MERGEABLE
Independent cold review (fresh agent, review-only) of the diff: MERGEABLE-WITH-NITS — no Blocker/High/Medium.
clampWeightedge cases ('',NaN, negative, decimals,1e3, huge) all verified to snap into [1,1000]; weight round-trips through load/save/412-reload;WeightedShufflecorrectly scoped to MultiCollection only;Inputpassthroughs 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):
toItemRequestshape) → GET back: weights persist[('E2E Heavy', 3), ('E2E Light', 1)]. ✅If-Match, items resent as the SPA does) → GET back: name changed, weights still 3 and 1 (not reset). ✅WeightedShuffleon a classic MultiCollection schedule item →PUT /schedules/{id}/items200, persisted + round-tripped. ✅WeightedShuffle(issue's "not on those surfaces"). ✅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
WeightedShuffleon 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 initemRules.tscomment +decisions.md.Full web suite green (861 tests), lint/typecheck clean, production build OK.
The follow-up commit (
d2d678aa) over the reviewedd639946bis test + docs + a comment-only edit — no production logic change.Review-verdict: MERGEABLE @
d2d678aae8