914 lines
43 KiB
Markdown
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.
|