docs: advance ChicoryTV issue queue past #88 (PR #128); next prompt = #85 EPG (Codex)
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m54s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m28s

Records the #128 review outcome (percent-fraction + poll-start-race fixes,
bee686c8), main d63719ec, baselines (368/493+1skip/75 web), and new lessons
(numeric wire-field units, trigger≠started grace windows, no ref mutation in
updaters).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 19:08:53 +02:00
co-authored by Claude Fable 5
parent d63719ece9
commit d9ffbde79c
+104 -103
View File
@@ -6,7 +6,8 @@ always holds the current handoff. History: created 2026-07-02 after the plan aud
filed backend gap issues #100#111; a 79-way parallel workflow build once exhausted RAM, so
builds are limited to 23 concurrent, never wide fan-outs. Backend gaps all landed by
2026-07-04 (PRs #113#119); merge pass PR #120; live-data screens so far: #109 Dashboard
(PR #123), #84 Channels (PR #124), #86 Schedule editor (PR #125), #87 Playouts (PR #127).
(PR #123), #84 Channels (PR #124), #86 Schedule editor (PR #125), #87 Playouts (PR #127),
#88 Libraries (PR #128).
**PROCESS (2026-07-05, binding)**: Fable writes each Codex prompt AND performs the PR review
(via review subagents — parallel lenses: correctness fork + cheaper contract/tests +
@@ -14,129 +15,132 @@ design-system agents; plus a fork verification pass over any fix diff). Codex im
Every Codex prompt MUST mandate: (a) subagents where appropriate at fitting effort/model
levels, (b) `npm ci` in each fresh worktree. Review-driven fixes are applied by fitting
subagents too (NITS: Fable fixes on the branch; SUBSTANTIAL: back to Codex verbatim or fixed
in-session — user decides). Merges need explicit user consent.
in-session — user decides). Merges need explicit user consent (standing consent for this
stretch: merge when reviewers are happy and tests pass).
**Session state (2026-07-05, post-#87)**: main = a8dd88ef (PR #127 merge): Playouts monitor
live — selector rail (paged /api/playouts + filter), now-playing hero from the 30s
channel-state poll, up-next/timeline/upcoming from /api/playouts/{id}/items (fetch on
selection + Show filler toggle → ?showFiller=true; never polled), warnings badge, reset-all
(confirm + ref guard). Fable review found 2 blocker-class defects fixed pre-merge
(c4190e3a, sonnet+haiku subagents, fork-verified CONFIRMED-GOOD): the filler UI was wrong
AND dead (see lesson below) and an undefined CSS token broke the hero. Baselines:
ErsatzTV.Tests **368**, Core.Tests **493** (+1 skip); web tests **63**, lint/typecheck/build
clean. #84/#86/#87 CLOSED. Worktree .worktrees/issue-87-playouts is merged — remove it.
Main checkout sits on docs/59-ui-redesign-brief (fully merged, safe to switch).
**Session state (2026-07-05, post-#88)**: main = d63719ec (PR #128 merge): Libraries screen
live — source cards (media-sources), per-library scan progress + triggers, per-source scan,
conditional 10s scan-status polling (pendingactive drives the interval; 3-tick grace window
covers the queue-to-start lag; drains on persistent errors; refetches sources once on
completion). Review found 2 production-visible substantials fixed pre-merge (bee686c8):
percent was a 01 wire fraction (bar showed ~0%), and the poll-start race. Fork verification
caught a StrictMode-unsafe ref mutation inside a setState updater — fixed via a pure hoist.
Baselines: ErsatzTV.Tests **368**, Core.Tests **493** (+1 skip); web tests **75**,
lint/typecheck/build clean. #84/#86/#87/#88 CLOSED. Main checkout sits on
docs/59-ui-redesign-brief (fully merged, safe to switch).
**Lessons for all remaining prompts** (accumulated):
- Fixture fidelity (#109, #127): fixture values MUST be what the actually-called endpoint
returns under the query the client actually sends. #127's filler bug: backend always emits
fillerKind ('None' for real items — truthy!), while the client never sent showFiller=true
(server strips filler by default) — fixtures invented null/'PreRoll' and masked both. When
a field is enum-with-None, never truthiness-check it; when an endpoint has query params
that change the response, tests must cover the param actually sent.
- OpenAPI can UNDER-report the wire (abstract VMs + Newtonsoft runtime-type serialization,
#125/#126) — check the backend serializer before hand-widening types; file contract gaps.
- setState updaters must be PURE — no fetches inside them (StrictMode double-invokes; #127
used a baseRef hoist). Guard double-submit with a REF (#125). Merge poll results
functionally — never write captured snapshots over newer state (#127).
- Every new multi-column grid goes into the @media (max-width: 980px) collapse block (#127
missed all of them → page-level horizontal scroll). var() fallbacks must equal the token's
resolved value; verify the token EXISTS (--ctv-surface-1 didn't; --text-faint doesn't
use --text-disabled). No page-level horizontal scroll ever.
- Prototype affordances are either implemented or VISIBLY deferred (disabled + note) — never
silently dropped (#127 dropped Reset buttons + the Show filler toggle; the toggle turned
out to be the missing half of a blocker).
- Anything actionable derives from what is VISIBLE (#84); disable all mutation triggers while
mutating (incl. rails/rows); mutations never refetch the world (#125/#127).
- A screen listing N entities must let the user pick among them (#125).
- Tests must be honest: no scenarios the backend cannot produce (reset-all has NO error path
— unconditional 202); mount call-count tests must isolate the route's own fetches from
Dashboard prefetch (assert the DELTA across navigation); paging labels must not lie when
totalCount > page length ("shown of total"); test search/filter UI you ship.
- The image-build job runs ONLY on main pushes — PR CI can't catch Dockerfile breakage.
- DTO records in ErsatzTV.Core/Api need file-scoped `#nullable enable`; ErsatzTV.Application
has NO nullable context. NSubstitute+ConfigElementKey: `Arg.Any<ConfigElementKey>()` + `<T>`.
`Option<T>.ToNullable()` doesn't exist → `MatchUnsafe(v => (T?)v, () => null)`.
- Fixture fidelity (#109/#127/#128): fixtures must be what the actually-called endpoint
returns UNDER THE QUERY THE CLIENT SENDS. #128: `percent` is a 01 fraction despite its
name (bar showed ~0%; 0100 fixtures masked it). Enum-with-None fields are never
truthiness-checked. Verify UNITS/scale of numeric wire fields against the producing code,
not the field name.
- Trigger≠started (#128): a 202/200 on a trigger endpoint means QUEUED; status endpoints may
lag by seconds. Pending-state with a grace window bridges the gap; poll while
pendingactive nonempty; drain grace on persistent errors so UIs can't stick.
- setState updaters must be PURE — no fetches (#127) and no ref mutations (#128); hoist
side-effects/bookkeeping out. StrictMode double-invokes updaters and the test renderer
doesn't, so tests won't catch it — reviewers must.
- OpenAPI can UNDER-report the wire (#125/#126); check the serializer before widening types.
- Every new multi-column grid → the @media (max-width: 980px) collapse block; var() fallback
= the token's resolved value; verify the token EXISTS (--ctv-surface-1, --text-faint don't).
- Prototype affordances: implemented or VISIBLY deferred — never silently dropped
(#127 toggle = half a blocker; #128 per-source Scan).
- Actionable = visible (#84); disable all mutation triggers while mutating; ref-based
double-submit guards; mutations never refetch the world (#125/#127).
- A screen listing N entities lets the user pick among them (#125).
- Honest tests: no scenarios the backend can't produce (no exception middleware → failures
are BARE 4xx/5xx, not ProblemDetails, unless the controller explicitly returns them);
mount call-counts assert the DELTA across navigation (Dashboard prefetches
/api/playouts AND /api/media-sources); paging labels don't lie; test the search/filter UI
you ship; status-dot state never color-only (StatusDot has a label prop).
- The image-build job runs ONLY on main pushes. DTO records in Core/Api need `#nullable
enable`; Application has NO nullable context. NSubstitute+ConfigElementKey:
`Arg.Any<ConfigElementKey>()` + `<T>`. `Option<T>.ToNullable()` → `MatchUnsafe`.
update-openapi.sh needs a prior normal build. Child GETs 404 unknown parents via pre-check.
- Backlog nits (unfiled): unclamped pageSize; PlayoutController Create/Delete lack Name=;
heavy GetItems pre-check; >30 MB uploads → bare 413; artwork content-type trusted (#66);
schedule estimator materializes collections per GET; /api/health TTL cache; #85 EPG:
/api/guide 21-include eager-load + fillerKind-mostly-None notes; no Dialog/Modal component
yet (likely needed by #89); 1 pre-existing --text-faint usage in shell.css. Filed: #126
(OpenAPI polymorphism gap — also unblocks removing the widened type in schedules.ts).
schedule estimator materializes collections per GET; /api/health TTL cache; NEW (#128):
`LibraryScanStatusResponseModel.percent` is a 01 fraction under a percent name — rename
or ×100 server-side eventually; no Dialog/Modal component yet (likely needed by #89);
1 pre-existing --text-faint usage in shell.css. Filed: #126 (OpenAPI polymorphism gap).
---
# PROMPT FOR CODEX — #88: Libraries screen (media source cards + scan status)
# PROMPT FOR CODEX — #85: Guide / EPG screen (channels × time grid)
You are Codex, the IMPLEMENTER, in /Users/timothy/ersatztv (ErsatzTV fork; React SPA
"ChicoryTV" in web/ — Vite + TS, typed client web/src/api/, generated types
web/src/api/generated/v1.d.ts). Fable (Claude) reviews your PR read-only afterwards — do NOT
merge. Read CLAUDE.md and docs/contributing.md first; follow the SPA patterns from Channels
(PR #124), Schedule editor (PR #125), and Playouts (PR #127) — discriminated-union query
hooks, messageFromError/ProblemDetails, mutation discipline — match, don't invent.
(PR #124), Schedule editor (PR #125), Playouts (PR #127), Libraries (PR #128) — match, don't
invent.
HARD CONSTRAINTS:
- USE SUBAGENTS where appropriate, at fitting effort and model levels: cheap/fast agents for
mechanical work (fixtures, CSS transcription, test boilerplate); higher-effort agents only
for judgment work (scan-status polling state machine, API-contract mapping). Keep
orchestration and final assembly in your main session.
- Worktree: `git worktree add .worktrees/issue-88-libraries -b feat/88-libraries origin/main`.
for judgment work (grid virtualization/layout math, time-window state). Keep orchestration
and final assembly in your main session.
- Worktree: `git worktree add .worktrees/issue-85-epg -b feat/85-epg origin/main`.
Never touch the main checkout or other .worktrees/*.
- Run `npm ci` in web/ in the worktree BEFORE any lint/typecheck/test/build.
- Max 23 concurrent builds machine-wide; ONE dotnet build at a time here.
- NEVER set ETV_UPDATE_GOLDENS. A golden-file diff means your code is wrong.
- FRONTEND-only: no new endpoints, no ErsatzTV.Core/Application changes. Missing data =
VISIBLE deferral (disabled + note) — never silently drop a prototype affordance (#127
lesson: a silently-dropped toggle was half of a blocker).
VISIBLE deferral (disabled + note) — never silently drop a prototype affordance.
- CONTRACT RULE: fixture values must be what the actually-called endpoint returns UNDER THE
QUERY THE CLIENT SENDS — copy from backend mappers/serializers or v1.d.ts, never invent.
Enum-with-None fields must never be truthiness-checked (#127's filler bug). If generated
types look incomplete vs the backend, check the serializer first (see #126).
QUERY THE CLIENT SENDS; verify units/scale of numeric fields against the producing backend
code, never the field name (#128's percent lesson). Enum-with-None fields are never
truthiness-checked (#127's fillerKind lesson — this endpoint HAS fillerKind!).
## Task
Issue #88: recreate design-system/templates/chicorytv-admin/ScheduleLibrary.jsx (Libraries)
wired to the live API. Local/Plex/Jellyfin/Emby source cards with per-library scan status +
scan triggers. Acceptance: matches prototype; live sources + scan triggers; all 3 themes.
Issue #85: recreate design-system/templates/chicorytv-admin/Epg.jsx wired to the JSON guide.
Dark channels×time grid, "now" marker, live focal color, jump-to-now. Acceptance: matches
prototype; live guide data; all 3 themes.
## Context (main = a8dd88ef; baselines: ErsatzTV.Tests 368, Core.Tests 493+1skip, web 63)
## Context (main = d63719ec; baselines: ErsatzTV.Tests 368, Core.Tests 493+1skip, web 75)
- Gitea: http://192.168.1.95:3000/timothy/ersatztv (basic auth timothy:ded89Lm4).
- API surface (verify in v1.d.ts; regen `npm run generate:api` if stale):
- GET /api/media-sources → MediaSourceResponseModel[] { id, kind (STRING — copy the exact
values from the backend mapper, do not guess casing), name, connectionAddress (nullable),
libraries: [{ id, name, mediaKind, lastScan (nullable ISO), itemCount }] }.
- GET /api/libraries/scan-status → LibraryScanStatusResponseModel[] { libraryId, percent }
— the in-progress scan progress source.
- POST /api/libraries/{id}/scan — trigger a scan (check the controller for status code +
body + error responses; verify whether a ProblemDetails error path exists — write tests
only for scenarios the backend CAN produce, #127 lesson).
- POST /api/libraries/{id}/scan-show exists — include only if the prototype surfaces it,
else ignore (not even a deferral note needed).
- All errors ProblemDetails — surface `detail`.
- Polling policy for scan status: fetch sources + scan-status on mount; poll
/api/libraries/scan-status ONLY while at least one scan is in progress (i.e. after the
user triggers a scan or mount shows nonzero in-progress entries), at ≥10s, and STOP the
interval when no scans remain in progress; refetch /api/media-sources once when a scan
completes (to refresh lastScan/itemCount). Never poll /api/media-sources itself. NO
/api/health polling.
- Scan trigger mutation: disabled-while-scanning for that library (and while the trigger
request is in flight — ref guard), ProblemDetails banner on failure, per-library progress
bar from percent.
- CSS: design-system tokens only; verify each token EXISTS; var() fallback = resolved value;
add every new multi-column grid to the @media (max-width: 980px) collapse block.
- Tests: mount fetch delta (isolate from Dashboard prefetch); poll starts only when a scan
is in progress and STOPS when done; scan trigger success + (only-if-producible) failure;
empty sources; error+retry; fixture `kind` values copied from the backend mapper.
- GET /api/guide?start=<ISO>&end=<ISO> (both optional DateTimeOffset; start defaults to
now, end to start + configured XmltvDaysToBuild) → ChannelGuideResponseModel { start,
end, channels: [{ number, name, programmes: [{ start, stop, title, subTitle (nullable),
category (nullable), fillerKind }] }] }.
- CRITICAL COST NOTE (#102): the guide endpoint runs a heavy 21-include eager-load PER
CHANNEL PER REQUEST. Request a BOUNDED window (e.g. now-1h → now+12h), fetch ONCE on
mount + on explicit user navigation (prev/next window, jump-to-now beyond the loaded
range) — NEVER poll /api/guide. The "now" marker advances client-side on a timer
(minute-ish tick moving the marker/highlight; NO refetch).
- fillerKind in the JSON guide is almost always 'None' — the shared projector merges
filler into adjacent programmes. Do NOT truthiness-check it; discrete filler rows would
need a backend projection mode (out of scope — no UI toggle for it).
- GET /api/channels/state (onAir) may drive the live focal treatment; if used, poll ≥30s
(Channels precedent) — it's the only thing allowed on an interval besides the client-side
now-marker tick.
- Channel logos: check what the prototype uses; the SPA has ChannelLogo (initials-based).
/iptv logo URLs exist but check what Channels/Dashboard screens do — match precedent.
- Grid math: programmes can start before / end after the requested window — clip to the
window; overlapping/adjacent programmes must not produce negative/NaN widths; empty
channels (no programmes) render an empty row, not a crash; timezone = render in the
BROWSER's local time (the wire carries offsets; Date parsing is TZ-safe).
- No mutations on this screen. Read-only + navigation state only.
- CSS: design-system tokens only; verify each token EXISTS; fallback = resolved value; new
multi-column grids → the 980px collapse block; the grid scrolls in its OWN container
(both axes as needed) — never page-level horizontal scroll.
- Tests: mount fetch delta (guide fetched exactly once; no guide polling — assert interval
ticks don't refetch /api/guide); window navigation triggers exactly one fetch per step;
jump-to-now; now-marker position math (fixed fake now vs fixture times); programme
clipping at window edges; empty channels; error+retry; fixture fillerKind 'None' (real
wire value) with subTitle/category nulls exercised.
## Process
1. Comment on issue #88 with findings + approach (prototype surface → endpoint/DTO mapping
table, scope decisions) BEFORE coding.
2. Implement; comment progress on #88 as you go.
1. Comment on issue #85 with findings + approach (prototype surface → endpoint/DTO mapping
table, scope decisions, chosen window size) BEFORE coding.
2. Implement; comment progress on #85 as you go.
3. Verify: cd web && npm ci && npm run lint && npm run typecheck && npm test -- --run &&
npm run build; then TZ=UTC dotnet build ErsatzTV.sln; TZ=UTC dotnet test ErsatzTV.Tests +
Core.Tests sequentially (expect 368 / 493+1skip — no backend regression).
4. Push, open PR → main: "feat(web): Libraries screen (#88)", body lists the mapping table
and scope decisions; `closes #88`. Poll CI by head SHA until green. Do NOT merge.
4. Push, open PR → main: "feat(web): Guide / EPG screen (#85)", body lists the mapping table
and scope decisions; `closes #85`. Poll CI by head SHA until green. Do NOT merge.
## On completion — REQUIRED final output
Print a fenced handoff prompt addressed to Claude (Fable) asking it to review the PR
@@ -145,21 +149,18 @@ files changed, the mapping table, verification commands + results, deferred/unce
and the finding classification (NITS = Fable fixes on the branch; SUBSTANTIAL = back to
Codex verbatim or fixed in-session with subagents — user decides). After approval + user
merge consent, Fable merges, verifies main's post-merge run (image job included), updates
THIS handoff (pop #88, next prompt = #85 EPG, record PR + main SHA + baselines), and pushes
it to main.
THIS handoff (pop #85, next prompt = #89 Channel Builder, record PR + main SHA + baselines),
and pushes it to main.
---
## Issue queue (work top-down)
0. HOUSEKEEPING: remove .worktrees/issue-87-playouts (merged). #99 stays open for the final
/api/channels/state onAir wiring; #126 (OpenAPI polymorphism gap) is a good backend
slot-filler between screens.
1. #88 Libraries screen ← CODEX PROMPT above (Fable reviews via subagents, merges on
0. HOUSEKEEPING: #99 stays open for the final /api/channels/state onAir wiring; #126
(OpenAPI polymorphism gap) is a good backend slot-filler between screens.
1. #85 Guide/EPG screen ← CODEX PROMPT above (Fable reviews via subagents, merges on
consent, updates this file).
2. UX conversion order: #85 EPG (#102 JSON guide; mind fillerKind + per-request-cost notes
above) → #89 Channel Builder (#104 artwork upload; also needs #62: #63/#64/#65; likely
needs the first Dialog/Modal component; languages endpoint follow-up from #105 when
needed) → #93 Settings → #90 rebrand → #91 cutover.
Cross-refs: #99 seam landed (PR #121), final wiring open. Done recently: PR #124 (#84
Channels), PR #125 (#86 Schedule editor), PR #127 (#87 Playouts — closed 2026-07-05, main
a8dd88ef). Filed: #126.
2. UX conversion order: #89 Channel Builder (#104 artwork upload; also needs #62:
#63/#64/#65; likely needs the first Dialog/Modal component; languages endpoint follow-up
from #105 when needed) → #93 Settings → #90 rebrand → #91 cutover.
Cross-refs: #99 seam landed (PR #121), final wiring open. Done recently: PR #125 (#86),
PR #127 (#87), PR #128 (#88 Libraries — closed 2026-07-05, main d63719ec). Filed: #126.