---
key: sched.weightedshuffle-editor
title: '2026-07-19 — WeightedShuffle SPA: weights edited on the multi-collection, order offered only on classic MultiCollection schedule items; fair-share is a reset not a mode (#404)'
status: active
since: '2026-07-19'
supersedes: none
superseded-by: none
rule: WeightedShuffle per-source weights are edited on the multi-collection editor (property of the MultiCollection), while the WeightedShuffle order itself is offered only on classic MultiCollection schedule items; fair-share is a "reset weights to 1" action, not a stored mode.
signals: 'WeightedShuffle SPA, multi-collection weights, fair-share reset · paths: `web/src/.../itemRules.ts` `MULTI_COLLECTION_ORDERS`, `itemsFromMultiCollection`/`toItemRequest` · issues: #404, #70, #402'
mechanics: spa-conventions.md §4 (replace-all-DTO trap); `fillWithGroupModeEligible` exclusion
---

The UI half of #70 (backend + API shipped in PR #402). No new endpoint or DTO — `weight` was already on
`MultiCollectionItemRequest`/`…ResponseModel` and `WeightedShuffle` already in the `PlaybackOrder` enum; this
is purely SPA (+ docs).

- **Two surfaces, split by where each datum lives.** The per-source *weights* are a property of the
  `MultiCollection`, so they are edited in the multi-collection editor (`/app/multi-collections`), not on the
  schedule item. The *order* (`WeightedShuffle`) is a property of the schedule item, so it is added to the
  classic schedule item's Playback Order options. Putting weights on the schedule item was rejected: the same
  multi-collection can be referenced by many schedule items, and weights are shared, not per-reference.
- **`WeightedShuffle` is offered ONLY for `collectionType === 'MultiCollection'`** (`itemRules.ts`
  `MULTI_COLLECTION_ORDERS`) — a *meaningfulness* scope, not a rejection mirror. Weights only exist on
  multi-collection members, so the order only does something with 2+ weighted sources; on a single collection
  the classic engine still **accepts** it (verified by live-E2E: `PUT /schedules/{id}/items` with a plain
  Collection + `WeightedShuffle` → 200) but it degrades to fair-share (≈ `Shuffle`), a confusing no-op — so
  the SPA doesn't offer it there. The *rejection* the #70 entry describes is on the separate **playlist and
  block** write paths (different engines); the playlist/block editors keep their own order lists (not
  `MULTI_COLLECTION_ORDERS`) and already omit `WeightedShuffle`, so nothing extra was needed there.
- **`WeightedShuffle` joins `ShuffleInOrder` in the `fillWithGroup` exclusion.** `fillWithGroupModeEligible`
  already excluded `ShuffleInOrder`; WeightedShuffle is excluded for the same structural reason —
  `PlayoutBuilder` splits a fill-with-group item into per-group enumerators scheduled one group at a time,
  which is incompatible with WeightedShuffle's *whole-multi-collection* per-source share of airtime. Offering
  the combination would silently not do what the name says.
- **Fair-share is a "Reset to fair share" button, not a stateful toggle.** decisions.md 2026-07-17 established
  that fair-share is not a separate mode — it is `WeightedShuffle` with all weights left at the default 1. So
  there is nothing to toggle *on*: the button just sets every source's weight to 1 (disabled when already
  there). A persistent toggle was rejected because "fair share" has no stored representation distinct from
  "the weights all happen to be 1".
- **Weights round-trip through the editor draft or they silently reset.** The multi-collection PUT replaces
  the whole item list, so `weight` is read in `itemsFromMultiCollection` and written in `toItemRequest`;
  dropping it would reset every source to 1 on the next unrelated save (rename, add a source). Covered by a
  round-trip test. This is the general replace-all-DTO trap now recorded in `spa-conventions.md` §4.
- **Percentages are display-only; the wire format is the integer weight.** A 3:1 weight shows as 75% / 25%,
  computed from the clamped weights; the request always carries the integer relative share (1..1000). The
  weight field is held as a string in the editor draft so it edits smoothly, clamped to the API validator's
  1..1000 on blur and again at save — an out-of-range value never reaches the server as a raw 400. `Input`
  gained `min`/`max`/`inputMode`/`onBlur` passthroughs for this (reusable by #425's weight UI).
