Files
ersatztv/docs/superpowers/plans/2026-07-22-clock-align-schedule-toggle.md
T

914 lines
43 KiB
Markdown

# Per-schedule clock-boundary padding toggle (#392) — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or
> superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a per-`ProgramSchedule` "pad every content item up to the next N-minute clock boundary" option
that the Classic playout builder honors automatically — no hand-wired Pad `FillerPreset` — filling each gap
with the schedule item's `FallbackFiller` if configured, else an offline gap; plus a 60-minute increment in
the filler-preset editor.
**Architecture:** Reuse the existing per-episode Pad machinery. `AddFiller`
(`PlayoutModeSchedulerBase`) already runs once per emitted content item. We add a **self-contained synthetic
-pad branch** in `AddFiller` that engages only when the item has no own Pad filler and its parent schedule
has `PadToNearestMinute` set; it computes the boundary target, fills with `FallbackFillerForPad`, and — when
no fallback content exists — returns an offline target that each scheduler uses to advance the build clock
(leaving an implicit offline gap, exactly as fixed-start items do). The schedule-level divisor reaches
`AddFiller` by populating the (currently null) `ProgramScheduleItem.ProgramSchedule` reverse navigation once,
centrally, in `PlayoutBuilder`.
**Tech Stack:** C#/.NET 10, EF Core (dual-provider Sqlite + MySql migrations), MediatR CQRS, LanguageExt,
NUnit + Shouldly golden tests, ChicoryTV React SPA (Vite/TS), OpenAPI-generated TS types.
## Global Constraints
- Work in the worktree `/Users/timothy/ersatztv/.claude/worktrees/392-clock-align` on branch
`feat/392-clock-align-schedule` (off `origin/main`). Never commit in the shared tree.
- DB model changes need a migration in **both** `ErsatzTV.Infrastructure.Sqlite` and
`ErsatzTV.Infrastructure.MySql`, generated via `scripts/add-migration.sh <Name>` (never hand-authored
snapshots).
- Any `/api/*` DTO change must regenerate OpenAPI artifacts in the SAME diff: `dotnet build ErsatzTV.sln`
`./scripts/update-openapi.sh``cd web && npm run generate:api`; commit
`ErsatzTV/wwwroot/openapi/v1.json`, `web/src/api/generated/v1.d.ts`, `docs/endpoint-index.md`.
- Response DTOs carry a file-scoped `#nullable enable`; request DTOs do not.
- All ProgramSchedule DTOs/records/VMs are **positional records** — add the new field in a CONSISTENT
position (append to the end) across every one, or the positional constructors misalign.
- **Never** set `ETV_UPDATE_PLAYOUT_GOLDENS` in CI; only locally to regenerate a golden, then review + commit it.
- Before any push touching `.cs`: BOM-check the touched set (`git diff --name-only origin/main | grep '\.cs$'`
then `xxd -p <f> | grep -c '^efbbbf'` must be 0) and run the format gate under `bash -c`.
- Commit hooks in the worktree can misfire — commit with `git -c core.hooksPath=/dev/null commit`, then run
the format/BOM checks manually.
- Independent cross-model review is MANDATORY before push (migration + builder + API write-path).
- Pad math is TZ-independent ONLY when the divisor divides 60 (all real IANA offsets are multiples of 15).
Keep golden divisors at 15; the SPA offers only 5/10/15/30/60.
---
### Task 1: 60-minute increment in the filler-preset editor (SPA, standalone)
Independent of everything else — ship first.
**Files:**
- Modify: `web/src/screens/FillerPresetsScreen.tsx:42`
**Interfaces:**
- Consumes: nothing.
- Produces: nothing other tasks depend on.
- [ ] **Step 1: Add 60 to `PAD_OPTIONS`.** Change line 42 from:
```tsx
const PAD_OPTIONS = [5, 10, 15, 30].map((minutes) => ({ label: String(minutes), value: String(minutes) }));
```
to:
```tsx
const PAD_OPTIONS = [5, 10, 15, 30, 60].map((minutes) => ({ label: String(minutes), value: String(minutes) }));
```
- [ ] **Step 2: Build the web app to verify no type/lint error.**
Run: `cd web && npm run build`
Expected: build succeeds.
- [ ] **Step 3: Lint.**
Run: `cd web && npm run lint`
Expected: no new errors in `FillerPresetsScreen.tsx`.
- [ ] **Step 4: Commit.**
```bash
git -c core.hooksPath=/dev/null add web/src/screens/FillerPresetsScreen.tsx
git -c core.hooksPath=/dev/null commit -m "feat(392): add 60-minute option to filler-preset pad increment"
```
---
### Task 2: `ProgramSchedule.PadToNearestMinute` column + dual-provider migration
**Files:**
- Modify: `ErsatzTV.Core/Domain/ProgramSchedule.cs`
- Generated: `ErsatzTV.Infrastructure.Sqlite/Migrations/*_Add_ProgramSchedule_PadToNearestMinute.cs` (+ `.Designer.cs` + snapshot)
- Generated: `ErsatzTV.Infrastructure.MySql/Migrations/*_Add_ProgramSchedule_PadToNearestMinute.cs` (+ `.Designer.cs` + snapshot)
**Interfaces:**
- Produces: `ProgramSchedule.PadToNearestMinute``public int? PadToNearestMinute { get; set; }` (null = feature off).
- [ ] **Step 1: Add the property.** In `ErsatzTV.Core/Domain/ProgramSchedule.cs`, after
`FixedStartTimeBehavior` (currently the last scalar before the nav collections), add:
```csharp
public int? PadToNearestMinute { get; set; }
```
- [ ] **Step 2: Generate both migrations.** From the worktree root:
Run: `./scripts/add-migration.sh Add_ProgramSchedule_PadToNearestMinute`
Expected: creates one migration pair under each provider's `Migrations/` folder and updates both
`TvContextModelSnapshot.cs`. The generated `Up` should contain an `AddColumn<int>` on `ProgramSchedule`
named `PadToNearestMinute`, `nullable: true`, no `defaultValue`.
- [ ] **Step 3: Verify the generated Up/Down.** Open both generated `*_Add_ProgramSchedule_PadToNearestMinute.cs`
and confirm they match this shape (Sqlite `type: "INTEGER"`, MySql `type: "int"`):
```csharp
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "PadToNearestMinute",
table: "ProgramSchedule",
type: "INTEGER", // MySql: "int"
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PadToNearestMinute",
table: "ProgramSchedule");
}
```
If EF emitted `defaultValue: 0` or `nullable: false`, fix it to the above (a nullable int must have neither).
- [ ] **Step 4: Build the two migration projects.**
Run: `dotnet build ErsatzTV.Infrastructure.Sqlite ErsatzTV.Infrastructure.MySql`
Expected: build succeeds.
- [ ] **Step 5: Commit.**
```bash
git -c core.hooksPath=/dev/null add ErsatzTV.Core/Domain/ProgramSchedule.cs \
ErsatzTV.Infrastructure.Sqlite/Migrations ErsatzTV.Infrastructure.MySql/Migrations
git -c core.hooksPath=/dev/null commit -m "feat(392): add ProgramSchedule.PadToNearestMinute column (dual-provider migration)"
```
---
### Task 3: Expose `padToNearestMinute` through the REST API
Depends on Task 2. Adds the field to the read + write chains and regenerates OpenAPI.
**Files:**
- Modify: `ErsatzTV.Application/ProgramSchedules/ProgramScheduleViewModel.cs`
- Modify: `ErsatzTV.Application/ProgramSchedules/Mapper.cs`
- Modify: `ErsatzTV.Core/Api/Scheduling/ProgramScheduleResponseModel.cs`
- Modify: `ErsatzTV/Controllers/Api/ScheduleController.cs` (`ProjectToResponseModel`)
- Modify: `ErsatzTV/Controllers/Api/Requests/CreateScheduleRequest.cs`
- Modify: `ErsatzTV/Controllers/Api/Requests/UpdateScheduleRequest.cs`
- Modify: `ErsatzTV.Application/ProgramSchedules/Commands/CreateProgramSchedule.cs`
- Modify: `ErsatzTV.Application/ProgramSchedules/Commands/CreateProgramScheduleHandler.cs`
- Modify: `ErsatzTV.Application/ProgramSchedules/Commands/UpdateProgramSchedule.cs`
- Modify: `ErsatzTV.Application/ProgramSchedules/Commands/UpdateProgramScheduleHandler.cs`
- Generated (regen, commit): `ErsatzTV/wwwroot/openapi/v1.json`, `web/src/api/generated/v1.d.ts`, `docs/endpoint-index.md`
- Test: `ErsatzTV.Application.Tests` (or the existing schedule handler test project — grep for
`UpdateProgramScheduleHandler`/`CreateProgramScheduleHandler` tests; if none exist, add a minimal one as below)
**Interfaces:**
- Consumes: `ProgramSchedule.PadToNearestMinute` (Task 2).
- Produces:
- `ProgramScheduleResponseModel(..., FixedStartTimeBehavior, int? PadToNearestMinute)`
- `ProgramScheduleViewModel(..., FixedStartTimeBehavior, int? PadToNearestMinute, int Version)` — note
`PadToNearestMinute` goes **before** `Version` to match existing positional order (Version is last).
- `CreateScheduleRequest`/`UpdateScheduleRequest`/`CreateProgramSchedule`/`UpdateProgramSchedule` each gain a
trailing `int? PadToNearestMinute` positional member.
- TS type `components['schemas']['ProgramScheduleResponseModel'].padToNearestMinute: number | null` and the
matching Create/Update request schemas.
- [ ] **Step 1: Write a failing handler round-trip test.** Find the existing schedule handler tests (grep
`FullyQualifiedName~ProgramSchedule` under `*.Tests`). If a `CreateProgramScheduleHandler`/`UpdateProgramScheduleHandler`
test class exists, add a test there; otherwise create
`ErsatzTV.Application.Tests/ProgramSchedules/ProgramSchedulePadToNearestMinuteTests.cs` mirroring the nearest
existing handler test's setup (in-memory `TvContext` via the shared test fixture). The test asserts the field
round-trips create→read and update→read:
```csharp
[Test]
public async Task Create_and_update_persist_PadToNearestMinute()
{
// Arrange: create a schedule with PadToNearestMinute = 30 via CreateProgramSchedule,
// then load it and assert entity.PadToNearestMinute == 30;
// then UpdateProgramSchedule with PadToNearestMinute = null and assert it clears to null.
// (Mirror the arrange/act pattern of the nearest existing ProgramSchedule handler test.)
}
```
Keep it minimal and consistent with the existing handler-test style (do not invent a new harness).
- [ ] **Step 2: Run it — expect a COMPILE failure** (the new positional arg doesn't exist yet).
Run: `dotnet test ErsatzTV.Application.Tests --filter "FullyQualifiedName~PadToNearestMinute"`
Expected: does not compile / FAIL.
- [ ] **Step 3: Add the field to the read chain.**
`ErsatzTV.Application/ProgramSchedules/ProgramScheduleViewModel.cs` — add `int? PadToNearestMinute` before `int Version`:
```csharp
public record ProgramScheduleViewModel(
int Id,
string Name,
bool KeepMultiPartEpisodesTogether,
bool TreatCollectionsAsShows,
bool ShuffleScheduleItems,
bool RandomStartPoint,
FixedStartTimeBehavior FixedStartTimeBehavior,
int? PadToNearestMinute,
int Version);
```
`ErsatzTV.Application/ProgramSchedules/Mapper.cs` — add the argument before `Version`:
```csharp
internal static ProgramScheduleViewModel ProjectToViewModel(ProgramSchedule programSchedule) =>
new(
programSchedule.Id,
programSchedule.Name,
programSchedule.KeepMultiPartEpisodesTogether,
programSchedule.TreatCollectionsAsShows,
programSchedule.ShuffleScheduleItems,
programSchedule.RandomStartPoint,
programSchedule.FixedStartTimeBehavior,
programSchedule.PadToNearestMinute,
programSchedule.Version);
```
`ErsatzTV.Core/Api/Scheduling/ProgramScheduleResponseModel.cs` — append the member:
```csharp
public record ProgramScheduleResponseModel(
int Id,
string Name,
bool KeepMultiPartEpisodesTogether,
bool TreatCollectionsAsShows,
bool ShuffleScheduleItems,
bool RandomStartPoint,
FixedStartTimeBehavior FixedStartTimeBehavior,
int? PadToNearestMinute);
```
`ErsatzTV/Controllers/Api/ScheduleController.cs` `ProjectToResponseModel` — append `vm.PadToNearestMinute`:
```csharp
private static ProgramScheduleResponseModel ProjectToResponseModel(ProgramScheduleViewModel vm) =>
new(
vm.Id,
vm.Name,
vm.KeepMultiPartEpisodesTogether,
vm.TreatCollectionsAsShows,
vm.ShuffleScheduleItems,
vm.RandomStartPoint,
vm.FixedStartTimeBehavior,
vm.PadToNearestMinute);
```
- [ ] **Step 4: Add the field to the write chain.**
`ErsatzTV/Controllers/Api/Requests/CreateScheduleRequest.cs` — append `int? PadToNearestMinute` to the record
and to `ToCreateCommand()`:
```csharp
public record CreateScheduleRequest(
string Name,
bool KeepMultiPartEpisodesTogether,
bool TreatCollectionsAsShows,
bool ShuffleScheduleItems,
bool RandomStartPoint,
FixedStartTimeBehavior FixedStartTimeBehavior,
int? PadToNearestMinute)
{
public CreateProgramSchedule ToCreateCommand() =>
new(
Name,
KeepMultiPartEpisodesTogether,
TreatCollectionsAsShows,
ShuffleScheduleItems,
RandomStartPoint,
FixedStartTimeBehavior,
PadToNearestMinute);
}
```
`ErsatzTV/Controllers/Api/Requests/UpdateScheduleRequest.cs` — same, `ToCommand(int id)`:
```csharp
public record UpdateScheduleRequest(
string Name,
bool KeepMultiPartEpisodesTogether,
bool TreatCollectionsAsShows,
bool ShuffleScheduleItems,
bool RandomStartPoint,
FixedStartTimeBehavior FixedStartTimeBehavior,
int? PadToNearestMinute)
{
public UpdateProgramSchedule ToCommand(int id) =>
new(
id,
Name,
KeepMultiPartEpisodesTogether,
TreatCollectionsAsShows,
ShuffleScheduleItems,
RandomStartPoint,
FixedStartTimeBehavior,
PadToNearestMinute);
}
```
`ErsatzTV.Application/ProgramSchedules/Commands/CreateProgramSchedule.cs` — append the member:
```csharp
public record CreateProgramSchedule(
string Name,
bool KeepMultiPartEpisodesTogether,
bool TreatCollectionsAsShows,
bool ShuffleScheduleItems,
bool RandomStartPoint,
FixedStartTimeBehavior FixedStartTimeBehavior,
int? PadToNearestMinute) : IRequest<Either<BaseError, CreateProgramScheduleResult>>;
```
`ErsatzTV.Application/ProgramSchedules/Commands/UpdateProgramSchedule.cs`:
```csharp
public record UpdateProgramSchedule(
int ProgramScheduleId,
string Name,
bool KeepMultiPartEpisodesTogether,
bool TreatCollectionsAsShows,
bool ShuffleScheduleItems,
bool RandomStartPoint,
FixedStartTimeBehavior FixedStartTimeBehavior,
int? PadToNearestMinute) : IRequest<Either<BaseError, UpdateProgramScheduleResult>>;
```
`CreateProgramScheduleHandler.cs` — set the field when building the entity (in the `new ProgramSchedule { ... }`
initializer), normalizing a non-positive value to null so only meaningful divisors persist:
```csharp
PadToNearestMinute = request.PadToNearestMinute is int m && m > 0 ? m : null
```
(add as the last initializer member; keep the existing trailing members intact).
`UpdateProgramScheduleHandler.cs` `ApplyUpdateRequest` — add to BOTH the rebuild diff and the write:
In `needToRefreshPlayout` (append a clause):
```csharp
programSchedule.FixedStartTimeBehavior != request.FixedStartTimeBehavior ||
programSchedule.PadToNearestMinute != (request.PadToNearestMinute is int upm && upm > 0 ? upm : null);
```
In the field writes (append):
```csharp
programSchedule.PadToNearestMinute = request.PadToNearestMinute is int upm2 && upm2 > 0 ? upm2 : null;
```
(Use distinct local names `upm`/`upm2` to avoid a redeclaration; or hoist a single `int? normalizedPad`
local before both uses — implementer's choice, keep it compiling.)
- [ ] **Step 5: Fix any OTHER positional constructor call sites the compiler flags.** Build and let the
compiler find every place that constructs these records positionally (e.g. Auto-Tune / channel-builder code
paths, Blazor-era callers if any remain). Add the new trailing arg (`null` where the caller has no pad
concept). Run:
Run: `dotnet build ErsatzTV.sln`
Expected: build succeeds after you supply the new arg at each flagged call site.
- [ ] **Step 6: Run the handler test — expect PASS.**
Run: `dotnet test ErsatzTV.Application.Tests --filter "FullyQualifiedName~PadToNearestMinute"`
Expected: PASS.
- [ ] **Step 7: Regenerate OpenAPI + TS types.**
```bash
dotnet build ErsatzTV.sln
./scripts/update-openapi.sh
cd web && npm run generate:api && cd ..
```
Expected: `v1.json`, `web/src/api/generated/v1.d.ts`, `docs/endpoint-index.md` now show `padToNearestMinute`
on `ProgramScheduleResponseModel`, `CreateScheduleRequest`, `UpdateScheduleRequest`.
- [ ] **Step 8: Verify the API-contract check is clean.**
Run: `cd web && npm run check:api && cd ..`
Expected: no diff (generated types match committed `v1.json`).
- [ ] **Step 9: Commit.**
```bash
git -c core.hooksPath=/dev/null add ErsatzTV.Application ErsatzTV.Core ErsatzTV/Controllers \
ErsatzTV/wwwroot/openapi/v1.json web/src/api/generated/v1.d.ts docs/endpoint-index.md
git -c core.hooksPath=/dev/null commit -m "feat(392): expose ProgramSchedule.padToNearestMinute on the REST API"
```
---
### Task 4: Classic builder — synthetic schedule-level pad + offline advance
Depends on Task 2. The core behavior. Self-contained synthetic-pad branch in `AddFiller`; reverse-nav
population in `PlayoutBuilder`; offline-target advance in the 4 schedulers.
**Files:**
- Modify: `ErsatzTV.Core/Scheduling/PlayoutBuilder.cs` (populate `ProgramScheduleItem.ProgramSchedule` reverse nav)
- Modify: `ErsatzTV.Core/Scheduling/PlayoutModeSchedulerBase.cs` (`AddFiller` return type + synthetic-pad branch)
- Modify: `ErsatzTV.Core/Scheduling/PlayoutModeSchedulerFlood.cs` (line ~115-138)
- Modify: `ErsatzTV.Core/Scheduling/PlayoutModeSchedulerDuration.cs` (line ~203-221)
- Modify: `ErsatzTV.Core/Scheduling/PlayoutModeSchedulerMultiple.cs` (line ~139-150)
- Modify: `ErsatzTV.Core/Scheduling/PlayoutModeSchedulerOne.cs` (line ~85-97)
- Test: `ErsatzTV.Core.Tests/Scheduling/Goldens/PlayoutBuildGoldenTests.cs`
- Golden data: `ErsatzTV.Core.Tests/Scheduling/Goldens/Goldens/classic-schedule-clock-padded-offline.txt`,
`...-fallback.txt`
**Interfaces:**
- Consumes: `ProgramSchedule.PadToNearestMinute` (Task 2); the existing
`FallbackFillerForPad(playoutBuilderState, enumerators, scheduleItem, duration, cancellationToken)`
`Option<PlayoutItem>` (unchanged).
- Produces: `AddFiller(...)` returns `(List<PlayoutItem> Items, DateTimeOffset? OfflinePadTarget)` instead of
`List<PlayoutItem>`. `OfflinePadTarget` is non-null ONLY for a synthetic schedule pad that emitted no
filler — callers must advance `CurrentTime` to it when it exceeds the content/filler end.
- [ ] **Step 1: Write failing golden-invariant tests.** In `PlayoutBuildGoldenTests.cs`, add two tests plus
their fixtures, modeled on `Classic_clock_padded` / `SeedPaddedData` (which build a `ProgramScheduleItemOne`,
a `ProgramSchedule`, a `Playout`, and reference data). The new fixtures set
`schedule.PadToNearestMinute = 15` and attach **no** Pad `FillerPreset` to the item.
Offline variant — no `FallbackFiller`, so gaps are offline (no filler items emitted), and each content item
after the first starts on a :15 boundary:
```csharp
// Issue #392: schedule-level clock padding with NO fallback filler → each content item is padded up to
// the next :15 boundary with an OFFLINE gap (no filler items). Proves the synthetic schedule pad advances
// the build clock to the boundary even when nothing fills the gap.
[Test]
public async Task Classic_schedule_clock_padded_offline()
{
(List<PlayoutItem> items, Dictionary<int, string> titles) = await BuildSchedulePaddedPlayout(withFallback: false);
List<PlayoutItem> content = items.Where(i => i.FillerKind == FillerKind.None).OrderBy(i => i.Start).ToList();
content.Count.ShouldBeGreaterThan(2);
// No filler of any kind is emitted (offline gaps only).
items.ShouldNotContain(i => i.FillerKind != FillerKind.None);
foreach (PlayoutItem item in content.Skip(1))
{
(item.Start.Minute % 15).ShouldBe(0, $"content item at {item.Start:HH:mm:ss} is not on a :15 boundary");
item.Start.Second.ShouldBe(0);
}
await CompareGolden("classic-schedule-clock-padded-offline.txt", items, titles);
}
```
Fallback variant — a `FallbackFiller` IS configured, so the gap is filled with `FillerKind.Fallback` content
up to the boundary:
```csharp
// Issue #392: schedule-level clock padding WITH a fallback filler → gaps fill with Fallback content up to
// the :15 boundary (no offline gap).
[Test]
public async Task Classic_schedule_clock_padded_fallback()
{
(List<PlayoutItem> items, Dictionary<int, string> titles) = await BuildSchedulePaddedPlayout(withFallback: true);
items.ShouldContain(i => i.FillerKind == FillerKind.Fallback);
List<PlayoutItem> content = items.Where(i => i.FillerKind == FillerKind.None).OrderBy(i => i.Start).ToList();
foreach (PlayoutItem item in content.Skip(1))
{
(item.Start.Minute % 15).ShouldBe(0, $"content item at {item.Start:HH:mm:ss} is not on a :15 boundary");
item.Start.Second.ShouldBe(0);
}
await CompareGolden("classic-schedule-clock-padded-fallback.txt", items, titles);
}
```
Add the fixture builder `BuildSchedulePaddedPlayout(bool withFallback)` + `SeedSchedulePaddedData` +
`GetSchedulePaddedReferenceData`, copied from `BuildPaddedPlayout`/`SeedPaddedData`/`GetPaddedReferenceData`
(lines 727-927) with these changes:
- The `ProgramScheduleItemOne` has **no** `PostRollFiller`. When `withFallback`, set `FallbackFiller` to a
`FillerPreset { FillerKind = FillerKind.Fallback, FillerMode = FillerMode.None, CollectionType = CollectionType.Collection, Collection = fillerCollection, CollectionId = fillerCollection.Id }` and set the
item's `FallbackFillerId`. When not, leave `FallbackFiller` null.
- The `ProgramSchedule` sets `PadToNearestMinute = 15` (and `Name = "Schedule Padded Test Schedule"`).
- `GetSchedulePaddedReferenceData` mirrors `GetPaddedReferenceData` but the schedule query must
`.Include(ps => ps.Items).ThenInclude(psi => psi.FallbackFiller)` (needed for the fallback variant) in
addition to `Collection`/`MediaItem`. It does NOT need `PostRollFiller`.
(Do NOT create the golden `.txt` files yet — Step 4 generates them.)
- [ ] **Step 2: Run the new tests — expect compile failure / FAIL** (the field and behavior don't exist / the
golden files are missing).
Run: `dotnet test ErsatzTV.Core.Tests --filter "FullyQualifiedName~PlayoutBuildGoldenTests.Classic_schedule_clock_padded"`
Expected: FAIL.
- [ ] **Step 3: Implement the builder change.**
**(a) `PlayoutBuilder.cs` — populate the reverse nav once, centrally.** At the top of the `Build(...)` method
body (after parameters are in scope, before the day loop), set each schedule item's parent so `AddFiller` can
read `scheduleItem.ProgramSchedule.PadToNearestMinute`. Use `referenceData.ProgramSchedule` and each alternate:
```csharp
// #392: the build query does not populate the ProgramScheduleItem.ProgramSchedule reverse nav
// (AsNoTracking). Populate it so schedule-level settings (PadToNearestMinute) are readable in AddFiller.
if (referenceData.ProgramSchedule?.Items is not null)
{
foreach (ProgramScheduleItem item in referenceData.ProgramSchedule.Items)
{
item.ProgramSchedule = referenceData.ProgramSchedule;
}
}
foreach (ProgramScheduleAlternate alternate in referenceData.ProgramScheduleAlternates)
{
if (alternate.ProgramSchedule?.Items is not null)
{
foreach (ProgramScheduleItem item in alternate.ProgramSchedule.Items)
{
item.ProgramSchedule = alternate.ProgramSchedule;
}
}
}
```
Verify the exact accessor names against `PlayoutReferenceData.cs` and `ProgramScheduleAlternate` (the record
exposes `ProgramSchedule`). If an accessor differs, adjust — the intent is "every scheduled item points at its
own parent schedule."
**(b) `PlayoutModeSchedulerBase.cs` — extract the pad boundary-math helper, change `AddFiller` return type, add the synthetic-pad branch.**
First, extract the boundary-math (currently inlined at lines 604-626) into one private static helper that BOTH
the existing item-pad path and the new synthetic branch call (DRY; the existing `Classic_clock_padded` golden
proves this extraction is behavior-preserving). Add this method to the class:
```csharp
// #392: shared clock-boundary ceiling used by both the item-level Pad filler and the schedule-level pad.
// Returns the next `padToNearestMinute` boundary at or after (blockStart + totalDuration); when the block
// already ends exactly on a boundary it advances one full interval (matches the pre-#392 Pad behavior).
private static DateTimeOffset ComputePadBoundary(
DateTimeOffset blockStart,
TimeSpan totalDuration,
int padToNearestMinute)
{
int currentMinute = (blockStart + totalDuration).Minute;
int targetMinute = (currentMinute + padToNearestMinute - 1) / padToNearestMinute * padToNearestMinute;
DateTimeOffset almostTargetTime = blockStart + totalDuration -
TimeSpan.FromMinutes(currentMinute) +
TimeSpan.FromMinutes(targetMinute);
var targetTime = new DateTimeOffset(
almostTargetTime.Year,
almostTargetTime.Month,
almostTargetTime.Day,
almostTargetTime.Hour,
almostTargetTime.Minute,
0,
almostTargetTime.Offset);
// ensure filler works for content less than one interval (and for content already on a boundary)
if (targetTime <= blockStart + totalDuration)
{
targetTime = targetTime.AddMinutes(padToNearestMinute);
}
return targetTime;
}
```
Then, in the EXISTING item-pad block, replace the inlined ceiling math (lines 604-626 — the `int currentMinute
= ...` through the `if (targetTime <= ...) { targetTime = targetTime.AddMinutes(...); }`) with a single call,
leaving the `remainingToFill` line (628) intact:
```csharp
DateTimeOffset targetTime = ComputePadBoundary(
playoutItem.StartOffset, totalDuration, padFiller.PadToNearestMinute.Value);
TimeSpan remainingToFill = targetTime - totalDuration - playoutItem.StartOffset;
```
(This is a pure extraction: the helper body is byte-identical logic to the removed lines. The
`Classic_clock_padded` golden in Step 7 must stay green, proving no behavior change.)
Change the `AddFiller` signature (line 282) return type to a named tuple:
```csharp
internal (List<PlayoutItem> Items, DateTimeOffset? OfflinePadTarget) AddFiller(
PlayoutBuilderState playoutBuilderState,
Dictionary<CollectionKey, IMediaCollectionEnumerator> enumerators,
ProgramScheduleItem scheduleItem,
PlayoutItem playoutItem,
List<MediaChapter> chapters,
PlayoutBuildWarnings warnings,
CancellationToken cancellationToken)
```
Update the two guard-rail early returns (lines 302, 313) from `return [playoutItem];` to
`return ([playoutItem], null);`. Add a local near the top of the method (before the pad section):
```csharp
DateTimeOffset? offlinePadTarget = null;
```
Immediately BEFORE the existing item-pad section (the `foreach (FillerPreset padFiller in Optional(
allFiller.FirstOrDefault(f => f.FillerMode == FillerMode.Pad && f.PadToNearestMinute.HasValue)))` at line 583),
insert the self-contained synthetic branch. It engages ONLY when the item has no own Pad filler and the parent
schedule has a positive divisor — mutually exclusive with the existing block:
```csharp
// #392: schedule-level clock padding. Applies only when the item has no own Pad filler preset (that
// wins) and the parent schedule declares a positive divisor. Reuses the existing pad boundary math +
// FallbackFillerForPad; when no fallback content exists, records an offline target so the caller
// advances the build clock to the boundary (leaving an offline gap).
bool itemHasPadFiller =
allFiller.Any(f => f.FillerMode == FillerMode.Pad && f.PadToNearestMinute.HasValue);
if (!itemHasPadFiller &&
scheduleItem.ProgramSchedule?.PadToNearestMinute is int schedulePadMinutes &&
schedulePadMinutes > 0)
{
TimeSpan totalDuration = result.Aggregate(
TimeSpan.Zero,
(acc, i) => acc + (i.FinishOffset - i.StartOffset));
DateTimeOffset targetTime = ComputePadBoundary(
playoutItem.StartOffset, totalDuration, schedulePadMinutes);
TimeSpan remainingToFill = targetTime - totalDuration - playoutItem.StartOffset;
if (remainingToFill > TimeSpan.Zero)
{
Option<PlayoutItem> maybeFallback = FallbackFillerForPad(
playoutBuilderState,
enumerators,
scheduleItem,
remainingToFill,
cancellationToken);
if (maybeFallback.IsSome)
{
foreach (PlayoutItem fallbackItem in maybeFallback)
{
result.Add(fallbackItem);
}
}
else
{
// No fallback content: leave an offline gap up to the boundary.
offlinePadTarget = targetTime;
}
}
}
```
Leave the existing item-pad `foreach` block (583-753) UNCHANGED. Finally, update the method's tail (line 766)
from `return result;` to:
```csharp
return (result, offlinePadTarget);
```
Confirm the sequential Start/Finish rewrite (lines 755-766) runs unconditionally at the method tail (it does —
it precedes `return`), so an appended fallback item is sequenced correctly.
**(c) Update the 4 scheduler call sites** to destructure the tuple and honor `OfflinePadTarget`.
`PlayoutModeSchedulerFlood.cs` (~115-138) — replace:
```csharp
List<PlayoutItem> maybePlayoutItems = AddFiller(
nextState, collectionEnumerators, scheduleItem, playoutItem, itemChapters, warnings, cancellationToken);
DateTimeOffset itemEndTimeWithFiller = maybePlayoutItems.Max(pi => pi.FinishOffset);
```
with:
```csharp
(List<PlayoutItem> maybePlayoutItems, DateTimeOffset? clockPadTarget) = AddFiller(
nextState, collectionEnumerators, scheduleItem, playoutItem, itemChapters, warnings, cancellationToken);
DateTimeOffset itemEndTimeWithFiller = maybePlayoutItems.Max(pi => pi.FinishOffset);
if (clockPadTarget is { } floodPadTarget && floodPadTarget > itemEndTimeWithFiller)
{
itemEndTimeWithFiller = floodPadTarget;
}
```
(the existing `nextState = nextState with { CurrentTime = itemEndTimeWithFiller, ... }` then picks up the advance).
`PlayoutModeSchedulerDuration.cs` (~203-221) — same destructure; after computing
`itemEndTimeWithFiller = maybePlayoutItems.Max(pi => pi.FinishOffset);` add the same `if (clockPadTarget is { } durPadTarget && durPadTarget > itemEndTimeWithFiller) itemEndTimeWithFiller = durPadTarget;` guard.
`PlayoutModeSchedulerMultiple.cs` (~139-150) — it currently inlines `.Max` into `CurrentTime`. Refactor:
```csharp
(List<PlayoutItem> filled, DateTimeOffset? clockPadTarget) = AddFiller(
nextState, collectionEnumerators, scheduleItem, playoutItem, itemChapters, warnings, cancellationToken);
playoutItems.AddRange(filled);
DateTimeOffset multipleEnd = playoutItems.Max(pi => pi.FinishOffset);
if (clockPadTarget is { } mulPadTarget && mulPadTarget > multipleEnd)
{
multipleEnd = mulPadTarget;
}
nextState = nextState with
{
CurrentTime = multipleEnd,
```
(Adapt to the exact surrounding structure — the key change is that `CurrentTime` becomes
`max(items' FinishOffset, clockPadTarget)`. Preserve everything else in the `with` expression.)
`PlayoutModeSchedulerOne.cs` (~85-97) — same shape:
```csharp
(List<PlayoutItem> playoutItems, DateTimeOffset? clockPadTarget) = AddFiller(
playoutBuilderState, collectionEnumerators, scheduleItem, playoutItem, itemChapters, warnings, cancellationToken);
DateTimeOffset oneEnd = playoutItems.Max(pi => pi.FinishOffset);
if (clockPadTarget is { } onePadTarget && onePadTarget > oneEnd)
{
oneEnd = onePadTarget;
}
PlayoutBuilderState nextState = playoutBuilderState with
{
CurrentTime = oneEnd
};
```
- [ ] **Step 4: Grep for any OTHER `AddFiller(` callers** the four above missed, and update them the same way.
Run: `grep -rn "AddFiller(" ErsatzTV.Core ErsatzTV.Core.Tests`
Expected: only the four schedulers (and possibly a direct test). Update any extra caller to destructure the tuple.
- [ ] **Step 5: Build.**
Run: `dotnet build ErsatzTV.Core`
Expected: build succeeds.
- [ ] **Step 6: Generate the two goldens, review, and verify.**
```bash
ETV_UPDATE_PLAYOUT_GOLDENS=1 dotnet test ErsatzTV.Core.Tests \
--filter "FullyQualifiedName~PlayoutBuildGoldenTests.Classic_schedule_clock_padded"
```
Expected: writes `classic-schedule-clock-padded-offline.txt` and `...-fallback.txt` (tests report
Inconclusive). **Read both golden files** and confirm: offline variant has only `None` content lines with
boundary-aligned starts and NO filler lines; fallback variant interleaves `Fallback` filler lines ending on
:15 boundaries. Then run WITHOUT the env var:
```bash
dotnet test ErsatzTV.Core.Tests --filter "FullyQualifiedName~PlayoutBuildGoldenTests.Classic_schedule_clock_padded"
```
Expected: PASS.
- [ ] **Step 7: Run the FULL golden suite** to prove no regression to the existing `Classic_clock_padded`
(item-level pad path must be byte-identical — the synthetic branch is gated off when an item pad exists).
Run: `dotnet test ErsatzTV.Core.Tests --filter "FullyQualifiedName~PlayoutBuildGoldenTests"`
Expected: PASS (all goldens, including `Classic_clock_padded`, unchanged).
- [ ] **Step 8: Add a precedence test** proving an item's own Pad filler wins when the schedule pad is also set.
Add to `PlayoutBuildGoldenTests.cs` a test that builds the SAME fixture as `Classic_clock_padded` but also sets
`schedule.PadToNearestMinute = 30` on that schedule, and asserts the output is byte-identical to the existing
`classic-clock-padded.txt` golden (the item's :15 PostRoll pad wins; the schedule's :30 is ignored):
```csharp
// #392: an item's own Pad filler takes precedence over the schedule-level pad (no double-pad).
[Test]
public async Task Classic_item_pad_wins_over_schedule_pad()
{
(List<PlayoutItem> items, Dictionary<int, string> titles) = await BuildPaddedPlayout(schedulePadMinutes: 30);
await CompareGolden("classic-clock-padded.txt", items, titles); // identical to the item-pad-only golden
}
```
Give `BuildPaddedPlayout`/`SeedPaddedData` an optional `int? schedulePadMinutes = null` parameter that, when
set, assigns `schedule.PadToNearestMinute`; the default keeps every existing caller unchanged.
Run: `dotnet test ErsatzTV.Core.Tests --filter "FullyQualifiedName~PlayoutBuildGoldenTests.Classic_item_pad_wins_over_schedule_pad"`
Expected: PASS (reuses the existing golden — no new golden file).
- [ ] **Step 9: Commit.**
```bash
git -c core.hooksPath=/dev/null add ErsatzTV.Core/Scheduling ErsatzTV.Core.Tests/Scheduling/Goldens
git -c core.hooksPath=/dev/null commit -m "feat(392): honor ProgramSchedule.PadToNearestMinute in the Classic builder"
```
---
### Task 5: SPA schedule editor control
Depends on Task 3 (generated TS type). Adds the pad control to the schedule form.
**Files:**
- Modify: `web/src/schedules/ScheduleForm.tsx`
**Interfaces:**
- Consumes: `ProgramSchedule.padToNearestMinute` / `CreateScheduleRequest.padToNearestMinute` (Task 3, generated types).
- Produces: nothing downstream.
- [ ] **Step 1: Seed state from the loaded schedule.** In `ScheduleForm.tsx`, next to the existing
`randomStart` / `fixedBehavior` state (lines ~25-26), add (value is a string for the `Select`, `''` = none):
```tsx
const [padToNearest, setPadToNearest] = useState<string>(
schedule?.padToNearestMinute != null ? String(schedule.padToNearestMinute) : ''
);
```
- [ ] **Step 2: Include it in the request body.** In the `body: CreateScheduleRequest = { ... }` object
(lines ~41-48), append:
```tsx
padToNearestMinute: padToNearest === '' ? null : Number(padToNearest)
```
- [ ] **Step 3: Render the control.** After the `Fixed start time behavior` `Select` (lines ~96-102), add an
object-options `Select` with a `(none)` entry (mirrors `FillerPresetsScreen`'s pad control):
```tsx
<Select
label="Pad to clock boundary"
value={padToNearest}
onChange={(e) => setPadToNearest(e.target.value)}
options={[
{ label: '(none)', value: '' },
{ label: '5', value: '5' },
{ label: '10', value: '10' },
{ label: '15', value: '15' },
{ label: '30', value: '30' },
{ label: '60', value: '60' }
]}
/>
```
If the local `Select` component's `options` prop does not accept `{label,value}[]` (the reference used a plain
`string[]`), check the `Select` signature in `web/src/components``FillerPresetsScreen.tsx` passes
`{label,value}[]` to the same component, so this shape is supported; match whichever form the component expects.
- [ ] **Step 4: Type-check + build.**
Run: `cd web && npm run build`
Expected: build succeeds; `padToNearestMinute` is a known field on the request type.
- [ ] **Step 5: Lint.**
Run: `cd web && npm run lint`
Expected: no new errors.
- [ ] **Step 6: Commit.**
```bash
git -c core.hooksPath=/dev/null add web/src/schedules/ScheduleForm.tsx
git -c core.hooksPath=/dev/null commit -m "feat(392): add pad-to-clock-boundary control to the schedule editor"
```
---
### Task 6: Docs — decision record + domain model
Depends on the behavior existing (Tasks 2-5). Docs-in-same-PR rule.
**Files:**
- Modify: `docs/decisions.md` (new active record)
- Modify: `docs/domain-model.md` (ProgramSchedule `PadToNearestMinute` field)
- Generated: `docs/decisions/README.md` (via `scripts/build_decisions_catalog.py`)
**Interfaces:**
- Consumes/Produces: none (docs only).
- [ ] **Step 1: Add the decision record.** Append to `docs/decisions.md` a record following the lifecycle
schema (5-field metadata block), extending — not superseding — `sched.clock-padding-existing`:
```markdown
## 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`
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). 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). 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.
```
(If `sched.clock-padding-existing` should cross-reference this successor, add a one-line "Extended by
`sched.clock-padding-schedule-toggle` (#392)." note to its body — a reference, not a status change, since it
is not superseded.)
- [ ] **Step 2: Update the domain model.** In `docs/domain-model.md`, in the `ProgramSchedule` entity/field
listing, add a row/line for `PadToNearestMinute` — "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 (#392)."
- [ ] **Step 3: Regenerate the decisions catalog + validate.**
```bash
python3 scripts/build_decisions_catalog.py
python3 scripts/decisions_validate.py
```
Expected: `docs/decisions/README.md` now lists `sched.clock-padding-schedule-toggle`; the validator passes
(reproduce any failure locally — a lone `decisions lifecycle` CI red is a known flake, but a local validator
error is real and must be fixed).
- [ ] **Step 4: Commit.**
```bash
git -c core.hooksPath=/dev/null add docs/decisions.md docs/decisions/README.md docs/domain-model.md
git -c core.hooksPath=/dev/null commit -m "docs(392): record per-schedule clock-padding decision + domain-model field"
```
---
## Pre-push gate (run after all tasks, before opening the PR)
- [ ] Full solution build: `dotnet build ErsatzTV.sln` — succeeds.
- [ ] Full test run of touched projects: `dotnet test ErsatzTV.Core.Tests ErsatzTV.Application.Tests` — green.
- [ ] Web: `cd web && npm run build && npm run lint && npm run check:api` — green.
- [ ] BOM check on touched `.cs`: `for f in $(git diff --name-only origin/main | grep '\.cs$'); do xxd -p "$f" | grep -q '^efbbbf' && echo "BOM: $f"; done` — prints nothing.
- [ ] Format gate under bash: `bash -c 'dotnet format whitespace ErsatzTV.sln --folder --verify-no-changes --include $(git diff --name-only origin/main | grep "\.cs$" | tr "\n" " ")'` — clean.
- [ ] Live-E2E (`scripts/e2e-local.sh`): create a schedule with `padToNearestMinute` via the API, read it back, build a Classic playout on a channel using it, and confirm (curl the playout detail / XMLTV) that content items land on boundaries. Curl endpoints, never a browser tab; fresh config dir.
- [ ] Independent cross-model review of the whole diff (migration + builder + API). Re-review the fix commit if the first review finds issues; loop to a clean `Review-verdict: MERGEABLE @ <head-sha>`.
- [ ] Tick the issue's `## Done-when` boxes only against real evidence; post the `Review-verdict` referencing the PR head sha.
## Self-review notes (coverage check)
Spec sections → tasks: data model → T2; builder wiring (synthesize + offline branch + precedence) → T4;
API → T3; SPA control + 60-min → T5 (+ T1); tests (with-fallback / offline / precedence) → T4; docs
(decision record + domain-model) → T6. Fill-source "fallback else offline" → T4 synthetic branch. The one
new behavior (advance-to-boundary offline) → T4 (b)/(c). No spec requirement is unassigned.