Files
ersatztv/docs/superpowers/specs/2026-07-22-clock-align-schedule-toggle-design.md
T
2026-07-23 08:03:31 +02:00

7.8 KiB

Design: per-schedule clock-boundary padding toggle (#392)

Issue: ersatztv#392 — "Clock-align convenience: one-click per-channel/schedule pad-to-boundary toggle + 60-min increment (SPA)". Follow-up to #77.

Status: approved design, pre-implementation.

Problem

Clock-boundary padding for Classic playouts already works end-to-end: a FillerPreset with FillerMode.Pad + PadToNearestMinute = N, attached to a schedule item's Pre/Mid/PostRoll slot, snaps each emitted content item up to the next N-minute clock boundary. AddFiller (PlayoutModeSchedulerBase.cs) is called once per emitted content item inside every scheduler loop (One/Multiple/Flood/Duration), so the padding is already per-episode, and it already tops up the gap with the schedule item's FallbackFiller via FallbackFillerForPad when the pad preset's own content is exhausted. Verified by PlayoutBuildGoldenTests.Classic_clock_padded + ChannelGuideProjectorClockPadTests. This is the sched.clock-padding-existing decision (#77/#388).

The only gap is convenience: to clock-align a whole schedule today, a user must create a Pad FillerPreset and hand-wire it into every schedule item's filler slot. #392 collapses that to a single schedule-level setting. #388 (the design-system epic this UI work was gated behind) has since closed, so the toggle is unblocked.

Approved decisions (from brainstorming)

  1. Home = per-schedule, not per-channel. Lives on ProgramSchedule beside its existing behavior flags (ShuffleScheduleItems, RandomStartPoint, FixedStartTimeBehavior). Per-channel was rejected as awkward plumbing (the channel drives a schedule; the builder would have to reach through the playout).
  2. Fill source = the schedule item's FallbackFiller if configured, else offline (dead-air gap). Reuses the existing FallbackFillerForPad top-up path; no content-selection surface on the toggle.
  3. Granularity = per-episode — every content item padded to a boundary (real-TV-listing look). This is what the existing per-item Pad path already does; no new per-item loop is needed.
  4. Precedence: when a schedule item has its own hand-wired Pad filler preset, the item's Pad wins and the schedule-level pad is skipped for that item. This respects explicit per-item config and avoids AddFiller's existing "more than one Pad filler" guard-rail error.

The one genuinely-new behavior

Today, when a Pad path computes a gap but has no content and no fallback filler, FallbackFillerForPad returns None and inserts nothing — so nextState.CurrentTime advances only to the content item's end and the next item starts immediately (no offline gap, no boundary alignment). To honor decision #2's "else offline", the schedule-level pad must, when nothing fills the gap, advance the build clock to the boundary target time, leaving a true offline gap (an absence of a PlayoutItem, which the streaming layer renders as "Channel is Offline"). This is the only new builder behavior; it is localized to the pad-fill branch and only engages for the schedule-level synthetic pad, never changing the existing per-item Pad-preset behavior.

Design

1. Data model

  • Add int? PadToNearestMinute to ErsatzTV.Core/Domain/ProgramSchedule.cs (null / absent = feature off).
  • EF config unchanged structurally (nullable int column); dual-provider migration via scripts/add-migration.sh <Name> (Sqlite and MySql).

2. Builder wiring (ErsatzTV.Core/Scheduling/PlayoutModeSchedulerBase.cs, AddFiller)

  • At pad-preset selection (~L583-585): if the item has no Pre/Mid/Post filler with FillerMode.Pad and the parent ProgramSchedule.PadToNearestMinute is set, synthesize a content-less Pad behavior with that divisor (treat as PostRoll-style, matching the Classic_clock_padded fixture).
  • The synthetic pad runs the existing boundary math. With no pad collection it skips AddDurationFiller and goes straight to FallbackFillerForPad → schedule item's FallbackFiller if present, else the new offline branch (advance CurrentTime to targetTime).
  • Precedence guard: presence of any item-level FillerMode.Pad filler suppresses the synthetic pad (decision #4). Never construct two pad fillers.
  • Access to the parent schedule flag: confirm AddFiller can reach the ProgramSchedule (via scheduleItem.ProgramSchedule nav or a builder-passed value). If the nav is not loaded on this path, thread the int? divisor down from PlayoutBuilder rather than force-loading a nav — decided at implementation time, but the value flows one-way and read-only.
  • Determinism: the pad boundary math is a pure function of playoutItem.StartOffset + already-added filler durations (no wall-clock "now" dependency), and FallbackFillerForPad advances its own enumerator, so no new PlayoutAnchor/Seed state is required (same reason #77 needed none).

3. API

  • Expose padToNearestMinute (nullable int) on the ProgramSchedule response DTO and the create/update request DTO. Confirm the exact DTO/handler names during planning (schedule GET/PUT surface).
  • Regenerate OpenAPI artifacts: build the app project, ./scripts/update-openapi.sh, npm run generate:api (per process.pr-routine-sequence). Ships v1.json, v1.d.ts, endpoint-index.md in the same diff (blocking api-docs gate).

4. SPA

  • Add a "Pad to clock boundary" control to the schedule editor screen: a minute Select with (none) = off and options 5 / 10 / 15 / 30 / 60, bound to padToNearestMinute. Follow spa-conventions.md.
  • Add 60 to PAD_OPTIONS in web/src/screens/FillerPresetsScreen.tsx (currently [5, 10, 15, 30]; the backend already accepts any integer).

5. Tests

  • New golden test alongside PlayoutBuildGoldenTests.Classic_clock_padded, driven by a schedule with PadToNearestMinute set and no per-item Pad preset. Two cases:
    • with FallbackFiller → gap filled with fallback content, block ends on the boundary;
    • without FallbackFiller → offline gap, next item starts on the boundary.
  • A test asserting precedence: an item with its own Pad preset + schedule-level pad on → behaves exactly as the item's Pad preset (schedule pad suppressed, no double-pad error).
  • Regenerate goldens only locally with ETV_UPDATE_PLAYOUT_GOLDENSnever set it in CI.
  • TZ-independence holds only when the divisor divides 60; keep the existing golden's TZ-invariance note.

6. Docs

  • New decision record extending (not reversing) sched.clock-padding-existing: the per-schedule convenience layer over the existing per-item Pad machinery + the new offline-advance semantics. Add the record; cross-reference the predecessor; no archive move (not a supersession).
  • Update docs/domain-model.md (ProgramSchedule field) and docs/spa-conventions.md if the schedule-editor control introduces a new pattern; docs/api-conventions.md checklist for the DTO change.

Non-goals / YAGNI

  • Per-channel toggle (rejected — per-schedule chosen).
  • Tunarr's pad-per-slot-vs-episode as a configurable distinction — per-episode is the single behavior.
  • A content-selection surface on the toggle (fills with fallback-else-offline only).
  • Any change to Sequential/YAML (pad_to_next/pad_until already cover those engines) or Block.

Risk & review

Touches a DB migration + Classic builder logic + an API write-path DTO → independent review is mandatory (process.independent-review-rubric). Live-E2E on the schedule editor round-trip (release.live-e2e-required) before push.

Done-when (mirrors the issue)

  • adversarial review passed
  • backend option + API + Classic builder wiring, with tests
  • SPA control added + 60-min increment option
  • docs updated (domain-model / decisions / spa-conventions / api-conventions as applicable)