programmes[].fillerKind; explicitly compare to None, no truthiness checks
time header + block positions
bounded request window, browser-local Date rendering, clipped programme geometry
now marker + live focal color
client-side minute tick for marker; /api/channels/state at 30s cadence for live state
Jump to now / prev-next
navigation state issues exactly one new bounded /api/guide?start=&end= request
Scope decisions
Request window is now - 1h through now + 12h, always sent explicitly.
/api/guide is never polled; only explicit navigation/retry refetches guide data.
/api/channels/state remains the only interval API, at the existing Channels 30s cadence.
No backend changes or new endpoints.
Discrete filler rows remain out of scope because the current JSON guide projector merges filler into adjacent programmes.
Review fixes included
Added request sequencing so stale same-window guide responses cannot overwrite newer retry/navigation state.
Live programme styling now matches by exact start/finish timestamps only, avoiding title-contract drift between guide programme titles and now-playing display titles.
Guarded omitted nowPlaying values from /api/channels/state, including the on-air/offline-fallback wire shape where the key is absent.
Initial guide loading degrades gracefully when /api/channels/state fails; the guide remains visible without live dots.
Reused the exported guide window helpers/constants in the screen and jump-to-now logic.
Replaced the bespoke crosshair glyph with the existing lucide Crosshair icon.
Guide toolbar wraps before intermediate-width layouts can push horizontal scrolling outside the EPG grid frame.
Verification
npm ci passed
npm run typecheck passed
npm run lint passed
npm test -- --run passed: 80 tests
npm run build passed
TZ=UTC dotnet build ErsatzTV.sln passed with existing NuGet audit warnings
TZ=UTC dotnet test ErsatzTV.Tests/ErsatzTV.Tests.csproj passed: 368
TZ=UTC dotnet test ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj passed: 493 passed, 1 skipped
Deferred / uncertain
No discrete filler-entry toggle until the backend exposes a JSON-only projection mode.
/api/guide remains expensive; this screen intentionally avoids polling it.
Channel group filtering is visibly disabled until the endpoint supplies groups for guide rows.
Shared poll-loop extraction and full-grid memoization remain non-blocking cleanups.
## Summary
Closes #85.
Adds the ChicoryTV Guide / EPG screen at `/app/guide`, wired to the JSON guide endpoint with a bounded, explicit time window.
## Prototype → API mapping
| Prototype surface | Live data / SPA mapping |
|---|---|
| channel rail `num`, `name` | `/api/guide` `channels[].number`, `channels[].name`; existing `ChannelLogo` initials |
| programme block title/subtitle/category | `/api/guide` `programmes[].title`, `subTitle`, `category`; null subtitle/category omitted |
| filler muted treatment | `programmes[].fillerKind`; explicitly compare to `None`, no truthiness checks |
| time header + block positions | bounded request window, browser-local `Date` rendering, clipped programme geometry |
| now marker + live focal color | client-side minute tick for marker; `/api/channels/state` at 30s cadence for live state |
| Jump to now / prev-next | navigation state issues exactly one new bounded `/api/guide?start=&end=` request |
## Scope decisions
- Request window is `now - 1h` through `now + 12h`, always sent explicitly.
- `/api/guide` is never polled; only explicit navigation/retry refetches guide data.
- `/api/channels/state` remains the only interval API, at the existing Channels 30s cadence.
- No backend changes or new endpoints.
- Discrete filler rows remain out of scope because the current JSON guide projector merges filler into adjacent programmes.
## Review fixes included
- Added request sequencing so stale same-window guide responses cannot overwrite newer retry/navigation state.
- Live programme styling now matches by exact start/finish timestamps only, avoiding title-contract drift between guide programme titles and now-playing display titles.
- Guarded omitted `nowPlaying` values from `/api/channels/state`, including the on-air/offline-fallback wire shape where the key is absent.
- Initial guide loading degrades gracefully when `/api/channels/state` fails; the guide remains visible without live dots.
- Reused the exported guide window helpers/constants in the screen and jump-to-now logic.
- Replaced the bespoke crosshair glyph with the existing lucide `Crosshair` icon.
- Guide toolbar wraps before intermediate-width layouts can push horizontal scrolling outside the EPG grid frame.
## Verification
- `npm ci` passed
- `npm run typecheck` passed
- `npm run lint` passed
- `npm test -- --run` passed: 80 tests
- `npm run build` passed
- `TZ=UTC dotnet build ErsatzTV.sln` passed with existing NuGet audit warnings
- `TZ=UTC dotnet test ErsatzTV.Tests/ErsatzTV.Tests.csproj` passed: 368
- `TZ=UTC dotnet test ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj` passed: 493 passed, 1 skipped
## Deferred / uncertain
- No discrete filler-entry toggle until the backend exposes a JSON-only projection mode.
- `/api/guide` remains expensive; this screen intentionally avoids polling it.
- Channel group filtering is visibly disabled until the endpoint supplies groups for guide rows.
- Shared poll-loop extraction and full-grid memoization remain non-blocking cleanups.
1. [CONFIRMED] Guide screen crashes when a channel is on air with no program item — web/src/App.tsxprogrammeMatchesNowPlaying
The guard is if (nowPlaying === null) return false; but the backend serializes with NullValueHandling.Ignore (ErsatzTV/Startup.cs ~343), so a null NowPlaying is omitted from /api/channels/state JSON and arrives as undefined. undefined !== null → the guard passes → new Date(nowPlaying.startUtc) throws TypeError and unmounts the whole GuideScreen.
Trigger is a normal runtime state: GetChannelStatesForApiHandler sets OnAir = ffmpegSegmenterService.IsActive(...) independently of NowPlaying, so onAir=true + nowPlaying omitted occurs whenever a channel streams offline-fallback filler. Note every pre-existing SPA call site uses truthiness (nowPlaying ?, nowPlaying?.title) for exactly this reason — only the new guide code introduced === null.
Fix: if (!nowPlaying) return false; (and ideally add a test with onAir=true + no nowPlaying key in the fixture — jsonResponse fixtures that include nowPlaying: null don't reproduce the wire shape).
2. [CONFIRMED] Live highlight can never match episodes or music videos — web/src/App.tsxprogrammeMatchesNowPlaying
The guide endpoint titles programmes via ChannelGuideMetadata.GetTitle → show title only ("Firefly"; episode name goes to subTitle). /api/channels/state titles via Mapper.GetDisplayTitle → "Firefly - s01e01 - Serenity". The programme.title === nowPlaying.title requirement therefore fails for every Episode (and MusicVideo: artist vs "artist - track"); only Movies can ever light up. The duplicate-title review fix made the match stricter but it was already broken by title asymmetry.
Suggested direction: match on start/finish timestamps only (they come from the same GuideFinish sources on both endpoints), or better, open a small backend follow-up to expose a shared programme/playout-item id on both endpoints — string correlation is a contract gap, not a frontend bug to patch harder.
Cleanup (non-blocking, but cheap now)
[CONFIRMED] Duplicated poll loop — useGuideScreenQuery.loadChannelStates + its 30s interval effect is a near line-for-line copy of the same logic in useChannelsScreenQuery (web/src/api/channels.ts). The merge-into-success spread is state-shape-agnostic; extract a shared helper so future fixes (backoff, pause-when-hidden) land in both.
[CONFIRMED] GUIDE_WINDOW_MS declared twice — guide.ts exports it (drives the fetch window); App.tsx redeclares the same 13h literal (drives pagination step + grid width). Import it instead; divergence would silently desync paging from data.
[PLAUSIBLE] Channel-state failure is fatal on initial load — getGuideScreenData's Promise.all turns a /api/channels/state 500 into the full "Guide unavailable" screen even though the guide loaded; the 30s poll treats the same failure as optional. This mirrors the channels-screen pattern, but there states are core content — here they're only live dots, so degrading gracefully is strictly better.
[CONFIRMED] Bespoke CrosshairIcon — a text + with custom CSS instead of lucide-react's Crosshair, which neighboring buttons' startIcons use. <Crosshair aria-hidden="true" size={15} />.
[PLAUSIBLE] Full-grid re-render on every tick — the 60s now-tick and the 30s state poll (which replaces the whole data object) rebuild channelStatesByNumber, slots, and re-run clipProgramme for every programme, when only the marker and live dots depend on the tick. useMemo the derived grid keyed on guide, keep channelStates in separate state, isolate the now layer in a small child.
Checked and cleared
Requested-vs-returned window drift: refuted — GetChannelGuideDataHandler echoes start/end verbatim (no clamping/snapping), so moveWindow paging from the requested start is safe today. Latent coupling only if a bound is ever omitted (server substitutes UtcNow/+daysToBuild).
Channel number types match (string on both ChannelGuideChannelResponseModel.number and ChannelStateResponseModel.channelNumber).
fillerKind !== 'None' is sound (StringEnumConverter).
Design tokens in shell.css all exist in the design system; no convention violations.
Handoff claim "guide endpoint is not polled" holds — only getChannelStates is on an interval.
The stale-request sequencing (requestId + windowStartRef guards) is correct, including the gap between setWindowStart and the effect's load().
Deferred items from the handoff (disabled group filter, discrete filler rows, no guide polling) are acknowledged and fine as-is.
Merge gate: fix#1 (blocker) and #2 (or explicitly defer #2 with the backend follow-up issue filed), CI green, then user consent.
## Fable review — PR #129 (feat/85-epg @ 7d93688)
Process: 8 finder angles (line-by-line, removed-behavior, cross-file/backend contract, reuse, simplification, efficiency, altitude, conventions) + adversarial verification per candidate. **Verdict: request changes** — 2 confirmed correctness bugs, 6 cleanup findings.
### Blockers
**1. [CONFIRMED] Guide screen crashes when a channel is on air with no program item — `web/src/App.tsx` `programmeMatchesNowPlaying`**
The guard is `if (nowPlaying === null) return false;` but the backend serializes with `NullValueHandling.Ignore` (`ErsatzTV/Startup.cs` ~343), so a null `NowPlaying` is **omitted** from `/api/channels/state` JSON and arrives as `undefined`. `undefined !== null` → the guard passes → `new Date(nowPlaying.startUtc)` throws TypeError and unmounts the whole GuideScreen.
Trigger is a normal runtime state: `GetChannelStatesForApiHandler` sets `OnAir = ffmpegSegmenterService.IsActive(...)` independently of `NowPlaying`, so onAir=true + nowPlaying omitted occurs whenever a channel streams offline-fallback filler. Note every pre-existing SPA call site uses truthiness (`nowPlaying ?`, `nowPlaying?.title`) for exactly this reason — only the new guide code introduced `=== null`.
Fix: `if (!nowPlaying) return false;` (and ideally add a test with onAir=true + no `nowPlaying` key in the fixture — `jsonResponse` fixtures that include `nowPlaying: null` don't reproduce the wire shape).
**2. [CONFIRMED] Live highlight can never match episodes or music videos — `web/src/App.tsx` `programmeMatchesNowPlaying`**
The guide endpoint titles programmes via `ChannelGuideMetadata.GetTitle` → show title only (`"Firefly"`; episode name goes to `subTitle`). `/api/channels/state` titles via `Mapper.GetDisplayTitle` → `"Firefly - s01e01 - Serenity"`. The `programme.title === nowPlaying.title` requirement therefore fails for every Episode (and MusicVideo: artist vs `"artist - track"`); only Movies can ever light up. The duplicate-title review fix made the match stricter but it was already broken by title asymmetry.
Suggested direction: match on start/finish timestamps only (they come from the same `GuideFinish` sources on both endpoints), or better, open a small backend follow-up to expose a shared programme/playout-item id on both endpoints — string correlation is a contract gap, not a frontend bug to patch harder.
### Cleanup (non-blocking, but cheap now)
3. **[CONFIRMED] Duplicated poll loop** — `useGuideScreenQuery.loadChannelStates` + its 30s interval effect is a near line-for-line copy of the same logic in `useChannelsScreenQuery` (`web/src/api/channels.ts`). The merge-into-success spread is state-shape-agnostic; extract a shared helper so future fixes (backoff, pause-when-hidden) land in both.
4. **[CONFIRMED] `GUIDE_WINDOW_MS` declared twice** — `guide.ts` exports it (drives the fetch window); `App.tsx` redeclares the same 13h literal (drives pagination step + grid width). Import it instead; divergence would silently desync paging from data.
5. **[CONFIRMED] `jumpToNow` re-inlines the 1-hour look-back** — `now.getTime() - 60 * 60 * 1000` duplicates `GUIDE_PAST_MS`/`defaultGuideWindowStart`. Call `defaultGuideWindowStart(now)`.
6. **[PLAUSIBLE] Channel-state failure is fatal on initial load** — `getGuideScreenData`'s `Promise.all` turns a `/api/channels/state` 500 into the full "Guide unavailable" screen even though the guide loaded; the 30s poll treats the same failure as optional. This mirrors the channels-screen pattern, but there states are core content — here they're only live dots, so degrading gracefully is strictly better.
7. **[CONFIRMED] Bespoke `CrosshairIcon`** — a text `+` with custom CSS instead of lucide-react's `Crosshair`, which neighboring buttons' startIcons use. `<Crosshair aria-hidden="true" size={15} />`.
8. **[PLAUSIBLE] Full-grid re-render on every tick** — the 60s now-tick and the 30s state poll (which replaces the whole `data` object) rebuild `channelStatesByNumber`, `slots`, and re-run `clipProgramme` for every programme, when only the marker and live dots depend on the tick. `useMemo` the derived grid keyed on `guide`, keep channelStates in separate state, isolate the now layer in a small child.
### Checked and cleared
- Requested-vs-returned window drift: **refuted** — `GetChannelGuideDataHandler` echoes `start`/`end` verbatim (no clamping/snapping), so `moveWindow` paging from the requested start is safe today. Latent coupling only if a bound is ever omitted (server substitutes `UtcNow`/`+daysToBuild`).
- Channel number types match (`string` on both `ChannelGuideChannelResponseModel.number` and `ChannelStateResponseModel.channelNumber`).
- `fillerKind !== 'None'` is sound (`StringEnumConverter`).
- Design tokens in `shell.css` all exist in the design system; no convention violations.
- Handoff claim "guide endpoint is not polled" holds — only `getChannelStates` is on an interval.
- The stale-request sequencing (requestId + windowStartRef guards) is correct, including the gap between `setWindowStart` and the effect's `load()`.
Deferred items from the handoff (disabled group filter, discrete filler rows, no guide polling) are acknowledged and fine as-is.
Merge gate: fix #1 (blocker) and #2 (or explicitly defer #2 with the backend follow-up issue filed), CI green, then user consent.
Pushed review fixes in a8d4979b0ceeb5b236860479d22821831fe77d45.
Fixed:
Blocker #1: programmeMatchesNowPlaying now treats omitted nowPlaying as absent with if (!nowPlaying), and the test suite includes an on-air channel fixture with no nowPlaying key.
Functional #2: live matching now uses exact start/finish timestamps only, avoiding the guide-title vs now-playing-display-title contract mismatch for episodes and music videos.
Cleanup #4/#5: the Guide screen imports the shared window constants/helper and jumpToNow calls defaultGuideWindowStart(now).
Cleanup #6: initial /api/channels/state failure degrades to no live-state data instead of failing the full guide load.
Cleanup #7: replaced the bespoke crosshair glyph with lucide Crosshair.
Deferred non-blocking cleanups: shared poll-loop extraction and full-grid memoization.
Fresh local verification on the pushed head:
npm run typecheck passed
npm run lint passed
npm test -- --run passed: 80 tests
npm run build passed
Gitea Actions run 512 is in progress for the new head SHA.
## Review response
Pushed review fixes in `a8d4979b0ceeb5b236860479d22821831fe77d45`.
Fixed:
- Blocker #1: `programmeMatchesNowPlaying` now treats omitted `nowPlaying` as absent with `if (!nowPlaying)`, and the test suite includes an on-air channel fixture with no `nowPlaying` key.
- Functional #2: live matching now uses exact start/finish timestamps only, avoiding the guide-title vs now-playing-display-title contract mismatch for episodes and music videos.
- Cleanup #4/#5: the Guide screen imports the shared window constants/helper and `jumpToNow` calls `defaultGuideWindowStart(now)`.
- Cleanup #6: initial `/api/channels/state` failure degrades to no live-state data instead of failing the full guide load.
- Cleanup #7: replaced the bespoke crosshair glyph with lucide `Crosshair`.
Deferred non-blocking cleanups: shared poll-loop extraction and full-grid memoization.
Fresh local verification on the pushed head:
- `npm run typecheck` passed
- `npm run lint` passed
- `npm test -- --run` passed: 80 tests
- `npm run build` passed
Gitea Actions run 512 is in progress for the new head SHA.
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
Closes #85.
Adds the ChicoryTV Guide / EPG screen at
/app/guide, wired to the JSON guide endpoint with a bounded, explicit time window.Prototype → API mapping
num,name/api/guidechannels[].number,channels[].name; existingChannelLogoinitials/api/guideprogrammes[].title,subTitle,category; null subtitle/category omittedprogrammes[].fillerKind; explicitly compare toNone, no truthiness checksDaterendering, clipped programme geometry/api/channels/stateat 30s cadence for live state/api/guide?start=&end=requestScope decisions
now - 1hthroughnow + 12h, always sent explicitly./api/guideis never polled; only explicit navigation/retry refetches guide data./api/channels/stateremains the only interval API, at the existing Channels 30s cadence.Review fixes included
nowPlayingvalues from/api/channels/state, including the on-air/offline-fallback wire shape where the key is absent./api/channels/statefails; the guide remains visible without live dots.Crosshairicon.Verification
npm cipassednpm run typecheckpassednpm run lintpassednpm test -- --runpassed: 80 testsnpm run buildpassedTZ=UTC dotnet build ErsatzTV.slnpassed with existing NuGet audit warningsTZ=UTC dotnet test ErsatzTV.Tests/ErsatzTV.Tests.csprojpassed: 368TZ=UTC dotnet test ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csprojpassed: 493 passed, 1 skippedDeferred / uncertain
/api/guideremains expensive; this screen intentionally avoids polling it.Fable review — PR #129 (feat/85-epg @
7d93688)Process: 8 finder angles (line-by-line, removed-behavior, cross-file/backend contract, reuse, simplification, efficiency, altitude, conventions) + adversarial verification per candidate. Verdict: request changes — 2 confirmed correctness bugs, 6 cleanup findings.
Blockers
1. [CONFIRMED] Guide screen crashes when a channel is on air with no program item —
web/src/App.tsxprogrammeMatchesNowPlayingThe guard is
if (nowPlaying === null) return false;but the backend serializes withNullValueHandling.Ignore(ErsatzTV/Startup.cs~343), so a nullNowPlayingis omitted from/api/channels/stateJSON and arrives asundefined.undefined !== null→ the guard passes →new Date(nowPlaying.startUtc)throws TypeError and unmounts the whole GuideScreen.Trigger is a normal runtime state:
GetChannelStatesForApiHandlersetsOnAir = ffmpegSegmenterService.IsActive(...)independently ofNowPlaying, so onAir=true + nowPlaying omitted occurs whenever a channel streams offline-fallback filler. Note every pre-existing SPA call site uses truthiness (nowPlaying ?,nowPlaying?.title) for exactly this reason — only the new guide code introduced=== null.Fix:
if (!nowPlaying) return false;(and ideally add a test with onAir=true + nonowPlayingkey in the fixture —jsonResponsefixtures that includenowPlaying: nulldon't reproduce the wire shape).2. [CONFIRMED] Live highlight can never match episodes or music videos —
web/src/App.tsxprogrammeMatchesNowPlayingThe guide endpoint titles programmes via
ChannelGuideMetadata.GetTitle→ show title only ("Firefly"; episode name goes tosubTitle)./api/channels/statetitles viaMapper.GetDisplayTitle→"Firefly - s01e01 - Serenity". Theprogramme.title === nowPlaying.titlerequirement therefore fails for every Episode (and MusicVideo: artist vs"artist - track"); only Movies can ever light up. The duplicate-title review fix made the match stricter but it was already broken by title asymmetry.Suggested direction: match on start/finish timestamps only (they come from the same
GuideFinishsources on both endpoints), or better, open a small backend follow-up to expose a shared programme/playout-item id on both endpoints — string correlation is a contract gap, not a frontend bug to patch harder.Cleanup (non-blocking, but cheap now)
useGuideScreenQuery.loadChannelStates+ its 30s interval effect is a near line-for-line copy of the same logic inuseChannelsScreenQuery(web/src/api/channels.ts). The merge-into-success spread is state-shape-agnostic; extract a shared helper so future fixes (backoff, pause-when-hidden) land in both.GUIDE_WINDOW_MSdeclared twice —guide.tsexports it (drives the fetch window);App.tsxredeclares the same 13h literal (drives pagination step + grid width). Import it instead; divergence would silently desync paging from data.jumpToNowre-inlines the 1-hour look-back —now.getTime() - 60 * 60 * 1000duplicatesGUIDE_PAST_MS/defaultGuideWindowStart. CalldefaultGuideWindowStart(now).getGuideScreenData'sPromise.allturns a/api/channels/state500 into the full "Guide unavailable" screen even though the guide loaded; the 30s poll treats the same failure as optional. This mirrors the channels-screen pattern, but there states are core content — here they're only live dots, so degrading gracefully is strictly better.CrosshairIcon— a text+with custom CSS instead of lucide-react'sCrosshair, which neighboring buttons' startIcons use.<Crosshair aria-hidden="true" size={15} />.dataobject) rebuildchannelStatesByNumber,slots, and re-runclipProgrammefor every programme, when only the marker and live dots depend on the tick.useMemothe derived grid keyed onguide, keep channelStates in separate state, isolate the now layer in a small child.Checked and cleared
GetChannelGuideDataHandlerechoesstart/endverbatim (no clamping/snapping), somoveWindowpaging from the requested start is safe today. Latent coupling only if a bound is ever omitted (server substitutesUtcNow/+daysToBuild).stringon bothChannelGuideChannelResponseModel.numberandChannelStateResponseModel.channelNumber).fillerKind !== 'None'is sound (StringEnumConverter).shell.cssall exist in the design system; no convention violations.getChannelStatesis on an interval.setWindowStartand the effect'sload().Deferred items from the handoff (disabled group filter, discrete filler rows, no guide polling) are acknowledged and fine as-is.
Merge gate: fix #1 (blocker) and #2 (or explicitly defer #2 with the backend follow-up issue filed), CI green, then user consent.
Review response
Pushed review fixes in
a8d4979b0ceeb5b236860479d22821831fe77d45.Fixed:
programmeMatchesNowPlayingnow treats omittednowPlayingas absent withif (!nowPlaying), and the test suite includes an on-air channel fixture with nonowPlayingkey.jumpToNowcallsdefaultGuideWindowStart(now)./api/channels/statefailure degrades to no live-state data instead of failing the full guide load.Crosshair.Deferred non-blocking cleanups: shared poll-loop extraction and full-grid memoization.
Fresh local verification on the pushed head:
npm run typecheckpassednpm run lintpassednpm test -- --runpassed: 80 testsnpm run buildpassedGitea Actions run 512 is in progress for the new head SHA.