From e99e72ee71a9c478977b23f632415c71d3b0d5af Mon Sep 17 00:00:00 2001 From: Timothy Date: Wed, 22 Jul 2026 23:40:45 +0200 Subject: [PATCH] docs(392): record per-schedule clock-padding decision + domain-model field --- docs/decisions.md | 27 +++++++++++++++++++++++++++ docs/decisions/README.md | 1 + docs/domain-model.md | 2 +- 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/docs/decisions.md b/docs/decisions.md index 1f28ff282..6e51946aa 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -1305,6 +1305,8 @@ into a schedule item's roll slot. That toggle is mostly SPA work and is deferred the #388 design-system sync epic** (all UI work is currently gated on #388), which also covers adding a 60-minute increment to the filler-preset editor's options (the backend already accepts any integer). +Extended by `sched.clock-padding-schedule-toggle` (#392). + ## 2026-07-17 — Shuffle-source construction extracted to `ShuffleSourceBuilder`; per-family seam, not a god-factory (#380) `key: sched.shuffle-source-builder` · `status: active` · `since: 2026-07-17` · `supersedes: none` · `superseded-by: none` **Rule:** Shuffle-source construction moves to a static, DI-free `ShuffleSourceBuilder` (a shared seam, not a service) so Classic and Playlist stop cross-engine reaching into `PlayoutBuilder` statics; a unified Classic+Playlist enumerator factory is explicitly rejected as a god-factory. Block/Scripted/YAML duplication is left alone, deferred to a follow-up gated on #381. @@ -3561,3 +3563,28 @@ no scheduling logic of its own. So "Scripted is un-golden-able" conflates two di The #381 "documented decision" arm correctly deferred the *pipeline* golden; it overstated the case by writing off engine-level coverage too. Scripted scheduling *behavior* is now covered in-process; only the external-process pipeline remains #563's. + +## 2026-07-22 — per-schedule clock-boundary padding is a synthetic content-less Pad over the existing per-episode machinery (#392) + +`key: sched.clock-padding-schedule-toggle` · `status: active` · `since: 2026-07-22` · `supersedes: none` · `superseded-by: none` +**Rule:** A `ProgramSchedule.PadToNearestMinute` (nullable int; null = off) makes the Classic builder pad every content item up to the next N-minute clock boundary without a hand-wired Pad `FillerPreset`, by reusing the existing per-content-item Pad path in `PlayoutModeSchedulerBase.AddFiller`. It extends — does not supersede — `sched.clock-padding-existing` (#77/#388). +**Signals:** per-schedule clock padding, PadToNearestMinute on ProgramSchedule, offline gap on pad, ClockPadOfflineTarget, synthetic Pad without FillerPreset · paths: `PlayoutModeSchedulerBase.AddFiller`, `PlayoutSchedulerResult.ClockPadOfflineTarget`, `FallbackFillerForPad`, `/app/schedules` · issues: #392, #77, #388 +**Mechanics:** `PlayoutBuildGoldenTests` (One/Flood/Duration/Multiple clock-pad cases), midnight-crossing invariant tests + +A `ProgramSchedule.PadToNearestMinute` (nullable int; null = off) makes the Classic builder pad every content +item up to the next N-minute clock boundary WITHOUT a hand-wired Pad `FillerPreset`. It reuses the existing +per-content-item Pad path (`PlayoutModeSchedulerBase.AddFiller`, already called once per emitted item): a +self-contained synthetic branch engages only when the item has no own `FillerMode.Pad` filler (the item's Pad +wins — no double-pad) and the parent schedule declares a positive divisor. The gap fills with the schedule +item's `FallbackFiller` via the existing `FallbackFillerForPad`; when no fallback content exists, the branch +records an offline target and the four schedulers advance `PlayoutBuilderState.CurrentTime` to the boundary, +leaving an implicit offline gap (the same representation fixed-start items use — absence of a `PlayoutItem`, +rendered as "Channel is Offline" at stream time). That advance is carried by a transient per-build +`PlayoutSchedulerResult.ClockPadOfflineTarget` (never serialized — no anchor schema, no migration); the +day-seam anchor clamp is exempted by exact equality with that target. + +This is the per-schedule convenience layer deferred behind #388 in `sched.clock-padding-existing`; that +record's per-item Pad-preset behavior is unchanged. Determinism needs no new anchor/seed state (the pad math +is a pure function of offsets). Coverage is per-scheduler-mode (One/Flood/Duration/Multiple) via golden and +invariant tests across midnight crossings. The SPA schedule editor exposes it as a 5/10/15/30/60 minute +picker; TZ-independence holds only for divisors of 60. See #77 (prior art) and #392. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index d513f1261..1c53a4697 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -114,6 +114,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera | `sched.autotune-per-channel-overrides` | Auto-Tune per-channel overrides reuse the Channel Builder's advanced-options DTO verbatim; per-source weights and bug-colour logo are deferred to #425. | 2026-07-17 | [link](../decisions.md#2026-07-17--auto-tune-per-channel-overrides-reuse-the-channel-builder-advanced-options-dto-weights--bug-colour-logo-split-out-to-425-385) | | `sched.autotune-per-source-weights` | Auto-Tune per-source rotation weights and query corrections are supplied at bulk-create time via #70's MultiCollection/SmartCollection machinery, not a post-hoc PUT. | 2026-07-18 | [link](../decisions.md#2026-07-18--auto-tune-per-source-weights-ride-70s-multicollection-machinery-created-at-tune-time-not-a-post-hoc-put-425) | | `sched.clock-padding-existing` | Clock-boundary padding already exists via `FillerPreset`'s `FillerMode.Pad` (Classic) and `pad_to_next`/`pad_until` (Sequential/YAML); #77 is closed as verified+documented, not built new, with a one-click per-channel toggle deferred behind the #388 design-system epic. | 2026-07-17 | [link](../decisions.md#2026-07-17--clock-boundary-schedule-padding-already-exists-fillermodepad-77-verified-convenience-toggle-deferred) | +| `sched.clock-padding-schedule-toggle` | A `ProgramSchedule.PadToNearestMinute` (nullable int; null = off) makes the Classic builder pad every content item up to the next N-minute clock boundary without a hand-wired Pad `FillerPreset`, by reusing the existing per-content-item Pad path in `PlayoutModeSchedulerBase.AddFiller`. It extends — does not supersede — `sched.clock-padding-existing` (#77/#388). | 2026-07-22 | [link](../decisions.md#2026-07-22--per-schedule-clock-boundary-padding-is-a-synthetic-content-less-pad-over-the-existing-per-episode-machinery-392) | | `sched.playbackorder-support-matrix` | Every build-time dispatch site logs a loud (non-fatal) warning on an unsupported `PlaybackOrder`, and a declared `PlaybackOrderSupport` matrix + partition tripwire test makes adding a new order safe by construction. | 2026-07-18 | [link](../decisions.md#2026-07-18--unsupported-playbackorder-is-loud-at-build-time-a-declared-support-matrix-and-tripwire-test-make-new-orders-safe-by-construction-403) | | `sched.reshuffle-scoped-reset` | `POST /api/v1/playouts/{id}/reshuffle` runs `ErasePlayoutHistory` (reseeds `Playout.Seed` + clears anchors/rerun-history) then enqueues a scoped `Reset` build, so reshuffle always reseeds — even for the non-Classic kinds `Reset` alone wouldn't reseed; `Playout.Seed` is surfaced on list/detail DTOs as visible confirmation. | 2026-07-16 | [link](../decisions.md#2026-07-16--per-playout-reshuffle--scoped-reset-build-seed-surfaced-71) | | `sched.seasonal-scheduling-existing` | Seasonal/date-conditional scheduling already ships first-class via `IAlternateScheduleItem` (Classic `ProgramScheduleAlternate`, Block `PlayoutTemplate`) evaluated by `AlternateScheduleSelector.GetScheduleForDate` (first match in `Index` order, catch-all last); #73 is closed as already-implemented with a docs-only "seasonal/holiday" recipe added, not new code. | 2026-07-17 | [link](../decisions.md#2026-07-17--seasonal--date-conditional-scheduling-already-exists-alternate-schedules--playout-templates-73-closed-as-implemented) | diff --git a/docs/domain-model.md b/docs/domain-model.md index 42ae9a278..b0d4b75ae 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -64,7 +64,7 @@ Channel (1) ──< Playout (0..N per channel; ChannelPlayoutSource distinguishe | **DecoTemplate** | Time-of-day (`DecoTemplateItem.StartTime`/`EndTime`) calendar of `Deco`s, assigned to a playout via `PlayoutTemplate.DecoTemplateId` (same row as the Block-template assignment — one `PlayoutTemplate` entry carries both a `Template` and an optional `DecoTemplate`). | `DecoTemplate`, `DecoTemplateItem`, `DecoTemplateGroup` | `/app/deco-templates` | | **Default deco vs deco templates** | `Playout.DecoId` = one static deco for the whole playout; `PlayoutTemplate.DecoTemplateId` = a time-varying deco schedule. Both are optional and independent. | `Playout`, `PlayoutTemplate` | `/app/playouts/{id}/templates` | | **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` | +| **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). `ProgramSchedule.PadToNearestMinute` (nullable int; null = off) is the per-schedule convenience layer (#392): optional clock-boundary padding divisor (minutes); when set, the Classic builder pads each content item up to the next boundary, filling with the item's FallbackFiller else offline. See `decisions.md` 2026-07-17 and 2026-07-22. | `FillerPreset` (`Pad`), `ProgramSchedule` (`PadToNearestMinute`) | `/app/filler-presets`, `/app/schedules` | | **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` | `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` |