Implements the ChicoryTV Channels screen from design-system/templates/chicorytv-admin/Channels.jsx against the existing channel APIs. The screen replaces the placeholder route with a dense grouped table, live/on-air treatment, disabled/hidden markers, bulk selection/action bar, row delete, and loading/error/empty states using existing SPA primitives.
Out of scope; Add/Edit route to builder placeholder only.
Troubleshoot
Not in this screen's API contract
Out of scope; disabled affordance.
Files Changed
web/src/api/channels.ts
web/src/App.tsx
web/src/App.test.tsx
web/src/shell.css
Verification
Web:
cd web && npm ci
npm run lint
npm run typecheck
npm test -- --run -> 37 passed
npm run build
Backend:
TZ=UTC dotnet build ErsatzTV.sln -> passed with existing NuGet advisory warnings and unrelated nullable warnings in artwork upload tests
TZ=UTC dotnet test ErsatzTV.Tests/ErsatzTV.Tests.csproj --no-build -v normal -> 368/368 passed
TZ=UTC dotnet test ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj --no-build -v normal -> 493 passed, 1 skipped
Deferred / Uncertain
Preview remains disabled until the SPA has the equivalent of legacy preview gating, JWT access-token support, and stream dialog behavior.
Channel Builder create/edit forms remain #89 scope.
Troubleshooting remains outside this issue's frontend contract.
Local origin/main resolved to 39d76080 after fetch, not the 4ffd777b SHA mentioned in the prompt; this branch was created from the current origin/main as required by the worktree command.
## Summary
Implements the ChicoryTV Channels screen from `design-system/templates/chicorytv-admin/Channels.jsx` against the existing channel APIs. The screen replaces the placeholder route with a dense grouped table, live/on-air treatment, disabled/hidden markers, bulk selection/action bar, row delete, and loading/error/empty states using existing SPA primitives.
Closes #84
## Endpoint / Widget Mapping
| Screen surface | API / DTO mapping | Scope decision |
| --- | --- | --- |
| Channel table rows | `GET /api/channels` -> `ChannelResponseModel[]` | In scope; sorted by numeric `sortNumber`, grouped by `group`. |
| Group headers/counts | `ChannelResponseModel.group` | In scope. |
| Disabled `(D)` marker/dimming | `ChannelResponseModel.isEnabled` | In scope. |
| Hidden `(H)` marker | `ChannelResponseModel.showInEpg` | In scope. |
| Live/on-air filter and row rail | `GET /api/channels/state` -> `ChannelStateResponseModel.onAir` | In scope; rendered as backend reports it. |
| Now-playing lane/progress | nullable `ChannelStateResponseModel.nowPlaying` | In scope; null renders `Idle` / `Off air`, no fabricated data. |
| Streaming badge | `ChannelResponseModel.streamingMode` backend display strings from `Mapper.cs` | In scope; branch strings copied from backend mapper. |
| Bulk renumber | `POST /api/channels/bulk/renumber` | In scope; refetches after mutate. |
| Bulk move group | `POST /api/channels/bulk/group` | In scope; refetches after mutate and shows ProblemDetails errors. |
| Bulk delete | `POST /api/channels/bulk/delete` | In scope; confirmation, refetch, ProblemDetails errors. |
| Row delete | `DELETE /api/channels/{id}` | In scope; confirmation + refetch. |
| Channel-state polling | `GET /api/channels/state` only | In scope; 30s interval, active unmount guard, keeps table visible on poll failure. |
| Preview | Legacy preview needs extra support/JWT/profile data not in list DTO | Deferred; affordance is present but disabled instead of opening an incomplete raw URL. |
| Add/Edit full forms | Full Channel Builder is #89 | Out of scope; Add/Edit route to builder placeholder only. |
| Troubleshoot | Not in this screen's API contract | Out of scope; disabled affordance. |
## Files Changed
- `web/src/api/channels.ts`
- `web/src/App.tsx`
- `web/src/App.test.tsx`
- `web/src/shell.css`
## Verification
Web:
- `cd web && npm ci`
- `npm run lint`
- `npm run typecheck`
- `npm test -- --run` -> 37 passed
- `npm run build`
Backend:
- `TZ=UTC dotnet build ErsatzTV.sln` -> passed with existing NuGet advisory warnings and unrelated nullable warnings in artwork upload tests
- `TZ=UTC dotnet test ErsatzTV.Tests/ErsatzTV.Tests.csproj --no-build -v normal` -> 368/368 passed
- `TZ=UTC dotnet test ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj --no-build -v normal` -> 493 passed, 1 skipped
## Deferred / Uncertain
- Preview remains disabled until the SPA has the equivalent of legacy preview gating, JWT access-token support, and stream dialog behavior.
- Channel Builder create/edit forms remain #89 scope.
- Troubleshooting remains outside this issue's frontend contract.
- Local `origin/main` resolved to `39d76080` after fetch, not the `4ffd777b` SHA mentioned in the prompt; this branch was created from the current `origin/main` as required by the worktree command.
Verdict: APPROVE with findings — no blockers. One SUBSTANTIAL correctness finding + one SUBSTANTIAL test gap should be resolved (before merge or as an immediate follow-up, user decides); the rest are nits.
Lenses: (1) deep React correctness, (2) API contract + test quality, (3) design-system adherence + scope.
Contract verification — all clean
Every path/method/body in channels.ts matches ChannelController.cs and BulkChannelRequests.cs field-for-field; status codes (204 success, 404/422 ProblemDetails) correctly handled by client.ts → messageFromError.
Fixture strings (HLS Direct, MPEG-TS, …) match Mapper.GetStreamingMode exactly; ChannelState fixtures match ChannelStateResponseModel in generated v1.d.ts exactly.
Polling discipline holds: the 30s interval calls only GET /api/channels/state; test mock path-matching order has no false positives.
Checkbox indeterminate and ProgressBar value={null} are real prop contracts; CSS uses only design-system tokens (no hardcoded colors); prototype fidelity is close; window.prompt/confirm is an acceptable deferral (no Dialog component exists yet); Preview-disabled and /app/new-channel placeholder routing are correct scope boundaries.
SUBSTANTIAL
Bulk ops act on selected-but-filtered-out rows (App.tsx, selectedChannels): select 3 channels under "All", switch filter to "On air" (1 visible), click "Delete selected" — all 3 are deleted while the header checkbox suggests a smaller selection. Fix: clear selection on filter change, or scope bulk ops to selectedIds ∩ visibleIds.
Bulk renumber is completely untested: no test exercises the Renumber button / POST /api/channels/bulk/renumber, success or failure — one of the three bulk mutations shipped with zero coverage of its request-body shape or error surfacing.
NITS
Stale-poll window: a slow state-poll response that started before a mutation+refresh can overwrite channelStates with pre-mutation data for up to 30s (no request-ordering guard).
Per-row Delete IconButton is not disabled={mutating} (actionbar buttons are) — a second in-flight mutation clears the first's error message.
The 30s poll keeps firing while the screen is in error/loading state (responses discarded; wasted traffic, never recovers the errored screen).
renumberSelected uses Number.parseFloat — silently truncates "5.1.2" → 5.1 and accepts negatives; relies on backend validation.
Test gaps beyond renumber: ChannelsErrorState/Retry never rendered under test; background-poll-failure resilience untested; bulk-delete success path (204 + refetch + selection cleared) not directly asserted.
CSS: var(--pad-cell-x, 12px) fallback is wrong (token resolves to 16px); prototype's row-hover highlight and hover-revealed action buttons not implemented; footer pagination from prototype dropped (fine — no server paging exists — but confirm deliberate).
Verified clean on the scary paths: hook ordering, StrictMode double-mount, poll-vs-loading clobber, double-submit on the actionbar, grouped rendering, unknown-channelId lookups, renumber float math.
Not merging — awaiting user consent per protocol, and a decision on whether findings 1–2 go back to Codex or get fixed in-session.
## Fable review — PR #124 (head 15c6cdd0), read-only, 3-lens subagent review
**Verdict: APPROVE with findings — no blockers. One SUBSTANTIAL correctness finding + one SUBSTANTIAL test gap should be resolved (before merge or as an immediate follow-up, user decides); the rest are nits.**
Lenses: (1) deep React correctness, (2) API contract + test quality, (3) design-system adherence + scope.
### Contract verification — all clean
- Every path/method/body in `channels.ts` matches `ChannelController.cs` and `BulkChannelRequests.cs` field-for-field; status codes (204 success, 404/422 ProblemDetails) correctly handled by `client.ts` → `messageFromError`.
- Fixture strings (`HLS Direct`, `MPEG-TS`, …) match `Mapper.GetStreamingMode` exactly; `ChannelState` fixtures match `ChannelStateResponseModel` in generated `v1.d.ts` exactly.
- Polling discipline holds: the 30s interval calls only `GET /api/channels/state`; test mock path-matching order has no false positives.
- `Checkbox indeterminate` and `ProgressBar value={null}` are real prop contracts; CSS uses only design-system tokens (no hardcoded colors); prototype fidelity is close; `window.prompt/confirm` is an acceptable deferral (no Dialog component exists yet); Preview-disabled and `/app/new-channel` placeholder routing are correct scope boundaries.
### SUBSTANTIAL
1. **Bulk ops act on selected-but-filtered-out rows** (`App.tsx`, `selectedChannels`): select 3 channels under "All", switch filter to "On air" (1 visible), click "Delete selected" — all 3 are deleted while the header checkbox suggests a smaller selection. Fix: clear selection on filter change, or scope bulk ops to `selectedIds ∩ visibleIds`.
2. **Bulk renumber is completely untested**: no test exercises the Renumber button / `POST /api/channels/bulk/renumber`, success or failure — one of the three bulk mutations shipped with zero coverage of its request-body shape or error surfacing.
### NITS
3. Stale-poll window: a slow state-poll response that started before a mutation+refresh can overwrite `channelStates` with pre-mutation data for up to 30s (no request-ordering guard).
4. Per-row Delete `IconButton` is not `disabled={mutating}` (actionbar buttons are) — a second in-flight mutation clears the first's error message.
5. The 30s poll keeps firing while the screen is in error/loading state (responses discarded; wasted traffic, never recovers the errored screen).
6. `renumberSelected` uses `Number.parseFloat` — silently truncates `"5.1.2"` → 5.1 and accepts negatives; relies on backend validation.
7. Test gaps beyond renumber: `ChannelsErrorState`/Retry never rendered under test; background-poll-failure resilience untested; bulk-delete success path (204 + refetch + selection cleared) not directly asserted.
8. CSS: `var(--pad-cell-x, 12px)` fallback is wrong (token resolves to 16px); prototype's row-hover highlight and hover-revealed action buttons not implemented; footer pagination from prototype dropped (fine — no server paging exists — but confirm deliberate).
Verified clean on the scary paths: hook ordering, StrictMode double-mount, poll-vs-loading clobber, double-submit on the actionbar, grouped rendering, unknown-channelId lookups, renumber float math.
**Not merging** — awaiting user consent per protocol, and a decision on whether findings 1–2 go back to Codex or get fixed in-session.
- Derive selectedChannels from visibleChannels and clear selection on
filter change so bulk operations can never act on rows hidden by the
active filter (reachable via the 30s state poll under the on-air filter)
- Disable the per-row delete button while a mutation is in flight
- Correct the --pad-cell-x fallback (token resolves to 16px)
- Add tests: bulk renumber request body + selection clearing, and
selection reset across filter changes
Review: PR #124 findings 1, 2, 4, 8
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
timothy
merged commit 7ee6e9870c into main2026-07-05 11:26:23 +02:00
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.
Summary
Implements the ChicoryTV Channels screen from
design-system/templates/chicorytv-admin/Channels.jsxagainst the existing channel APIs. The screen replaces the placeholder route with a dense grouped table, live/on-air treatment, disabled/hidden markers, bulk selection/action bar, row delete, and loading/error/empty states using existing SPA primitives.Closes #84
Endpoint / Widget Mapping
GET /api/channels->ChannelResponseModel[]sortNumber, grouped bygroup.ChannelResponseModel.group(D)marker/dimmingChannelResponseModel.isEnabled(H)markerChannelResponseModel.showInEpgGET /api/channels/state->ChannelStateResponseModel.onAirChannelStateResponseModel.nowPlayingIdle/Off air, no fabricated data.ChannelResponseModel.streamingModebackend display strings fromMapper.csPOST /api/channels/bulk/renumberPOST /api/channels/bulk/groupPOST /api/channels/bulk/deleteDELETE /api/channels/{id}GET /api/channels/stateonlyFiles Changed
web/src/api/channels.tsweb/src/App.tsxweb/src/App.test.tsxweb/src/shell.cssVerification
Web:
cd web && npm cinpm run lintnpm run typechecknpm test -- --run-> 37 passednpm run buildBackend:
TZ=UTC dotnet build ErsatzTV.sln-> passed with existing NuGet advisory warnings and unrelated nullable warnings in artwork upload testsTZ=UTC dotnet test ErsatzTV.Tests/ErsatzTV.Tests.csproj --no-build -v normal-> 368/368 passedTZ=UTC dotnet test ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj --no-build -v normal-> 493 passed, 1 skippedDeferred / Uncertain
origin/mainresolved to39d76080after fetch, not the4ffd777bSHA mentioned in the prompt; this branch was created from the currentorigin/mainas required by the worktree command.Fable review — PR #124 (head
15c6cdd0), read-only, 3-lens subagent reviewVerdict: APPROVE with findings — no blockers. One SUBSTANTIAL correctness finding + one SUBSTANTIAL test gap should be resolved (before merge or as an immediate follow-up, user decides); the rest are nits.
Lenses: (1) deep React correctness, (2) API contract + test quality, (3) design-system adherence + scope.
Contract verification — all clean
channels.tsmatchesChannelController.csandBulkChannelRequests.csfield-for-field; status codes (204 success, 404/422 ProblemDetails) correctly handled byclient.ts→messageFromError.HLS Direct,MPEG-TS, …) matchMapper.GetStreamingModeexactly;ChannelStatefixtures matchChannelStateResponseModelin generatedv1.d.tsexactly.GET /api/channels/state; test mock path-matching order has no false positives.Checkbox indeterminateandProgressBar value={null}are real prop contracts; CSS uses only design-system tokens (no hardcoded colors); prototype fidelity is close;window.prompt/confirmis an acceptable deferral (no Dialog component exists yet); Preview-disabled and/app/new-channelplaceholder routing are correct scope boundaries.SUBSTANTIAL
App.tsx,selectedChannels): select 3 channels under "All", switch filter to "On air" (1 visible), click "Delete selected" — all 3 are deleted while the header checkbox suggests a smaller selection. Fix: clear selection on filter change, or scope bulk ops toselectedIds ∩ visibleIds.POST /api/channels/bulk/renumber, success or failure — one of the three bulk mutations shipped with zero coverage of its request-body shape or error surfacing.NITS
channelStateswith pre-mutation data for up to 30s (no request-ordering guard).IconButtonis notdisabled={mutating}(actionbar buttons are) — a second in-flight mutation clears the first's error message.renumberSelectedusesNumber.parseFloat— silently truncates"5.1.2"→ 5.1 and accepts negatives; relies on backend validation.ChannelsErrorState/Retry never rendered under test; background-poll-failure resilience untested; bulk-delete success path (204 + refetch + selection cleared) not directly asserted.var(--pad-cell-x, 12px)fallback is wrong (token resolves to 16px); prototype's row-hover highlight and hover-revealed action buttons not implemented; footer pagination from prototype dropped (fine — no server paging exists — but confirm deliberate).Verified clean on the scary paths: hook ordering, StrictMode double-mount, poll-vs-loading clobber, double-submit on the actionbar, grouped rendering, unknown-channelId lookups, renumber float math.
Not merging — awaiting user consent per protocol, and a decision on whether findings 1–2 go back to Codex or get fixed in-session.