From d639946b5c61be95ef1547040f5fad8c6c3c0201 Mon Sep 17 00:00:00 2001 From: Timothy Date: Sun, 19 Jul 2026 11:28:12 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(404):=20weighted-distribution=20SPA=20?= =?UTF-8?q?=E2=80=94=20per-source=20weight=20inputs=20+=20WeightedShuffle?= =?UTF-8?q?=20order?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/decisions.md | 37 ++++ docs/domain-model.md | 4 +- docs/spa-conventions.md | 12 ++ web/src/components/forms.tsx | 21 ++- web/src/schedules/itemRules.test.ts | 26 ++- web/src/schedules/itemRules.ts | 15 +- .../screens/MultiCollectionsScreen.test.tsx | 85 ++++++++- web/src/screens/MultiCollectionsScreen.tsx | 168 +++++++++++++++--- web/src/shell.css | 31 ++++ 9 files changed, 356 insertions(+), 43 deletions(-) diff --git a/docs/decisions.md b/docs/decisions.md index b01205535..ec6a62284 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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,39 @@ 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`). It needs per-source weights, which only exist on multi-collection members, so it + is meaningless on the single-source collection types — and the classic write path *rejects* it for + playlist/block items (decisions.md 2026-07-17), so offering it there would only produce a 400. This is the + SPA mirror of that write-path gate. +- **`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). diff --git a/docs/domain-model.md b/docs/domain-model.md index 593a1a9fa..01ee195a6 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -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=...` | diff --git a/docs/spa-conventions.md b/docs/spa-conventions.md index 9414579b1..ed585a12c 100644 --- a/docs/spa-conventions.md +++ b/docs/spa-conventions.md @@ -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 diff --git a/web/src/components/forms.tsx b/web/src/components/forms.tsx index 831836409..f96049bf2 100644 --- a/web/src/components/forms.tsx +++ b/web/src/components/forms.tsx @@ -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 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) => 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 (