Files
ersatztv/docs/decisions/records/sched/seasonal-scheduling-existing.md
T
timothy fba5233caf
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / decisions lifecycle (pull_request) Failing after 23s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m17s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m29s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m5s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 16m5s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 17m6s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
feat(610): split the decision corpus into one YAML-frontmatter file per record
168 records -> docs/decisions/records/<area>/<topic>.md (163 active, 23 dirs) and
docs/decisions/archive/<area>/<topic>.md (5 archived). The filename IS the key,
so one-active-record-per-key becomes a filesystem property rather than a
validator check, and supersession becomes a `git mv`.

WHY: the monolith was a concurrency problem before an aesthetic one. A
3,900-line append target made parallel sessions collide -- PR #605 and PR #614
both hit append-vs-append conflicts during routine rebases, and hand-resolving
those inside the corpus is exactly the operation the rationale-rewrite guard
exists to police.

HOW IT IS VERIFIED: a ~170-file diff cannot be meaningfully read, so correctness
does not rest on reading it. The parser was taught BOTH formats first, so the
body-diff guard parses the old form at the merge-base and the new form at head --
the migration validates itself, no bypass. The proof is a field-level equivalence
harness: 168 records before and after, zero lost, zero gained, zero field
mismatches, zero rationale bodies differing. Reviewers should scrutinise the
harness; it is the actual evidence.

What measuring caught that reading would not have:

- ~500 lines sit OUTSIDE any record -- decisions.md's lifecycle schema and each
  topic file's preamble, mostly the only copy. Source files are kept and
  stripped, never deleted. They also cannot be filed per-area: topic files hold
  several areas and 4 of 23 areas span several files.
- Archive discovery was a non-recursive glob; after the split it found ZERO
  archived records, surfacing as four bogus "supersedes points to unknown key"
  errors rather than an obvious failure.
- ~32 live docs point into the corpus BY DATE, which the split dangles. Each
  stripped file now ends with a generated "Records formerly in this file" index,
  which also rescues the identical breadcrumbs in old issue comments.
- decisions.md's "In this file:" list was 97 same-file anchor bullets that the
  split makes WRONG, not merely stale. Dropped; the generated index replaces
  them with links that resolve.

The equivalence harness now runs against a checked-in FIXTURE, not the live
corpus. The earlier version migrated the real tree, which made it a one-shot:
the moment the migration landed there was nothing left to move and the tests
failed for reasons unrelated to the code. A fixture keeps them testing the
SCRIPT rather than the repo's current state.

Keys preserved verbatim, warts included: `sched` (12) and `scheduling` (1) remain
two directories for one concept. Renaming a key is not a move -- it changes
identity, breaks the equivalence proof, and invalidates MemPalace's per-key
drawers. Taxonomy normalisation is separate work.

refs #610
2026-07-25 19:45:09 +02:00

6.0 KiB
Raw Blame History

key, title, status, since, supersedes, superseded-by, rule, signals, mechanics
key title status since supersedes superseded-by rule signals mechanics
sched.seasonal-scheduling-existing 2026-07-17 — Seasonal / date-conditional scheduling already exists (alternate schedules / playout templates); #73 closed as implemented active 2026-07-17 none none 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. alternate schedules, playout templates, date-conditional, catch-all-last evaluation · paths: `AlternateScheduleSelector.cs`, `PlayoutBuilder`, `EffectiveBlock`, `DecoSelector`, `PlayoutScheduleEditors.tsx` · issues: #73, #70 `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).