---
key: sched.seasonal-scheduling-existing
title: '2026-07-17 — Seasonal / date-conditional scheduling already exists (alternate schedules / playout templates); #73 closed as implemented'
status: active
since: '2026-07-17'
supersedes: none
superseded-by: none
rule: 'Seasonal/date-conditional scheduling already ships first-class via `IAlternateScheduleItem` (Classic `ProgramScheduleAlternate`, Block `PlayoutTemplate`) evaluated by `AlternateScheduleSelector.GetScheduleForDate` (first match in `Index` order, catch-all last); #73 is closed as already-implemented with a docs-only "seasonal/holiday" recipe added, not new code.'
signals: 'alternate schedules, playout templates, date-conditional, catch-all-last evaluation · paths: `AlternateScheduleSelector.cs`, `PlayoutBuilder`, `EffectiveBlock`, `DecoSelector`, `PlayoutScheduleEditors.tsx` · issues: #73, #70'
mechanics: '`AlternateScheduleSelectorTests`, `playoutTemplateCalendar.test.ts`; `channels.md` "Recipe: seasonal / holiday programming"'
---

#73 asked for "seasonal / date-conditional channels & schedule rules" — a PseudoTV holiday-channel analogue
— on the stated premise that *"ErsatzTV has no native date-conditional scheduling today; you'd hand-build it
seasonally."* **That premise is false.** The feature is first-class, shipped, SPA-reachable, unit-tested and
already documented (upstream machinery carried into the fork). #73 is closed as already-implemented; the only
deliverable was the docs recipe below.

- **The predicate**: `IAlternateScheduleItem` (`DaysOfWeek`, `DaysOfMonth`, `MonthsOfYear`,
  `LimitToDateRange`, `StartMonth/StartDay/StartYear?`, `EndMonth/EndDay/EndYear?`), implemented by
  `ProgramScheduleAlternate` (Classic — picks the `ProgramSchedule` for a date) and
  `Scheduling/PlayoutTemplate` (Block — picks the `Template` + optional `DecoTemplate` for a date).
- **The evaluator**: `AlternateScheduleSelector.GetScheduleForDate` — first match in `Index` order,
  broadest/unconditional row last = catch-all. Wired into `PlayoutBuilder` (:572, :956), `EffectiveBlock`,
  `BlockPlayoutFillerBuilder`, and `DecoSelector`. Wrap-around (Nov→Feb) and invalid/leap dates (Feb 31
  clamped) are already handled; `AlternateScheduleSelectorTests` + `playoutTemplateCalendar.test.ts` pin it.
- **SPA**: `PlayoutScheduleEditors.tsx` ships the "Limit to date range" control for both editors, with a
  per-date calendar preview (`playoutTemplateCalendar.ts`).
- **Nullable years are the seasonal switch** (`AlternateScheduleSelector.cs:32-40`): years default to the
  *queried date's* year, so blank years = repeats every year. The override branch requires **both**
  `StartYear` and `EndYear` (only one → silently still yearly), and explicit years force `reverse = false`,
  disabling wrap-around. This was the single most load-bearing undocumented behaviour and is now in the docs.
- **Correcting the issue's Deco hypothesis**: the issue proposed "a Deco-like modifier". Decos carry **no**
  dates — `DecoTemplateItem.StartTime/EndTime` are time-of-day. The date dimension lives *exclusively* on the
  `PlayoutTemplate` / `ProgramScheduleAlternate` join row. Composition is **PlayoutTemplate (date range) →
  Template (time-of-day grid) → Block (content)**.

**Why closed rather than #77-style "re-scope to verify + document":** #77 had two real gaps (undocumented AND
untested). #73 has neither — tests and docs both already existed on main — so even the #77 re-scope target was
already met. Under the close-don't-park convention (same date), holding #73 open for a hypothetical future
build is precisely the parking that convention forbids.

**Rejected asks, and why:**

- **Per-schedule-item date predicate** (the issue's literal ask; shipped design swaps the *whole* schedule per
  date instead). Rejected as-designed: functionally equivalent for every seasonal use case — clone a schedule,
  date-gate it, order it above the catch-all — at a cost of "clone a schedule". Moving the predicate onto
  `ProgramScheduleItem` rows would touch the classic playout builder's core loop for **zero new expressible
  behaviour**, carrying regression risk in the exact engine #163/#380 just built golden nets around.
- **"Prioritize collection X during a date range"** — routed to **#70**, not built here. The ask decomposes as
  *date scoping* (shipped) × *soft prioritization* (the weighting primitive #70 is actively building across the
  `PlaybackOrder` switch sites). Once #70 lands, this is pure composition with no date-aware code in the
  weighting path. Building a second weighting primitive under #73 while #70 was mid-flight would have been a
  direct collision in the same milestone.
- **Date-gating a playout's default `Playout.DecoId`** — rejected: the gated path already exists
  (`PlayoutTemplate.DecoTemplateId`), and a date-conditional *fallback* is a contradiction in terms.
- **UI rename** of "Alternate Schedules" / "Playout Templates" to something that reads as "seasonal" — rejected:
  inherited upstream vocabulary baked into routes, API paths and parity docs; churn for marginal gain. The
  discoverability gap is addressed with docs instead.

**Known non-goal:** the issue's "optionally, auto-surface a seasonal channel in the lineup during its window"
is genuinely absent — M3U/XMLTV lineup visibility is not date-conditional. It was speculative, has no concrete
use case (the practical equivalent: air a date-gated schedule year-round, or toggle `ShowInEpg`), and per
close-don't-park no placeholder issue was filed. Refile if a real need appears.

**The real gap was discoverability, and it was a docs gap.** The mechanism was documented as a *mechanism*
("Alternate Schedules… useful for seasonal programming") but never as a *task* — a user thinking "holiday
channel" has no reason to look under "Alternate Schedules", and on finding that line gets no worked steps.
Fixed with a task-shaped **"Recipe: seasonal / holiday programming"** section in `channels.md` (both engines,
plus the gotchas above) and a `domain-model.md` glossary row. No production code changed, so no live-E2E
(same reasoning as #77).
