feat(392): per-schedule clock-boundary padding toggle + 60-min increment #573

Merged
timothy merged 14 commits from feat/392-clock-align-schedule into main 2026-07-23 09:10:29 +02:00
Owner

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

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)
timothy added 13 commits 2026-07-23 08:22:27 +02:00
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
chore(392): regenerate migration + OpenAPI + decisions catalog after rebase onto main
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 27s
PR Gates / Docs update reminder (pull_request) Successful in 38s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 34s
PR Gates / decisions lifecycle (pull_request) Failing after 43s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 14m55s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 18m23s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 22m58s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 23m49s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
60fecd18b7
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>
Author
Owner

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.

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.
timothy added 1 commit 2026-07-23 08:48:34 +02:00
docs(392): drop predecessor cross-ref line (append-only decisions diff)
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 12s
PR Gates / decisions lifecycle (pull_request) Successful in 21s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m53s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 4m44s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 8s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m58s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 19m45s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
bb3e245b3c
Author
Owner

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.

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 main 2026-07-23 09:10:29 +02:00
timothy deleted branch feat/392-clock-align-schedule 2026-07-23 09:10:31 +02:00
Sign in to join this conversation.