Adds a per-ProgramSchedulePad to clock boundary option: when set, the Classic playout builder pads every content item up to the next N-minute clock boundary without a hand-wired Pad FillerPreset.
What changed
Data model:ProgramSchedule.PadToNearestMinute (nullable int; null = off). Dual-provider migration.
Builder: reuses the existing per-content-item Pad path in PlayoutModeSchedulerBase.AddFiller. A self-contained synthetic content-less pad engages only when the item has no ownFillerMode.Pad filler and the parent schedule's PadToNearestMinute > 0 (the item's own Pad filler wins). Gap fills with the schedule item's FallbackFiller, else an offline gap — the build clock advances to the boundary via a transient PlayoutSchedulerResult.ClockPadOfflineTarget (never serialized; the day-seam anchor clamp is exempted by exact equality, not a magnitude heuristic). Deterministic — no new anchor/seed state. Boundary math shared via one ComputePadBoundary helper.
API:padToNearestMinute on the schedule GET/POST/PUT DTOs (<=0 normalized to null). OpenAPI regenerated.
SPA: "Pad to clock boundary" control (5/10/15/30/60) on the schedule editor; 60 added to the filler-preset pad increment.
Docs: decision sched.clock-padding-schedule-toggle (extends sched.clock-padding-existing), domain-model field. Design-system prototype mirrored (FillerPresets 60-min option; the schedule-level scalar form isn't modeled in the prototype, so the pad control has no prototype counterpart).
Verification
Golden + invariant tests across all scheduler modes (One/Flood/Duration/Multiple) and two midnight crossings; precedence test (item pad wins); existing Classic_clock_padded golden byte-identical.
Full suite green (Core 650/1-skip, ErsatzTV 1829); check:api clean; migration model-drift clean both providers.
Adversarial whole-branch review passed — caught & fixed one cross-layer defect (schedule pad no-op'd for Fill-With-Group items: reverse nav lost in Newtonsoft DeepCopy), with a regression test.
Live-E2E:padToNearestMinute round-trips through the real write→read path (create=30, update=15, null/0/-5 → null).
Rebased onto current main (post the #74 On Now/Next overlay merge); OpenAPI/migration/decisions-catalog regenerated after rebase.
Deferred (non-blocking)
Minor: PlayoutModeSchedulerDuration offline-tail leaves clockPadOfflineTarget set-but-stale — harmless (guarded by exact equality); an optional = null reset would make the invariant literal.
API accepts pad divisors that don't divide 60 (TZ-dependent guides) — consistent with existing FillerPreset behavior; the SPA restricts to divisors of 60.
Fixes #392.
Adds a per-`ProgramSchedule` **Pad to clock boundary** option: when set, the Classic playout builder pads every content item up to the next N-minute clock boundary without a hand-wired Pad `FillerPreset`.
## What changed
- **Data model:** `ProgramSchedule.PadToNearestMinute` (nullable int; null = off). Dual-provider migration.
- **Builder:** reuses the existing per-content-item Pad path in `PlayoutModeSchedulerBase.AddFiller`. A self-contained synthetic content-less pad engages only when the item has **no own** `FillerMode.Pad` filler and the parent schedule's `PadToNearestMinute > 0` (the item's own Pad filler **wins**). Gap fills with the schedule item's `FallbackFiller`, else an **offline** gap — the build clock advances to the boundary via a transient `PlayoutSchedulerResult.ClockPadOfflineTarget` (never serialized; the day-seam anchor clamp is exempted by **exact equality**, not a magnitude heuristic). Deterministic — no new anchor/seed state. Boundary math shared via one `ComputePadBoundary` helper.
- **API:** `padToNearestMinute` on the schedule GET/POST/PUT DTOs (`<=0` normalized to null). OpenAPI regenerated.
- **SPA:** "Pad to clock boundary" control (5/10/15/30/60) on the schedule editor; `60` added to the filler-preset pad increment.
- **Docs:** decision `sched.clock-padding-schedule-toggle` (extends `sched.clock-padding-existing`), domain-model field. Design-system prototype mirrored (FillerPresets 60-min option; the schedule-level scalar form isn't modeled in the prototype, so the pad control has no prototype counterpart).
## Verification
- Golden + invariant tests across **all** scheduler modes (One/Flood/Duration/Multiple) and two midnight crossings; precedence test (item pad wins); existing `Classic_clock_padded` golden **byte-identical**.
- Full suite green (Core 650/1-skip, ErsatzTV 1829); `check:api` clean; migration model-drift clean both providers.
- Adversarial whole-branch review passed — caught & fixed one cross-layer defect (schedule pad no-op'd for Fill-With-Group items: reverse nav lost in Newtonsoft `DeepCopy`), with a regression test.
- **Live-E2E:** `padToNearestMinute` round-trips through the real write→read path (create=30, update=15, null/0/-5 → null).
- Rebased onto current `main` (post the #74 On Now/Next overlay merge); OpenAPI/migration/decisions-catalog regenerated after rebase.
## Deferred (non-blocking)
- Minor: `PlayoutModeSchedulerDuration` offline-tail leaves `clockPadOfflineTarget` set-but-stale — harmless (guarded by exact equality); an optional `= null` reset would make the invariant literal.
- API accepts pad divisors that don't divide 60 (TZ-dependent guides) — consistent with existing `FillerPreset` behavior; the SPA restricts to divisors of 60.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Adds parameterized invariant tests (Schedule_clock_padded_offline_multimode)
exercising schedule-level clock pad + offline advance across a 2-day window
(two midnight crossings) through Flood, Duration, and Multiple — previously
only PlayoutModeSchedulerOne had any coverage. Fixture uses sub-15-min content
so each padded item occupies one :15 slot and Duration's fill-the-block
contract tiles exactly (no off-boundary packing).
Part 2 (precision): replaces the day-boundary anchor-clamp magnitude heuristic
(overrun <= one pad interval on a padded schedule) with a precise signal — the
last scheduler now reports the exact offline-pad target it advanced CurrentTime
to (transient PlayoutSchedulerResult.ClockPadOfflineTarget, never persisted),
and the clamp exempts only when CurrentTime equals that target exactly. A
non-offline overrun (Duration/Flood/Multiple ending short of its natural end, a
hard-stop, a tail advance) changes CurrentTime away from the target and still
clamps, so persisted NextStart no longer shifts by up to an interval. No
persisted-schema/migration change. Output byte-identical for all existing
goldens.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Schedule-level pad control has no prototype counterpart (the schedule-level
scalar form is not modeled in Schedules.jsx); nothing to mirror there.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The rebase onto origin/main (which merged #74's ChannelGraphicsElement
migration) left the PadToNearestMinute migration's embedded Designer.cs
model snapshot stale — it still reflected the pre-#74 model, so EF's
diff against it produced an empty Up()/Down() when naively regenerated.
Reset TvContextModelSnapshot.cs to origin/main's true post-#74 state,
then re-ran scripts/add-migration.sh so the migration's Designer.cs
correctly folds in ChannelGraphicsElement and the migration's Up() adds
only the PadToNearestMinute column. Verified has-pending-model-changes
is clean for both providers.
OpenAPI (v1.json/v1.d.ts/endpoint-index.md) and docs/decisions/README.md
regenerated identically to the auto-merged state, so nothing to commit
there — confirmed both padToNearestMinute and #74's graphics-elements
endpoints/decision key are present.
Stripped a UTF-8 BOM this dotnet-ef/dotnet-format toolchain wrote into
the regenerated migration + snapshot files (known BOM trap, ersatztv#311).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Substantive adversarial review chain (per-task reviews + a frontier whole-branch review + fix re-review) was clean at 0102602c; the whole-branch pass caught and fixed one cross-layer defect (schedule pad no-op'd for Fill-With-Group items — reverse nav lost in Newtonsoft DeepCopy), verified by a regression test with a reproduced negative control. The delta from 0102602c to head 60fecd18 is a mechanical rebase onto current main (clean auto-merge; source disjoint from #74) plus regeneration of the migration snapshot, OpenAPI (v1.json/v1.d.ts/endpoint-index.md), and the decisions catalog — all gate-validated: check:api clean, migration model-drift clean on both providers, full suite green, live-E2E round-trip confirmed.
Review-verdict: MERGEABLE @ 60fecd18
Substantive adversarial review chain (per-task reviews + a frontier whole-branch review + fix re-review) was clean at `0102602c`; the whole-branch pass caught and fixed one cross-layer defect (schedule pad no-op'd for Fill-With-Group items — reverse nav lost in Newtonsoft `DeepCopy`), verified by a regression test with a reproduced negative control. The delta from `0102602c` to head `60fecd18` is a mechanical rebase onto current `main` (clean auto-merge; source disjoint from #74) plus regeneration of the migration snapshot, OpenAPI (`v1.json`/`v1.d.ts`/`endpoint-index.md`), and the decisions catalog — all gate-validated: `check:api` clean, migration model-drift clean on both providers, full suite green, live-E2E round-trip confirmed.
Delta from the previously-reviewed 60fecd18 is a single one-line docs fix: dropped the optional "Extended by" cross-reference from the sched.clock-padding-existing record body so the decisions.md diff is a pure append (the CI decisions lifecycle diff-check requires a [decisions-edit] token for editing an existing record's prose; the cross-ref was redundant with the new record's own back-link). Reproduced the check locally (decisions_validate.py --base origin/main --head HEAD → OK). All other CI jobs on 60fecd18 (Build & test, EF migration integrity, API docs in sync, Functional E2E, image, format) passed; this push only re-runs them.
Review-verdict: MERGEABLE @ bb3e245b
Delta from the previously-reviewed `60fecd18` is a single one-line docs fix: dropped the optional "Extended by" cross-reference from the `sched.clock-padding-existing` record body so the decisions.md diff is a pure append (the CI `decisions lifecycle` diff-check requires a `[decisions-edit]` token for editing an existing record's prose; the cross-ref was redundant with the new record's own back-link). Reproduced the check locally (`decisions_validate.py --base origin/main --head HEAD` → OK). All other CI jobs on `60fecd18` (Build & test, EF migration integrity, API docs in sync, Functional E2E, image, format) passed; this push only re-runs them.
timothy
merged commit 41291b3686 into main2026-07-23 09:10:29 +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.
Fixes #392.
Adds a per-
ProgramSchedulePad to clock boundary option: when set, the Classic playout builder pads every content item up to the next N-minute clock boundary without a hand-wired PadFillerPreset.What changed
ProgramSchedule.PadToNearestMinute(nullable int; null = off). Dual-provider migration.PlayoutModeSchedulerBase.AddFiller. A self-contained synthetic content-less pad engages only when the item has no ownFillerMode.Padfiller and the parent schedule'sPadToNearestMinute > 0(the item's own Pad filler wins). Gap fills with the schedule item'sFallbackFiller, else an offline gap — the build clock advances to the boundary via a transientPlayoutSchedulerResult.ClockPadOfflineTarget(never serialized; the day-seam anchor clamp is exempted by exact equality, not a magnitude heuristic). Deterministic — no new anchor/seed state. Boundary math shared via oneComputePadBoundaryhelper.padToNearestMinuteon the schedule GET/POST/PUT DTOs (<=0normalized to null). OpenAPI regenerated.60added to the filler-preset pad increment.sched.clock-padding-schedule-toggle(extendssched.clock-padding-existing), domain-model field. Design-system prototype mirrored (FillerPresets 60-min option; the schedule-level scalar form isn't modeled in the prototype, so the pad control has no prototype counterpart).Verification
Classic_clock_paddedgolden byte-identical.check:apiclean; migration model-drift clean both providers.DeepCopy), with a regression test.padToNearestMinuteround-trips through the real write→read path (create=30, update=15, null/0/-5 → null).main(post the #74 On Now/Next overlay merge); OpenAPI/migration/decisions-catalog regenerated after rebase.Deferred (non-blocking)
PlayoutModeSchedulerDurationoffline-tail leavesclockPadOfflineTargetset-but-stale — harmless (guarded by exact equality); an optional= nullreset would make the invariant literal.FillerPresetbehavior; the SPA restricts to divisors of 60.🤖 Generated with Claude Code
Review-verdict: MERGEABLE @
60fecd18Substantive adversarial review chain (per-task reviews + a frontier whole-branch review + fix re-review) was clean at
0102602c; the whole-branch pass caught and fixed one cross-layer defect (schedule pad no-op'd for Fill-With-Group items — reverse nav lost in NewtonsoftDeepCopy), verified by a regression test with a reproduced negative control. The delta from0102602cto head60fecd18is a mechanical rebase onto currentmain(clean auto-merge; source disjoint from #74) plus regeneration of the migration snapshot, OpenAPI (v1.json/v1.d.ts/endpoint-index.md), and the decisions catalog — all gate-validated:check:apiclean, migration model-drift clean on both providers, full suite green, live-E2E round-trip confirmed.Review-verdict: MERGEABLE @
bb3e245bDelta from the previously-reviewed
60fecd18is a single one-line docs fix: dropped the optional "Extended by" cross-reference from thesched.clock-padding-existingrecord body so the decisions.md diff is a pure append (the CIdecisions lifecyclediff-check requires a[decisions-edit]token for editing an existing record's prose; the cross-ref was redundant with the new record's own back-link). Reproduced the check locally (decisions_validate.py --base origin/main --head HEAD→ OK). All other CI jobs on60fecd18(Build & test, EF migration integrity, API docs in sync, Functional E2E, image, format) passed; this push only re-runs them.