feat: schedules editor full mutation depth + channel-editor gaps (#207, #212, #126) #229

Merged
timothy merged 22 commits from feat/207-212-schedules-channel-depth into main 2026-07-11 12:05:53 +02:00
Owner

Closes #207, #212, #126. Pre-reviewed in adversarial-reviewer#19 (slice 1/4, CHANGES REQUIRED) — this PR implements the corrected plan.

Backend

  • Flat ScheduleItemResponseModel (fixes #126): the three /api/schedules/{id}/items endpoints (GET/POST/PUT) now return a non-polymorphic 55-field DTO whose mutation fields are named 1:1 with ScheduleItemRequest (+ display names, playlistGroupId, {id,name} watermark/graphics lists). OpenAPI is now truthful; the hand-widened TS type is deleted. GET→draft→PUT is aligned by construction.
  • Discovery endpoints: GET /api/languages (shared by schedule + channel language pickers; both store the three-letter ISO 639-2 code), GET /api/channels/music-video-credits-templates, GET /api/channels/stream-selectors, and fillerKind on FillerPresetResponseModel + optional ?fillerKind= filter.
  • Round-trip release gate: fixed-point test (GET→map→PUT→GET, semantic equality across regenerated row ids) over a seeded mixed schedule covering all four playout subtypes + rerun + search + fillers + overrides. A second test documents the deliberate EnforceProperties normalization for shuffled schedules (Blazor-parity lossiness, recorded in decisions.md).

SPA — schedules editor (#207)

  • Extracted from App.tsx into web/src/screens/SchedulesScreen.tsx + web/src/schedules/ domain folder.
  • Draft/explicit-Save architecture (adversarial-review finding 1): all item edits/add/copy/remove/reorder are local; one Save issues the single destructive PUT-replace; 422 keeps the draft; dirty guard on schedule-switch, in-app nav (web/src/navigationGuard.ts), and beforeunload.
  • Full per-item inspector parity: all ~43 Blazor controls, with every conditional/reset rule encoded in a pure, 44-case-tested itemRules.ts (playback-order options per collection type, mode-gated fields, forced resets, marathonShuffleGroups doubling as "Shuffle Playlist Items", FillWithGroup eligibility, Fixed/Flood hidden under schedule shuffle).
  • Source pickers for all 10 collection types (debounced search autocompletes, playlist group→playlist with playlistGroupId preselect), five filler slots filtered by kind, watermark/graphics multi-selects, language pickers.
  • Schedule CRUD: create (live TopBar button)/edit/delete with unique-name 422 inline; treatCollectionsAsShows gated on keepMultiPartEpisodesTogether.
  • Deliberate deviation: Copy also copies multi/smart/rerun collection refs — fixes a Blazor CopyItem omission (decisions.md).

SPA — channel editor (#212)

  • External logo URL with Blazor's exact mutual-exclusion contract (non-blank URL wins → logo: {path: url, contentType: ''}; upload clears URL; hydration via logo.isExternalUrl; http(s) validation).
  • Language / credits-template / stream-selector free-text fields → enumerated Selects (keep-current-value-if-missing so loading never silently changes a stored value).
  • Bare-channel create from the channels list via the previously-unused POST /api/channels with Blazor's /channels/add defaults.

Docs (same PR)

api-conventions checklist artifacts (v1.json + endpoint-index + generated types regenerated), spa-conventions (navigation-guard convention), blazor-route-parity verdict rows for the schedules + channel-editor clusters, decisions.md entries (draft/Save architecture, copy deviation, EnforceProperties lossiness, bare-create entry point, external-URL mutual exclusion).

Verification

dotnet test ErsatzTV.Tests 982/982 · web npm test 544/544 (63 files) · lint clean · npm run build clean · npm run check:api in sync. Live E2E + scoped adversarial review to follow on this PR.

🤖 Generated with Claude Code

Closes #207, #212, #126. Pre-reviewed in adversarial-reviewer#19 (slice 1/4, CHANGES REQUIRED) — this PR implements the corrected plan. ## Backend - **Flat `ScheduleItemResponseModel` (fixes #126)**: the three `/api/schedules/{id}/items` endpoints (GET/POST/PUT) now return a non-polymorphic 55-field DTO whose mutation fields are named 1:1 with `ScheduleItemRequest` (+ display names, `playlistGroupId`, `{id,name}` watermark/graphics lists). OpenAPI is now truthful; the hand-widened TS type is deleted. GET→draft→PUT is aligned by construction. - **Discovery endpoints**: `GET /api/languages` (shared by schedule + channel language pickers; both store the three-letter ISO 639-2 code), `GET /api/channels/music-video-credits-templates`, `GET /api/channels/stream-selectors`, and `fillerKind` on `FillerPresetResponseModel` + optional `?fillerKind=` filter. - **Round-trip release gate**: fixed-point test (GET→map→PUT→GET, semantic equality across regenerated row ids) over a seeded mixed schedule covering all four playout subtypes + rerun + search + fillers + overrides. A second test documents the deliberate `EnforceProperties` normalization for shuffled schedules (Blazor-parity lossiness, recorded in decisions.md). ## SPA — schedules editor (#207) - Extracted from App.tsx into `web/src/screens/SchedulesScreen.tsx` + `web/src/schedules/` domain folder. - **Draft/explicit-Save architecture** (adversarial-review finding 1): all item edits/add/copy/remove/reorder are local; one Save issues the single destructive PUT-replace; 422 keeps the draft; dirty guard on schedule-switch, in-app nav (`web/src/navigationGuard.ts`), and `beforeunload`. - **Full per-item inspector parity**: all ~43 Blazor controls, with every conditional/reset rule encoded in a pure, 44-case-tested `itemRules.ts` (playback-order options per collection type, mode-gated fields, forced resets, `marathonShuffleGroups` doubling as "Shuffle Playlist Items", FillWithGroup eligibility, Fixed/Flood hidden under schedule shuffle). - Source pickers for all 10 collection types (debounced search autocompletes, playlist group→playlist with `playlistGroupId` preselect), five filler slots filtered by kind, watermark/graphics multi-selects, language pickers. - **Schedule CRUD**: create (live TopBar button)/edit/delete with unique-name 422 inline; `treatCollectionsAsShows` gated on `keepMultiPartEpisodesTogether`. - Deliberate deviation: **Copy also copies multi/smart/rerun collection refs** — fixes a Blazor `CopyItem` omission (decisions.md). ## SPA — channel editor (#212) - **External logo URL** with Blazor's exact mutual-exclusion contract (non-blank URL wins → `logo: {path: url, contentType: ''}`; upload clears URL; hydration via `logo.isExternalUrl`; http(s) validation). - Language / credits-template / stream-selector free-text fields → enumerated Selects (keep-current-value-if-missing so loading never silently changes a stored value). - **Bare-channel create** from the channels list via the previously-unused `POST /api/channels` with Blazor's `/channels/add` defaults. ## Docs (same PR) api-conventions checklist artifacts (v1.json + endpoint-index + generated types regenerated), spa-conventions (navigation-guard convention), blazor-route-parity verdict rows for the schedules + channel-editor clusters, decisions.md entries (draft/Save architecture, copy deviation, EnforceProperties lossiness, bare-create entry point, external-URL mutual exclusion). ## Verification `dotnet test ErsatzTV.Tests` 982/982 · web `npm test` 544/544 (63 files) · lint clean · `npm run build` clean · `npm run check:api` in sync. Live E2E + scoped adversarial review to follow on this PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
timothy added 13 commits 2026-07-11 00:42:32 +02:00
Task A (#126): new non-polymorphic ScheduleItemResponseModel /
ScheduleItemsResponseModel in Core/Api/Scheduling, plus shared
NamedIdResponseModel. ScheduleItemResponseMapper flattens the
One/Flood/Multiple/Duration VM hierarchy. ScheduleController GET/POST/PUT
items now return the flat DTOs.

Task B: GET /api/languages (LanguagesController + LanguageCodeResponseModel);
GET /api/channels/music-video-credits-templates and
GET /api/channels/stream-selectors; FillerKind added to
FillerPresetResponseModel with optional ?fillerKind= filter on
GET /api/filler-presets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- ScheduleController GET/POST/PUT items now assert flat ScheduleItemResponseModel shapes
- LanguagesControllerTests (route + VM->DTO mapping)
- ChannelController tests for music-video-credits-templates + stream-selectors literal routes
- FillerPreset controller/handler tests for FillerKind field + ?fillerKind= filter

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixed-point round-trip over all schedule-item subtypes (One/Flood/Multiple/
Duration) and field families (fixed-start, preferred audio/subtitle, custom
title, marathon, fillers x5, 2 watermarks, 2 graphics, rerun, search) proving
the flat ScheduleItemResponseModel reconstructs an identical PUT. Second test
documents the deliberate ShuffleScheduleItems normalization (Flood->One,
Fixed->Dynamic, Playlist->PlaybackOrder None).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Regenerated ErsatzTV/wwwroot/openapi/v1.json, docs/endpoint-index.md, and
  web/src/api/generated/v1.d.ts for the flat schedule-item DTO + discovery endpoints
- docs/decisions.md: flat ScheduleItemResponseModel + NamedIdResponseModel rationale
- docs/api-conventions.md: shared {id,name} embed, polymorphic-VM flattening, and
  optional enum query-filter conventions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The flat ScheduleItemResponseModel removed the polymorphic ProgramScheduleItemViewModel
from the API surface, which was the only path exposing the Application VMs
(WatermarkViewModel, PlaylistViewModel, FillerPresetViewModel, collection VMs, etc.).
WatermarkViewModel is no longer in v1.json, so its serializer-contract guard case is moot.
ChannelViewModel still covers the schema transformer's VM path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Blazor-parity gating, option lists and forced-reset transforms for the
schedules editor, exhaustively unit-tested (44 cases). No React/fetch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New web/src/api/languages.ts module (getLanguages) plus channels.ts additions
(getMusicVideoCreditsTemplates, getChannelStreamSelectors, createChannel) for the
channel-editor gaps in #212. Each has URL-building tests.
Adds a "New blank channel" action next to "Add Channel" on ChannelsScreen
(web/src/App.tsx) that POSTs CreateChannelRequest with Blazor's add-mode
defaults (ChannelEditor.razor's else branch) via the new createChannel client,
then navigates to the channel's editor. Distinct from "Add Channel", which
remains the library-to-lineup ChannelBuilder flow and is untouched.
Channel editor cluster moves from GAPS to PARITY-OK in the verdict table now
that external logo URL, bare-create, and enumerated pickers have landed.
Records the bare-create-on-list-screen and external-logo-wins decisions in
decisions.md.
Extract ScheduleScreen from App.tsx into screens/SchedulesScreen.tsx +
schedules/ domain folder (itemRules, pickers, inspector, ScheduleForm).
Draft model with explicit Save (single destructive PUT), Discard, dirty
guard (navigationGuard + beforeunload), schedule CRUD, and all Blazor
item fields/gates/resets. Rewrite api/schedules.ts to the flat DTO +
CRUD + languages/filler-by-kind pickers. Live TopBar Add Schedule via a
window CustomEvent. Screen + nav-guard tests; App.test updated for the
extracted screen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- shell.css: picker/multiselect/fieldset/toolbar styles for the new editor
- spa-conventions.md §8: navigation-guard convention
- decisions.md: draft/explicit-Save, Copy-includes-refs, shuffled-GET lossiness
- blazor-route-parity.md: schedules cluster -> PARITY-OK (#207 DONE)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
merge: feat/212-spa-channel into feat/207-212-schedules-channel-depth
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 11m0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
0120d3ce5b
# Conflicts:
#	docs/decisions.md
timothy added 3 commits 2026-07-11 01:01:13 +02:00
After a collection-type change, snap playbackOrder to the new type's first
offered order when the current one is no longer valid (e.g. Collection+Marathon
-> TelevisionShow left a stale 'Marathon' while the native <select> displayed
'Chronological' and Marathon fields stayed visible), and reconcile multipleMode
into the valid set for the new (type, order) state (e.g. CollectionSize
surviving a switch into Playlist). Replaces the narrow MultiCollection/Playlist
special-cases with a general invariant. Adds repro + a from/to-pair invariant
test; updates the Playlist multipleMode test to the corrected behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
F1: switching schedules synchronously clears items/baseline/selection/dirty
before setActiveId (applySwitch) and gates every mutation surface on a
successful items load for the CURRENT activeId (itemsLoaded) — a failed items
GET for schedule B can no longer leave B's header over A's dirty draft and PUT
A's lineup into B.
F3: key={selectedItem._key} on ScheduleItemInspector so per-item child state
(PlaylistPicker groupId, SearchPicker query) resets on selection change.
F4: mutate() no-ops and all edit surfaces disable while saving, so edits during
an in-flight Save can't be silently discarded by the Save .then.
F5: create-schedule auto-switch routes through guardedSwitch so a dirty draft
gets the same discard confirm.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
test(schedules): pin FixedStartTimeBehavior + FixDiscardToFillAttempts in round-trip (F7)
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Failing after 2s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 12s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m1s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
5918e947d9
Seed a non-null FixedStartTimeBehavior (Flexible) on the Fixed-start item and
assert it survives envelope A; assert the Marathon-order Duration item's
DiscardToFillAttempts is zeroed by the deliberate FixDiscardToFillAttempts
server normalization (Random/Shuffle keep the value, all else -> 0).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
timothy added 1 commit 2026-07-11 01:10:47 +02:00
Merge main: CI MySQL host-port unpin (collision fix, #236)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 6m56s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 11s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 7m50s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
ef615f8c69
timothy added 3 commits 2026-07-11 01:27:42 +02:00
Bug 1 (500 on watermark/graphics save): Replace/Add handlers projected the
freshly-built entity graph, whose ProgramScheduleItemWatermark / -GraphicsElement
join rows carry only foreign-key ids — the Watermark/GraphicsElement navs are null,
and Mapper.ProjectToViewModel dereferences them unguarded, throwing an NRE that the
controller surfaced as a 500 on PUT/POST. Both handlers now reload the persisted
item(s) through the read-side include chain before projecting. Extracted that chain
into ProgramScheduleItemQueryExtensions.IncludeScheduleItemDetails() so GET, Replace
and Add share one source of truth.

Masking: PersistItems returned a lazy LanguageExt Map, and the existing round-trip
test only checked .IsRight — never enumerating it, so the deferred NRE never fired.
The new ScheduleItemWriteProjectionTests force enumeration (as the controller's
.ToList()/serialization does) and seed watermark/graphics via a separate context so
the handler's fresh factory context has nothing pre-tracked.

Bug 2 (server side): GetProgramScheduleItemsHandler now .OrderBy(i => i.Index) —
it previously returned id order, which is not index order.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bug 2 (client side): array position becomes the persisted index on the next
PUT-replace, so the schedules editor must ingest strictly by the server-provided
`index` rather than trusting response row order — otherwise a reload + re-save could
silently reshuffle the lineup. Applied at both ingest points (GET load and the
replace response). Pinned by a shuffled-response-order test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Merge branch 'feat/207-212-schedules-channel-depth' of ssh://192.168.1.95:22/timothy/ersatztv into feat/207-212-schedules-channel-depth
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 12s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m5s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
7f248fd553
timothy added 1 commit 2026-07-11 11:45:34 +02:00
merge: origin/main (review-gates batch #222/#239) into feat/207-212
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Failing after 3m12s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m46s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
b82ae0c906
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
timothy added 1 commit 2026-07-11 11:51:14 +02:00
test(spa): raise timeout on trash see-all paging test (CI VM flake, run 686)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 11s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 10m17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
3c7ed5c2f8
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
timothy merged commit 6bbf669403 into main 2026-07-11 12:05:53 +02:00
timothy deleted branch feat/207-212-schedules-channel-depth 2026-07-11 12:05:54 +02:00
Sign in to join this conversation.