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).
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).
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.
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.
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)
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>
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>
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>
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>
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>
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>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes #207, #212, #126. Pre-reviewed in adversarial-reviewer#19 (slice 1/4, CHANGES REQUIRED) — this PR implements the corrected plan.
Backend
ScheduleItemResponseModel(fixes #126): the three/api/schedules/{id}/itemsendpoints (GET/POST/PUT) now return a non-polymorphic 55-field DTO whose mutation fields are named 1:1 withScheduleItemRequest(+ 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.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, andfillerKindonFillerPresetResponseModel+ optional?fillerKind=filter.EnforcePropertiesnormalization for shuffled schedules (Blazor-parity lossiness, recorded in decisions.md).SPA — schedules editor (#207)
web/src/screens/SchedulesScreen.tsx+web/src/schedules/domain folder.web/src/navigationGuard.ts), andbeforeunload.itemRules.ts(playback-order options per collection type, mode-gated fields, forced resets,marathonShuffleGroupsdoubling as "Shuffle Playlist Items", FillWithGroup eligibility, Fixed/Flood hidden under schedule shuffle).playlistGroupIdpreselect), five filler slots filtered by kind, watermark/graphics multi-selects, language pickers.treatCollectionsAsShowsgated onkeepMultiPartEpisodesTogether.CopyItemomission (decisions.md).SPA — channel editor (#212)
logo: {path: url, contentType: ''}; upload clears URL; hydration vialogo.isExternalUrl; http(s) validation).POST /api/channelswith Blazor's/channels/adddefaults.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.Tests982/982 · webnpm test544/544 (63 files) · lint clean ·npm run buildclean ·npm run check:apiin sync. Live E2E + scoped adversarial review to follow on this PR.🤖 Generated with Claude Code
- 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>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>