feat(web+api): playout alternate schedules + templates editors, default-deco read-side (#144 S6, #162) #179

Merged
timothy merged 6 commits from feat/144-s6-playout-editors into main 2026-07-07 21:03:11 +02:00
Owner

Slice S6 — the FINAL slice of the #162 scheduling REST plan (parity issue #144).

API (PlayoutController)

  • GET/PUT /api/playouts/{id}/alternate-schedules (Classic playouts only — 422 otherwise): index assigned from array order, last row = catch-all default whose scheduleId is written to playout.ProgramScheduleId (semantics verified against AlternateScheduleSelector first-match-wins + the handler's Max(Index) default-write; a PUT of exactly what GET returned round-trips with zero churn incl. the synthesized catch-all). Non-empty 422 (guards the handler's Max() throw the upstream TODO warned about); scheduleId existence 422 without dispatch.
  • GET/PUT /api/playouts/{id}/templates (Block only): templateId/decoTemplateId existence 422s; empty list allowed (Blazor parity — verified upstream has no non-empty guard).
  • Date-range hardening (review find): out-of-range month/day with limitToDateRange:true → 422 on both PUTs (previously crashed AlternateScheduleSelector — its own catch re-throws for bad months).
  • PlayoutResponseModel gained nullable decoId/decoName (the S3-deferred default-deco read-side); all 6 PlayoutNameViewModel construction sites updated.
  • Known wire quirk documented: DayOfWeek serializes as day-name strings (StringEnumConverter) though the OpenAPI schema says integer — TS client overrides on both request/response paths (E2E-confirmed; #126 precedent).

SPA

  • Playouts route gains sub-paths: /app/playouts/{id}/alternate-schedules + /app/playouts/{id}/templates — reorderable priority table (last alt-schedule row badged "Default" with recurrence controls disabled + explanatory note, since the server coerces it to match-all) + detail panel (schedule or template+decoTemplate pickers, days-of-week chips with Weekdays/Weekends helpers, days-of-month/months grids, date-range gating with nullable year).
  • Entry buttons kind-gated on the playout card (Classic → alternate schedules; Block → templates + a default-deco select wired to PUT /{id}/deco).
  • E2E-found navigation bug fixed: sub-route clicks now actually render (new PlayoutsRouteScreen wrapper owning pathname state + popstate, the established BlocksScreen pattern — routeFromLocation returns the same route object for sub-paths so App-level state never re-rendered). Regression tests assert client-side screen swap without reload.

Verification

  • ErsatzTV.Tests 786 green; Core.Tests 493 green; web 301 green; lint/tsc/build clean.
  • Live E2E: full API matrix on real Classic + Block playouts (kind-gating both directions, default-schedule flip on reorder confirmed via scheduleName, existence/non-empty 422s, deco read-side, DayOfWeek wire evidence) + browser flows (kind-gated buttons, both editors add/reorder/save/reload, default-deco set+clear, date-range gating, Default badge follows reorder).
  • Adversarial review: no must-fix (priority/round-trip semantics verified end-to-end); date-range should-fix applied; nits → #172.

Completes the #162 slice plan (S1–S6 all merged/pending-merge). Part of #144 — close-out comment follows after merge. Do not auto-close.

🤖 Generated with Claude Code

Slice S6 — the FINAL slice of the #162 scheduling REST plan (parity issue #144). **API** (PlayoutController) - `GET/PUT /api/playouts/{id}/alternate-schedules` (Classic playouts only — 422 otherwise): index assigned from array order, **last row = catch-all default** whose scheduleId is written to `playout.ProgramScheduleId` (semantics verified against `AlternateScheduleSelector` first-match-wins + the handler's Max(Index) default-write; a PUT of exactly what GET returned round-trips with zero churn incl. the synthesized catch-all). Non-empty 422 (guards the handler's `Max()` throw the upstream TODO warned about); scheduleId existence 422 without dispatch. - `GET/PUT /api/playouts/{id}/templates` (Block only): templateId/decoTemplateId existence 422s; empty list allowed (Blazor parity — verified upstream has no non-empty guard). - Date-range hardening (review find): out-of-range month/day with `limitToDateRange:true` → 422 on both PUTs (previously crashed `AlternateScheduleSelector` — its own catch re-throws for bad months). - `PlayoutResponseModel` gained nullable `decoId`/`decoName` (the S3-deferred default-deco read-side); all 6 `PlayoutNameViewModel` construction sites updated. - Known wire quirk documented: `DayOfWeek` serializes as day-name strings (StringEnumConverter) though the OpenAPI schema says integer — TS client overrides on both request/response paths (E2E-confirmed; #126 precedent). **SPA** - Playouts route gains sub-paths: `/app/playouts/{id}/alternate-schedules` + `/app/playouts/{id}/templates` — reorderable priority table (last alt-schedule row badged "Default" with recurrence controls disabled + explanatory note, since the server coerces it to match-all) + detail panel (schedule or template+decoTemplate pickers, days-of-week chips with Weekdays/Weekends helpers, days-of-month/months grids, date-range gating with nullable year). - Entry buttons kind-gated on the playout card (Classic → alternate schedules; Block → templates + a default-deco select wired to `PUT /{id}/deco`). - E2E-found navigation bug fixed: sub-route clicks now actually render (new `PlayoutsRouteScreen` wrapper owning pathname state + popstate, the established BlocksScreen pattern — `routeFromLocation` returns the same route object for sub-paths so App-level state never re-rendered). Regression tests assert client-side screen swap without reload. **Verification** - ErsatzTV.Tests 786 green; Core.Tests 493 green; web 301 green; lint/tsc/build clean. - Live E2E: full API matrix on real Classic + Block playouts (kind-gating both directions, default-schedule flip on reorder confirmed via scheduleName, existence/non-empty 422s, deco read-side, DayOfWeek wire evidence) + browser flows (kind-gated buttons, both editors add/reorder/save/reload, default-deco set+clear, date-range gating, Default badge follows reorder). - Adversarial review: no must-fix (priority/round-trip semantics verified end-to-end); date-range should-fix applied; nits → #172. Completes the #162 slice plan (S1–S6 all merged/pending-merge). Part of #144 — close-out comment follows after merge. Do not auto-close. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
timothy added 6 commits 2026-07-07 20:57:45 +02:00
Adds per-playout REST for classic-playout alternate schedules and
block-playout templates, plus the default-deco read-side deferred from S3:

- GET/PUT /api/playouts/{id}/alternate-schedules (Classic only; 422 otherwise)
- GET/PUT /api/playouts/{id}/templates (Block only; 422 otherwise)
- PlayoutResponseModel gains decoId/decoName (GetPlayoutById includes Deco)

PUT assigns Index from array order (top = highest priority, last = catch-all
default), mirroring the Blazor editors. Alternate-schedule PUT requires a
non-empty list and existing ProgramScheduleIds; template PUT requires existing
TemplateIds and any supplied DecoTemplateId. Regenerates the OpenAPI spec.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds two sub-path editors under the Playouts screen:
- /app/playouts/{id}/alternate-schedules (Classic playouts)
- /app/playouts/{id}/templates (Block playouts)

Each has a reorderable priority table (up/down/delete) plus a selected-row
detail panel: schedule or template+deco-template pickers, a limit-to-date-range
toggle gating month/day/year selects, and day-of-week / day-of-month / month
multi-select chips with Weekdays/Weekends helpers. Entry points on the playout
card are kind-gated. The Block playout card also gets a default-deco select
wired to PUT /api/playouts/{id}/deco, using the new decoName read field.

DayOfWeek is overridden to day-name strings in the API client (the wire format
is Newtonsoft StringEnumConverter, though the OpenAPI schema types it as number).
Deviations from Blazor: chip multi-select instead of MudSelect, flat grouped
template pickers instead of a group->item cascade, a shorter year range, and the
template calendar preview is omitted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
routeFromLocation() returns the same ScreenRoute object reference for
/app/playouts and any /app/playouts/{id}/* sub-path, so navigateToPath's
pushState + synthetic popstate caused App's setActiveRoute(routeFromLocation())
to bail via Object.is and never re-invoke ScreenContent. Only a hard reload
picked up the new sub-route.

Mirror the existing BlocksScreen/TemplatesScreen/DecosScreen pattern: add a
PlayoutsRouteScreen wrapper that owns its own pathname state and popstate
listener, so client-side navigation into and out of the alternate-schedules
and templates editors re-renders correctly.
The last row in the alternate-schedules editor is the catch-all default:
ReplacePlayoutAlternateScheduleItemsHandler excludes the highest-index item
from the saved rows entirely and only keeps its ProgramScheduleId as the
playout's default schedule, so any recurrence edits made to that row (days of
week/month, months, date range) were silently discarded on save.

Disable the recurrence controls (chips, selects, limit-to-date-range
checkbox) whenever the selected row is the default, and show an inline note
explaining why. The Templates editor is unaffected — its handler saves every
row's recurrence fields as-is, so RecurrenceFields there keeps full editing.
fix(api): validate date-range month/day bounds on playout PUT actions
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m37s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m59s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
d7617b7456
AlternateScheduleSelector.GetScheduleForDate constructs
new DateTime(year, StartMonth, StartDay) and only recovers from an
out-of-range *day* (rolling to the 1st of the next month); an out-of-range
*month* (e.g. 0 or 13) throws again from inside that recovery path and is
never caught, crashing playout building.

Validate StartMonth/EndMonth (1..12) and StartDay/EndDay (1..31) on both
PUT /api/playouts/{id}/alternate-schedules and PUT /api/playouts/{id}/templates
whenever an item sets LimitToDateRange, returning 422 before dispatching to
the mediator. Both actions already documented 422 in their ProducesResponseType
metadata.
timothy merged commit c480fbe8d5 into main 2026-07-07 21:03:11 +02:00
timothy deleted branch feat/144-s6-playout-editors 2026-07-07 21:03:11 +02:00
Sign in to join this conversation.