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
9 changed files with 401 additions and 43 deletions
+40
View File
@@ -99,6 +99,7 @@ in-file entries.
- [2026-07-18 — Unsupported PlaybackOrder is loud at build time; a declared support matrix and tripwire test make new orders safe by construction (#403)](#2026-07-18--unsupported-playbackorder-is-loud-at-build-time-a-declared-support-matrix-and-tripwire-test-make-new-orders-safe-by-construction-403)
- [2026-07-18 — CI build-once was measured and rejected; keep the #420 tree-skip](#2026-07-18--ci-build-once-was-measured-and-rejected-keep-the-420-tree-skip)
- [2026-07-18 — Never-scanned `LastScan` surfaces as null at the API boundary, not the 0001-01-01 MinValue sentinel (#409)](#2026-07-18--never-scanned-lastscan-surfaces-as-null-at-the-api-boundary-not-the-0001-01-01-minvalue-sentinel-409)
- [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)](#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)
---
@@ -1942,3 +1943,42 @@ internal scanner code that coalesces to `SystemTime.MinValueUtc` for its own non
`LibrariesScreen.tsx` was removed — both call sites revert to a plain null/truthy check now that the API
is honest. (Follow-up option, not done here: have `MediaSourceRepository` write `null` instead of
`MinValue` so the data is clean at rest too; the read coercion makes that non-urgent.)
## 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)
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).
+2 -2
View File
@@ -66,11 +66,11 @@ Channel (1) ──< Playout (0..N per channel; ChannelPlayoutSource distinguishe
| **FillerPreset** | A reusable filler definition: `FillerKind` (PreRoll/MidRoll/PostRoll/Tail/Fallback; also `GuideMode=99`, `DecoDefault=100`) × `FillerMode` (None/Duration/Count/Pad/RandomCount) over a collection/media-item/multi-collection/smart-collection/playlist source, with an optional `Expression` (NCalc). Referenced from `ProgramScheduleItem` (Pre/Mid/Post/Tail/FallbackFillerId) and `Channel.FallbackFillerId`. | `FillerPreset`, `FillerKind`, `FillerMode` | `/app/filler-presets` |
| **Clock-boundary padding** (#77) | Snapping a schedule to clean `:00/:15/:30`-style guide times is **not a separate feature** — it is `FillerMode.Pad` + `PadToNearestMinute` on a `FillerPreset` (Classic), or the `pad_to_next`/`pad_until` YAML instructions (Sequential). Block playouts are inherently clock-anchored via `TemplateItem.StartTime`. The EPG reflects the padded boundary automatically (`ChannelGuideProjector` coalesces trailing filler into the programme window). No one-click per-channel toggle yet — deferred to a UI follow-up blocked on #388. See `decisions.md` 2026-07-17. | `FillerPreset` (`Pad`) | `/app/filler-presets` |
| **Seasonal / date-conditional scheduling** (#73) | Holiday/seasonal channels are **not a separate feature** — they are the existing date predicate on `IAlternateScheduleItem`, implemented by `ProgramScheduleAlternate` (Classic) and `PlayoutTemplate` (Block), evaluated by `AlternateScheduleSelector.GetScheduleForDate` (first match by `Index`, catch-all last). **Leaving `StartYear`/`EndYear` empty makes the range repeat every year** — the "set once, works every December" switch; explicit years (required in pairs) mean a one-off window and disable wrap-around detection. Wrap-around (Nov→Feb) and invalid/leap dates (Feb 31) are handled. No *soft* prioritization primitive exists (binary first-match-wins); that ask belongs to #70's weighting work. See `channels.md` → "Recipe: seasonal / holiday programming" and `decisions.md` 2026-07-17. | `IAlternateScheduleItem`, `ProgramScheduleAlternate`, `PlayoutTemplate` | `/app/playouts/{id}/alternate-schedules`, `/app/playouts/{id}/templates` |
| **Playback order** | How a schedule item's source(s) are sequenced (`PlaybackOrder`). Note three that are easily confused: **`Shuffle`** is Fisher–Yates over the flattened items, so airtime is implicitly proportional to collection size (a 200-episode show swamps a 20-episode one). **`ShuffleInOrder`** is a balanced shuffle (keyj) that pads sources to equal length with non-emitting spacers — it plays every item exactly once per cycle, so it prevents *clumping* but leaves airtime proportional to size; it is **not** fair-share. **`WeightedShuffle`** (#70) picks a *source* by smooth weighted round-robin then takes its next item, so each source's `Weight` is its share of airtime — equal weights (the default) mean equal airtime regardless of library size, with small sources looping. Classic engine only; rejected at the write path for playlist/block items. See `decisions.md` 2026-07-17. | `PlaybackOrder`, `MultiCollectionItem.Weight`, `MultiCollectionSmartItem.Weight` | `/app/schedules` (weight UI pending, gated on #388) |
| **Playback order** | How a schedule item's source(s) are sequenced (`PlaybackOrder`). Note three that are easily confused: **`Shuffle`** is Fisher–Yates over the flattened items, so airtime is implicitly proportional to collection size (a 200-episode show swamps a 20-episode one). **`ShuffleInOrder`** is a balanced shuffle (keyj) that pads sources to equal length with non-emitting spacers — it plays every item exactly once per cycle, so it prevents *clumping* but leaves airtime proportional to size; it is **not** fair-share. **`WeightedShuffle`** (#70) picks a *source* by smooth weighted round-robin then takes its next item, so each source's `Weight` is its share of airtime — equal weights (the default) mean equal airtime regardless of library size, with small sources looping. Classic engine only; rejected at the write path for playlist/block items. See `decisions.md` 2026-07-17. | `PlaybackOrder`, `MultiCollectionItem.Weight`, `MultiCollectionSmartItem.Weight` | `WeightedShuffle` is offered as a Playback Order **only** on classic schedule items whose source is a MultiCollection (`web/src/schedules/itemRules.ts`, #404); the per-source weights themselves are edited at `/app/multi-collections` |
| **Watermark** | `ChannelWatermark` image overlay; attached at channel, schedule-item, block-item, deco, or playout-item level with position/size/opacity. | `ChannelWatermark`, `DecoWatermark`, `BlockItemWatermark`, `ProgramScheduleItemWatermark` | `/app/watermarks` |
| **Collection** | Manual list of media items (`CollectionItem`). | `Collection` | `/app/collections` |
| **SmartCollection** | Saved search — a `Query` string, no static item list. | `SmartCollection` | `/app/collections` |
| **MultiCollection** | Combines multiple `Collection`s and/or `SmartCollection`s (with grouping via `MultiCollectionItem`/`MultiCollectionSmartItem`). Both join entities carry a per-source `Weight` (default 1) used by `PlaybackOrder.WeightedShuffle` (#70) and ignored by every other order — the two are mirrors, so a change to one belongs on the other. | `MultiCollection` | `/app/multi-collections` (#151) |
| **MultiCollection** | Combines multiple `Collection`s and/or `SmartCollection`s (with grouping via `MultiCollectionItem`/`MultiCollectionSmartItem`). Both join entities carry a per-source `Weight` (default 1) used by `PlaybackOrder.WeightedShuffle` (#70) and ignored by every other order — the two are mirrors, so a change to one belongs on the other. The editor exposes a per-source weight input (1..1000, mirroring the API validator) with a computed % share and a "Reset to fair share" action; it round-trips `weight` from the GET because the PUT replaces the item list (#404). | `MultiCollection` | `/app/multi-collections` (#151, weight UI #404) |
| **RerunCollection** | One source (collection/media item/multi/smart) with separate `FirstRunPlaybackOrder` vs `RerunPlaybackOrder`. | `RerunCollection` | `/app/rerun-collections` (#152) |
| **Playlist** / **PlaylistGroup** | Ordered `PlaylistItem`s; `IsSystem` flag marks built-in/non-deletable playlists and groups. | `Playlist`, `PlaylistGroup`, `PlaylistItem` | `/app/playlists` |
| **Media kinds** | `CollectionType` enum distinguishes container kinds (Collection/TelevisionShow/TelevisionSeason/Artist/MultiCollection/SmartCollection/Playlist/RerunFirstRun/RerunRerun/SearchQuery) from leaf media kinds (Movie/Episode/MusicVideo/OtherVideo/Song/Image/RemoteStream) plus synthetic `FakeCollection`/`FakePlaylistItem`. Concrete media entities: `Movie`, `Show`/`Season`/`Episode`, `Artist`/`MusicVideo`/`Song`, `OtherVideo`, `Image`, `RemoteStream` (`ErsatzTV.Core/Domain/MediaItem/`). | `MediaItem` subclasses | `/app/media?kind=...` |
+12
View File
@@ -172,6 +172,18 @@ fresher edit:
ETag and the draft data come from one read and stay consistent (the fail-safe ordering; don't pair a
list-row draft with a separately-fetched ETag). Editors that navigate back to the list on save (Playout,
Multi/Rerun, Collection reorder) need only the 412 branch — no post-save ETag rotation.
- **Replace-all PUT → round-trip every persisted field, even ones the editor doesn't visibly edit.** A
replace-all update (Multi/Rerun collection, schedule items, block items) submits the *whole* item list,
so any field the draft mapping drops is silently reset to its default server-side. The multi-collection
editor's per-source `weight` (#404) is the canonical trap: it must be read in `itemsFromMultiCollection`
and written in `toItemRequest`, or every source resets to weight 1 on the next save of an unrelated edit
(rename, add a source). When you add a field to a replace-all DTO, add it to **both** the response→draft
and draft→request mappings and cover the round-trip in a test.
- **Numeric inputs with API bounds**: hold the value as a string in the draft so the field edits smoothly
(transient empty/partial), pass `type="number"` + `min`/`max`/`inputMode="numeric"` to `Input` for the
native stepper, clamp to the API's validator range on `onBlur` (immediate feedback) **and** again at save
(authoritative) — never let an out-of-range value reach the server and surface a raw 400. Mirror the
server's bound as a shared const (e.g. multi-collection weight `WEIGHT_MIN`/`WEIGHT_MAX` = 1..1000, #404).
## 5. Artwork rendering
+19 -2
View File
@@ -1,4 +1,4 @@
import type { ChangeEvent, CSSProperties, MouseEvent, ReactNode } from 'react';
import type { ChangeEvent, CSSProperties, FocusEvent, MouseEvent, ReactNode } from 'react';
import { Check, ChevronDown, Minus } from 'lucide-react';
import { Spinner } from './feedback';
@@ -112,6 +112,13 @@ export interface InputProps {
type?: string;
fullWidth?: boolean;
style?: CSSProperties;
// Native <input> passthroughs — added for numeric fields (e.g. the multi-collection weight input,
// #404): min/max bound the stepper, inputMode hints the mobile keyboard, onBlur normalizes on commit.
min?: number;
max?: number;
inputMode?: 'numeric' | 'decimal' | 'text';
onBlur?: (e: FocusEvent<HTMLInputElement>) => void;
ariaLabel?: string;
}
export function Input({
@@ -126,7 +133,12 @@ export function Input({
size = 'md',
type = 'text',
fullWidth = true,
style
style,
min,
max,
inputMode,
onBlur,
ariaLabel
}: InputProps) {
return (
<label className={classNames('ctv-field', fullWidth && 'ctv-field-full')}>
@@ -146,8 +158,13 @@ export function Input({
type={type}
value={value}
onChange={onChange}
onBlur={onBlur}
placeholder={placeholder}
disabled={disabled}
min={min}
max={max}
inputMode={inputMode}
aria-label={ariaLabel}
/>
{trailing && <span className="ctv-field-trailing">{trailing}</span>}
</span>
+23 -3
View File
@@ -31,8 +31,25 @@ function draft(overrides: Partial<DraftItem> = {}): DraftItem {
}
describe('playbackOrderOptions', () => {
it('MultiCollection offers Shuffle + ShuffleInOrder only', () => {
expect(playbackOrderOptions('MultiCollection')).toEqual(['Shuffle', 'ShuffleInOrder']);
it('MultiCollection offers Shuffle + ShuffleInOrder + WeightedShuffle only', () => {
expect(playbackOrderOptions('MultiCollection')).toEqual(['Shuffle', 'ShuffleInOrder', 'WeightedShuffle']);
});
it('WeightedShuffle is offered ONLY on MultiCollection (needs per-source weights)', () => {
const others: DraftItem['collectionType'][] = [
'Collection',
'SmartCollection',
'SearchQuery',
'TelevisionShow',
'TelevisionSeason',
'Artist',
'Playlist',
'RerunFirstRun',
'RerunRerun'
];
for (const t of others) {
expect(playbackOrderOptions(t)).not.toContain('WeightedShuffle');
}
});
it('Collection / SmartCollection / SearchQuery offer the collection-like set incl. Marathon', () => {
@@ -160,10 +177,13 @@ describe('fillWithGroupModeEligible', () => {
expect(fillWithGroupModeEligible(draft({ playoutMode: 'Duration', playbackOrder: 'Random', collectionType: 'SmartCollection' }))).toBe(true);
});
it('ineligible for One/Flood, ShuffleInOrder, or non-collection types', () => {
it('ineligible for One/Flood, ShuffleInOrder, WeightedShuffle, or non-collection types', () => {
expect(fillWithGroupModeEligible(draft({ playoutMode: 'One', collectionType: 'Collection' }))).toBe(false);
expect(fillWithGroupModeEligible(draft({ playoutMode: 'Flood', collectionType: 'Collection' }))).toBe(false);
expect(fillWithGroupModeEligible(draft({ playoutMode: 'Multiple', playbackOrder: 'ShuffleInOrder', collectionType: 'Collection' }))).toBe(false);
// WeightedShuffle joins ShuffleInOrder in the exclusion (#404): fillWithGroup schedules per-group,
// which conflicts with WeightedShuffle's whole-multi-collection per-source airtime share.
expect(fillWithGroupModeEligible(draft({ playoutMode: 'Multiple', playbackOrder: 'WeightedShuffle', collectionType: 'MultiCollection' }))).toBe(false);
expect(fillWithGroupModeEligible(draft({ playoutMode: 'Multiple', playbackOrder: 'Shuffle', collectionType: 'TelevisionShow' }))).toBe(false);
expect(fillWithGroupModeEligible(draft({ playoutMode: 'Multiple', playbackOrder: 'None', collectionType: 'Playlist' }))).toBe(false);
});
+13 -4
View File
@@ -43,7 +43,13 @@ export function nextDraftKey(): string {
// ---- Option lists --------------------------------------------------------
const MULTI_COLLECTION_ORDERS: PlaybackOrder[] = ['Shuffle', 'ShuffleInOrder'];
// WeightedShuffle (#70) is offered ONLY for MultiCollection: it distributes airtime across per-source
// weights, which live on MultiCollectionItem/SmartItem, so it is only *meaningful* with 2+ weighted
// sources. On a single collection the classic engine still accepts it but it degrades to fair-share
// (≈ Shuffle), a confusing no-op — hence MultiCollection-only here. (Distinct concern: the playlist and
// block editors — which keep their own order lists, not this one — must also omit it because those
// write paths reject it outright; docs/decisions.md 2026-07-17.)
const MULTI_COLLECTION_ORDERS: PlaybackOrder[] = ['Shuffle', 'ShuffleInOrder', 'WeightedShuffle'];
const COLLECTION_LIKE_ORDERS: PlaybackOrder[] = ['Chronological', 'Random', 'Shuffle', 'ShuffleInOrder', 'Marathon'];
const TV_SHOW_ORDERS: PlaybackOrder[] = ['Chronological', 'SeasonEpisode', 'Random', 'Shuffle', 'MultiEpisodeShuffle'];
const SEASON_ARTIST_ORDERS: PlaybackOrder[] = ['Chronological', 'Random', 'Shuffle'];
@@ -153,11 +159,14 @@ export function shufflePlaylistItemsVisible(item: DraftItem): boolean {
return item.collectionType === 'Playlist';
}
// fillWithGroupMode is eligible iff Multiple/Duration && playbackOrder != ShuffleInOrder &&
// collectionType in {Collection, MultiCollection, SmartCollection}.
// fillWithGroupMode is eligible iff Multiple/Duration && playbackOrder not in {ShuffleInOrder,
// WeightedShuffle} && collectionType in {Collection, MultiCollection, SmartCollection}.
// WeightedShuffle joins ShuffleInOrder in the exclusion: fillWithGroup splits the multi-collection
// into per-group enumerators scheduled one group at a time (PlayoutBuilder), which is incompatible
// with WeightedShuffle's whole-collection per-source share of airtime (#70/#404).
export function fillWithGroupModeEligible(item: DraftItem): boolean {
const modeOk = item.playoutMode === 'Multiple' || item.playoutMode === 'Duration';
const orderOk = item.playbackOrder !== 'ShuffleInOrder';
const orderOk = item.playbackOrder !== 'ShuffleInOrder' && item.playbackOrder !== 'WeightedShuffle';
const typeOk =
item.collectionType === 'Collection' ||
item.collectionType === 'MultiCollection' ||
+122 -3
View File
@@ -122,7 +122,7 @@ describe('MultiCollectionsScreen', () => {
);
expect(postCall).toBeDefined();
expect(JSON.parse(String(postCall?.[1]?.body))).toEqual({
items: [{ collectionId: 5, playbackOrder: 'Chronological', scheduleAsGroup: false, smartCollectionId: null }],
items: [{ collectionId: 5, playbackOrder: 'Chronological', scheduleAsGroup: false, smartCollectionId: null, weight: 1 }],
name: 'New Bundle'
});
});
@@ -155,13 +155,132 @@ describe('MultiCollectionsScreen', () => {
expect(parsed.name).toBe('Saturday Bundle');
expect(parsed.items).toEqual(
expect.arrayContaining([
{ collectionId: 5, playbackOrder: 'Chronological', scheduleAsGroup: false, smartCollectionId: null },
{ collectionId: null, playbackOrder: 'Chronological', scheduleAsGroup: true, smartCollectionId: 9 }
{ collectionId: 5, playbackOrder: 'Chronological', scheduleAsGroup: false, smartCollectionId: null, weight: 1 },
{ collectionId: null, playbackOrder: 'Chronological', scheduleAsGroup: true, smartCollectionId: 9, weight: 1 }
])
);
});
});
// Weighted multi-collection fixture reused by the round-trip tests: 3:1 split (Favorites : Action).
const weightedMulti = [
{
id: 1,
items: [
{ collectionId: 5, name: 'Favorites', playbackOrder: 'Chronological', scheduleAsGroup: false, smartCollectionId: null, weight: 3 },
{ collectionId: null, name: 'Action', playbackOrder: 'Chronological', scheduleAsGroup: true, smartCollectionId: 9, weight: 1 }
],
name: 'Saturday Bundle'
}
];
it('preserves weights when only an unrelated field is edited (the silent-reset trap)', async () => {
// The canonical #404 trap: the replace-all PUT resubmits the whole item list, so editing ONLY the
// name must still round-trip each source's weight untouched — dropping it in the draft mapping
// would silently reset every weight to 1 on this save (docs/decisions.md 2026-07-17).
const fetchMock = mockApi({
multi: weightedMulti,
onRequest: (url, method) =>
url === '/api/v1/multi-collections/1' && method === 'PUT'
? jsonResponse({ id: 1, items: [], name: 'Weekend Bundle' })
: null
});
render(<MultiCollectionsScreen />);
fireEvent.click(await screen.findByText('Saturday Bundle'));
await screen.findByLabelText('Weight for Favorites');
// Edit ONLY the name — never touch a weight input.
fireEvent.change(screen.getByPlaceholderText('Multi-collection name'), { target: { value: 'Weekend Bundle' } });
fireEvent.click(screen.getByRole('button', { name: 'Save multi-collection' }));
await waitFor(() => {
const putCall = fetchMock.mock.calls.find(
([u, init]) => u === '/api/v1/multi-collections/1' && (init?.method ?? '').toUpperCase() === 'PUT'
);
expect(putCall).toBeDefined();
const parsed = JSON.parse(String(putCall?.[1]?.body));
expect(parsed.name).toBe('Weekend Bundle');
// Both original weights survive unchanged — NOT reset to 1.
expect(parsed.items).toEqual(
expect.arrayContaining([
{ collectionId: 5, playbackOrder: 'Chronological', scheduleAsGroup: false, smartCollectionId: null, weight: 3 },
{ collectionId: null, playbackOrder: 'Chronological', scheduleAsGroup: true, smartCollectionId: 9, weight: 1 }
])
);
});
});
it('round-trips per-source weights on save (no silent reset), and edits them', async () => {
// Weighted multi-collection: the API returns each item's weight and the update REPLACES the item
// list, so the editor must carry weight through untouched or every source silently resets to 1
// (docs/decisions.md 2026-07-17, #404).
const weighted = weightedMulti;
const fetchMock = mockApi({
multi: weighted,
onRequest: (url, method) =>
url === '/api/v1/multi-collections/1' && method === 'PUT'
? jsonResponse({ id: 1, items: [], name: 'Saturday Bundle' })
: null
});
render(<MultiCollectionsScreen />);
fireEvent.click(await screen.findByText('Saturday Bundle'));
// The persisted weights hydrate the inputs (75% / 25% for a 3:1 split).
const favoritesWeight = await screen.findByLabelText('Weight for Favorites');
expect((favoritesWeight as HTMLInputElement).value).toBe('3');
expect(screen.getByText('75%')).toBeInTheDocument();
expect(screen.getByText('25%')).toBeInTheDocument();
// Edit one weight; an out-of-range value clamps to the API ceiling on blur (mirrors the validator).
fireEvent.change(favoritesWeight, { target: { value: '5000' } });
fireEvent.blur(favoritesWeight);
expect((favoritesWeight as HTMLInputElement).value).toBe('1000');
fireEvent.click(screen.getByRole('button', { name: 'Save multi-collection' }));
await waitFor(() => {
const putCall = fetchMock.mock.calls.find(
([u, init]) => u === '/api/v1/multi-collections/1' && (init?.method ?? '').toUpperCase() === 'PUT'
);
expect(putCall).toBeDefined();
const parsed = JSON.parse(String(putCall?.[1]?.body));
// Favorites clamped to 1000; Action's weight of 1 rides through unchanged (not reset).
expect(parsed.items).toEqual(
expect.arrayContaining([
{ collectionId: 5, playbackOrder: 'Chronological', scheduleAsGroup: false, smartCollectionId: null, weight: 1000 },
{ collectionId: null, playbackOrder: 'Chronological', scheduleAsGroup: true, smartCollectionId: 9, weight: 1 }
])
);
});
});
it('resets all weights to fair share', async () => {
const weighted = [
{
id: 1,
items: [
{ collectionId: 5, name: 'Favorites', playbackOrder: 'Chronological', scheduleAsGroup: false, smartCollectionId: null, weight: 3 },
{ collectionId: null, name: 'Action', playbackOrder: 'Chronological', scheduleAsGroup: true, smartCollectionId: 9, weight: 1 }
],
name: 'Saturday Bundle'
}
];
mockApi({ multi: weighted });
render(<MultiCollectionsScreen />);
fireEvent.click(await screen.findByText('Saturday Bundle'));
const favoritesWeight = (await screen.findByLabelText('Weight for Favorites')) as HTMLInputElement;
expect(favoritesWeight.value).toBe('3');
fireEvent.click(screen.getByRole('button', { name: 'Reset to fair share' }));
expect(favoritesWeight.value).toBe('1');
expect((screen.getByLabelText('Weight for Action') as HTMLInputElement).value).toBe('1');
});
it('confirms and DELETEs a multi-collection', async () => {
const fetchMock = mockApi();
+139 -29
View File
@@ -70,20 +70,53 @@ function useMultiCollectionsData() {
/* ---------- editor ---------- */
// One row in the editor: a manual collection or a smart collection, plus its
// schedule-as-group flag. Names are resolved from the fetched collection lists.
// Per-source weight bounds. Must mirror the API's MultiCollectionItemWeight validator (1..1000) so a
// client-side value never provokes a raw 400 (docs/decisions.md 2026-07-17, #404).
const WEIGHT_MIN = 1;
const WEIGHT_MAX = 1000;
// Coerce the editor's free-text weight to a valid integer share. Empty / non-numeric / out-of-range
// all snap to the [1,1000] bound — 1 (the floor, = fair share) when the field is blank or invalid.
function clampWeight(raw: string): number {
const parsed = Math.trunc(Number(raw));
if (!Number.isFinite(parsed)) {
return WEIGHT_MIN;
}
return Math.min(WEIGHT_MAX, Math.max(WEIGHT_MIN, parsed));
}
// One row in the editor: a manual collection or a smart collection, plus its schedule-as-group flag
// and per-source weight. `weight` is held as a string so the number field edits smoothly (transient
// empty / partial values are allowed); it is clamped to the API bounds on blur and again at save.
// Names are resolved from the fetched collection lists.
interface DraftItem {
id: number;
kind: 'manual' | 'smart';
name: string;
scheduleAsGroup: boolean;
weight: string;
}
function itemsFromMultiCollection(mc: MultiCollection): DraftItem[] {
return mc.items.map((item) =>
item.collectionId != null
? { id: item.collectionId, kind: 'manual', name: item.name, scheduleAsGroup: item.scheduleAsGroup }
: { id: item.smartCollectionId ?? 0, kind: 'smart', name: item.name, scheduleAsGroup: item.scheduleAsGroup }
? {
id: item.collectionId,
kind: 'manual',
name: item.name,
scheduleAsGroup: item.scheduleAsGroup,
// Round-trip the persisted weight — the update REPLACES the item list, so dropping it here
// would silently reset every weight to 1 on save (the API returns it for exactly this
// reason). Default 1 for rows created before weights existed (docs/decisions.md, #404).
weight: String(item.weight ?? WEIGHT_MIN)
}
: {
id: item.smartCollectionId ?? 0,
kind: 'smart',
name: item.name,
scheduleAsGroup: item.scheduleAsGroup,
weight: String(item.weight ?? WEIGHT_MIN)
}
);
}
@@ -92,7 +125,8 @@ function toItemRequest(item: DraftItem): MultiCollectionItemRequest {
collectionId: item.kind === 'manual' ? item.id : null,
playbackOrder: 'Chronological',
scheduleAsGroup: item.scheduleAsGroup,
smartCollectionId: item.kind === 'smart' ? item.id : null
smartCollectionId: item.kind === 'smart' ? item.id : null,
weight: clampWeight(item.weight)
};
}
@@ -162,7 +196,10 @@ function MultiCollectionEditor({
}
const collection = collections.find((entry) => entry.id === id);
setItems((current) => [...current, { id, kind: 'manual', name: collection?.name ?? `#${id}`, scheduleAsGroup: false }]);
setItems((current) => [
...current,
{ id, kind: 'manual', name: collection?.name ?? `#${id}`, scheduleAsGroup: false, weight: String(WEIGHT_MIN) }
]);
setSelectedCollection('');
};
@@ -173,7 +210,10 @@ function MultiCollectionEditor({
}
const collection = smartCollections.find((entry) => entry.id === id);
setItems((current) => [...current, { id, kind: 'smart', name: collection?.name ?? `#${id}`, scheduleAsGroup: false }]);
setItems((current) => [
...current,
{ id, kind: 'smart', name: collection?.name ?? `#${id}`, scheduleAsGroup: false, weight: String(WEIGHT_MIN) }
]);
setSelectedSmart('');
};
@@ -187,6 +227,28 @@ function MultiCollectionEditor({
);
};
// Free-text while typing (allow transient empty / partial); the value is clamped to the API bounds
// on blur (normalizeWeight) and again at save (clampWeight in toItemRequest).
const setWeight = (target: DraftItem, next: string) => {
setItems((current) =>
current.map((item) => (item.kind === target.kind && item.id === target.id ? { ...item, weight: next } : item))
);
};
const normalizeWeight = (target: DraftItem) => {
setItems((current) =>
current.map((item) =>
item.kind === target.kind && item.id === target.id ? { ...item, weight: String(clampWeight(item.weight)) } : item
)
);
};
// Fair share = every source at weight 1 (docs/decisions.md 2026-07-17: fair-share is not a separate
// mode, just WeightedShuffle with equal weights — so this resets, it does not write a different order).
const resetToFairShare = () => {
setItems((current) => current.map((item) => ({ ...item, weight: String(WEIGHT_MIN) })));
};
const trimmedName = name.trim();
const save = async () => {
@@ -240,6 +302,15 @@ function MultiCollectionEditor({
const sortedItems = [...items].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
// Percentages are a DISPLAY concern (the wire format is integer relative shares) — a 3:1 weight
// shows as 75% / 25% (#404). Computed from clamped weights so a transient out-of-range edit never
// skews the shares mid-type.
const totalWeight = items.reduce((sum, item) => sum + clampWeight(item.weight), 0);
const weightPercent = (item: DraftItem): number =>
totalWeight > 0 ? Math.round((clampWeight(item.weight) / totalWeight) * 100) : 0;
// Fair share = every source already at weight 1; nothing to reset in that state.
const alreadyFairShare = items.length > 0 && items.every((item) => clampWeight(item.weight) === WEIGHT_MIN);
return (
<div className="ctv-collections">
<div className="ctv-channels-actionbar">
@@ -316,33 +387,72 @@ function MultiCollectionEditor({
{sortedItems.length === 0 ? (
<div className="ctv-collections-empty">No collections added yet.</div>
) : (
sortedItems.map((item, index) => (
<div
className="ctv-settings-flush-row"
key={`${item.kind}:${item.id}`}
style={index === 0 ? { borderTop: 'none' } : undefined}
>
<Layers aria-hidden="true" color="var(--ctv-accent)" size={15} />
<span className="ctv-settings-flush-row-main ctv-settings-flush-row-title">{item.name}</span>
<Badge tone="neutral">{item.kind === 'smart' ? 'Smart' : 'Manual'}</Badge>
<label className="ctv-collections-order-toggle" title="Schedule as group">
<Switch
checked={item.scheduleAsGroup}
onChange={(next) => setScheduleAsGroup(item, next)}
<>
{sortedItems.map((item, index) => (
<div
className="ctv-settings-flush-row"
key={`${item.kind}:${item.id}`}
style={index === 0 ? { borderTop: 'none' } : undefined}
>
<Layers aria-hidden="true" color="var(--ctv-accent)" size={15} />
<span className="ctv-settings-flush-row-main ctv-settings-flush-row-title">{item.name}</span>
<Badge tone="neutral">{item.kind === 'smart' ? 'Smart' : 'Manual'}</Badge>
<div
className="ctv-collections-weight"
title="Relative share of airtime under the Weighted Shuffle playback order"
>
<span className="ctv-collections-weight-label" aria-hidden="true">
Weight
</span>
<Input
ariaLabel={`Weight for ${item.name}`}
fullWidth={false}
inputMode="numeric"
max={WEIGHT_MAX}
min={WEIGHT_MIN}
onBlur={() => normalizeWeight(item)}
onChange={(event) => setWeight(item, event.target.value)}
size="sm"
style={{ width: 68 }}
type="number"
value={item.weight}
/>
<span className="ctv-collections-weight-pct">{weightPercent(item)}%</span>
</div>
<label className="ctv-collections-order-toggle" title="Schedule as group">
<Switch
checked={item.scheduleAsGroup}
onChange={(next) => setScheduleAsGroup(item, next)}
size="sm"
/>
<span>Schedule as group</span>
</label>
<IconButton
onClick={() => removeItem(item)}
size="sm"
/>
<span>Schedule as group</span>
</label>
<IconButton
onClick={() => removeItem(item)}
title={`Remove ${item.name}`}
variant="ghost"
>
<Trash2 aria-hidden="true" size={14} />
</IconButton>
</div>
))}
<div className="ctv-settings-flush-footer ctv-collections-weight-footer">
<span className="ctv-collections-weight-hint">
Weights set each source&rsquo;s share of airtime under the <strong>Weighted Shuffle</strong> playback
order (set on the schedule item). Equal weights = fair share.
</span>
<span className="ctv-channels-spacer" />
<Button
disabled={alreadyFairShare}
onClick={resetToFairShare}
size="sm"
title={`Remove ${item.name}`}
variant="ghost"
>
<Trash2 aria-hidden="true" size={14} />
</IconButton>
Reset to fair share
</Button>
</div>
))
</>
)}
</Card>
+31
View File
@@ -4369,6 +4369,37 @@ body {
cursor: pointer;
}
/* per-source weight input + computed share (#404) */
.ctv-collections-weight {
display: inline-flex;
align-items: center;
gap: var(--space-3, 6px);
color: var(--text-secondary);
font-size: var(--text-xs, 12px);
}
.ctv-collections-weight-label {
color: var(--text-secondary);
}
.ctv-collections-weight-pct {
min-width: 34px;
text-align: right;
font: var(--text-2xs) / 1 var(--font-mono);
color: var(--text-disabled);
}
.ctv-collections-weight-footer {
gap: var(--space-6, 12px);
}
.ctv-collections-weight-hint {
flex: 1;
min-width: 0;
font-size: var(--text-xs, 12px);
color: var(--text-secondary);
}
.ctv-collections-query {
margin-top: 3px;
font: var(--text-2xs) / 1.3 var(--font-mono);