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

**Issue:** [ersatztv#392](http://192.168.1.95:3000/timothy/ersatztv/issues/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_GOLDENS` — **never** 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)
