43 KiB
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-alignon branchfeat/392-clock-align-schedule(offorigin/main). Never commit in the shared tree. - DB model changes need a migration in both
ErsatzTV.Infrastructure.SqliteandErsatzTV.Infrastructure.MySql, generated viascripts/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; commitErsatzTV/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_GOLDENSin 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$'thenxxd -p <f> | grep -c '^efbbbf'must be 0) and run the format gate underbash -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:
const PAD_OPTIONS = [5, 10, 15, 30].map((minutes) => ({ label: String(minutes), value: String(minutes) }));
to:
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.
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, afterFixedStartTimeBehavior(currently the last scalar before the nav collections), add:
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.csand confirm they match this shape (Sqlitetype: "INTEGER", MySqltype: "int"):
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.
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 forUpdateProgramScheduleHandler/CreateProgramScheduleHandlertests; 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)— notePadToNearestMinutegoes beforeVersionto match existing positional order (Version is last).CreateScheduleRequest/UpdateScheduleRequest/CreateProgramSchedule/UpdateProgramScheduleeach gain a trailingint? PadToNearestMinutepositional member.- TS type
components['schemas']['ProgramScheduleResponseModel'].padToNearestMinute: number | nulland the matching Create/Update request schemas.
-
Step 1: Write a failing handler round-trip test. Find the existing schedule handler tests (grep
FullyQualifiedName~ProgramScheduleunder*.Tests). If aCreateProgramScheduleHandler/UpdateProgramScheduleHandlertest class exists, add a test there; otherwise createErsatzTV.Application.Tests/ProgramSchedules/ProgramSchedulePadToNearestMinuteTests.csmirroring the nearest existing handler test's setup (in-memoryTvContextvia the shared test fixture). The test asserts the field round-trips create→read and update→read:
[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:
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:
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:
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:
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():
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):
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:
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:
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:
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):
programSchedule.FixedStartTimeBehavior != request.FixedStartTimeBehavior ||
programSchedule.PadToNearestMinute != (request.PadToNearestMinute is int upm && upm > 0 ? upm : null);
In the field writes (append):
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 (
nullwhere 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.
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.
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(populateProgramScheduleItem.ProgramSchedulereverse nav) - Modify:
ErsatzTV.Core/Scheduling/PlayoutModeSchedulerBase.cs(AddFillerreturn 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 existingFallbackFillerForPad(playoutBuilderState, enumerators, scheduleItem, duration, cancellationToken)→Option<PlayoutItem>(unchanged). -
Produces:
AddFiller(...)returns(List<PlayoutItem> Items, DateTimeOffset? OfflinePadTarget)instead ofList<PlayoutItem>.OfflinePadTargetis non-null ONLY for a synthetic schedule pad that emitted no filler — callers must advanceCurrentTimeto 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 onClassic_clock_padded/SeedPaddedData(which build aProgramScheduleItemOne, aProgramSchedule, aPlayout, and reference data). The new fixtures setschedule.PadToNearestMinute = 15and attach no PadFillerPresetto 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:
// 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:
// 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
ProgramScheduleItemOnehas noPostRollFiller. WhenwithFallback, setFallbackFillerto aFillerPreset { FillerKind = FillerKind.Fallback, FillerMode = FillerMode.None, CollectionType = CollectionType.Collection, Collection = fillerCollection, CollectionId = fillerCollection.Id }and set the item'sFallbackFillerId. When not, leaveFallbackFillernull. - The
ProgramSchedulesetsPadToNearestMinute = 15(andName = "Schedule Padded Test Schedule"). GetSchedulePaddedReferenceDatamirrorsGetPaddedReferenceDatabut the schedule query must.Include(ps => ps.Items).ThenInclude(psi => psi.FallbackFiller)(needed for the fallback variant) in addition toCollection/MediaItem. It does NOT needPostRollFiller.
(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:
// #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:
// #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:
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:
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):
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:
// #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:
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:
List<PlayoutItem> maybePlayoutItems = AddFiller(
nextState, collectionEnumerators, scheduleItem, playoutItem, itemChapters, warnings, cancellationToken);
DateTimeOffset itemEndTimeWithFiller = maybePlayoutItems.Max(pi => pi.FinishOffset);
with:
(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:
(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:
(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.
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:
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.csa test that builds the SAME fixture asClassic_clock_paddedbut also setsschedule.PadToNearestMinute = 30on that schedule, and asserts the output is byte-identical to the existingclassic-clock-padded.txtgolden (the item's :15 PostRoll pad wins; the schedule's :30 is ignored):
// #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.
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 existingrandomStart/fixedBehaviorstate (lines ~25-26), add (value is a string for theSelect,''= none):
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:
padToNearestMinute: padToNearest === '' ? null : Number(padToNearest)
- Step 3: Render the control. After the
Fixed start time behaviorSelect(lines ~96-102), add an object-optionsSelectwith a(none)entry (mirrorsFillerPresetsScreen's pad control):
<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.
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(ProgramSchedulePadToNearestMinutefield) - Generated:
docs/decisions/README.md(viascripts/build_decisions_catalog.py)
Interfaces:
-
Consumes/Produces: none (docs only).
-
Step 1: Add the decision record. Append to
docs/decisions.mda record following the lifecycle schema (5-field metadata block), extending — not superseding —sched.clock-padding-existing:
## 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 theProgramScheduleentity/field listing, add a row/line forPadToNearestMinute— "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.
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.
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 withpadToNearestMinutevia 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-whenboxes only against real evidence; post theReview-verdictreferencing 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.