Compare commits

..
Author SHA1 Message Date
timothy a8d4979b0c fix(web): address Guide review feedback refs #85
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m48s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m32s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-05 23:06:08 +02:00
timothy 7d93688841 feat(web): add Guide EPG screen refs #85
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m24s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m3s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-05 22:31:30 +02:00
timothyandClaude Fable 5 d9ffbde79c 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>
2026-07-05 19:08:53 +02:00
timothy d63719ece9 Merge pull request 'feat(web): Libraries screen (#88)' (#128) from feat/88-libraries into main
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
2026-07-05 17:06:50 +00:00
timothyandClaude Fable 5 bee686c846 fix(web): libraries scan-flow review fixes for #88
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m24s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m3s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
- Normalize scan-status percent at the API boundary: the wire value is a
  0-1 fraction despite the field name; the bar previously showed ~0% for
  the whole scan (fixtures now use fractional wire values incl. 0/1)
- Fix the poll-start race: a triggered library enters a pending set that
  survives status pruning until seen active or a 3-tick grace window
  expires; the poll runs while pending ∪ active is nonempty (previously
  the queue-to-start lag could mean polling never started and the button
  re-enabled mid-scan); grace ticks also drain on persistent status-fetch
  errors so the UI can't stick
- Keep setState updaters pure: pending/grace bookkeeping hoisted into
  pruneGraceExpiredPending() outside the updater (StrictMode-safe)
- Wire the prototype's per-source Scan button (one POST per library,
  disabled while any of its libraries is pending/active)
- Honest bare-500 media-sources failure test (backend has no exception
  middleware); loadScanStatuses returns its promise; poll effect depends
  on derived hasActiveScans; synchronous ref double-submit guard;
  --radius-xs fallback 3px; StatusDot label on source cards (75 tests)

Review: PR #128 findings

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 19:01:05 +02:00
timothy 5c4acb9c7b feat(web): add Libraries screen refs #88
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m18s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-05 18:26:06 +02:00
timothyandClaude Fable 5 223ef70912 docs: advance ChicoryTV issue queue past #87 (PR #127); next prompt = #88 Libraries (Codex)
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m22s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m0s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m1s
Records the #127 review outcome (filler blocker + undefined token), pre-merge
fixes (c4190e3a), main a8dd88ef, baselines (368/493+1skip/63 web), and new
lessons (fixture-vs-actual-query fidelity, pure setState updaters, responsive
collapse, visible deferrals, honest tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 17:53:07 +02:00
timothy a8dd88ef73 Merge pull request 'feat(web): Playouts screen (#87)' (#127) from feat/87-playouts into main
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
2026-07-05 15:50:57 +00:00
timothyandClaude Fable 5 c4190e3a54 fix(web): playouts monitor review fixes for #87
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m28s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m9s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
- Filler unit: treat fillerKind 'None'/null as non-filler (real items no
  longer render the Filler badge); add the prototype's Show filler toggle
  wired to ?showFiller=true (the client previously never requested filler,
  making the filler UI dead code); fixtures now use real wire values
- Hoist selection fetch out of the setState updater (StrictMode-safe)
  via a baseRef snapshot; merge polled channel states functionally
  instead of clobbering with the captured snapshot
- Fix undefined --ctv-surface-1 token on the hero (→ --surface-card per
  prototype); correct --text-lg fallback; collapse the new playout grids
  in the 980px media block (no page-level horizontal scroll)
- Visible deferrals: disabled Reset / Schedule reset buttons with note
  (no per-playout reset endpoint yet)
- itemsLoading spinners on selection switch; time-window now-matching
  with title fallback; rail disabled while reset-all mutates
- Tests: mount-count delta isolation from the Dashboard prefetch;
  reset-all reframed as transport-failure (mock honors fixture status;
  backend is unconditionally 202); rail filter test; paging honesty
  ('shown of total' label); listPlayout() fixture matches the list DTO
  (63 tests)

Review: PR #127 findings

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 17:45:01 +02:00
timothy 1840ef83b1 feat(web): add Playouts monitor screen refs #87
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m1s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-05 17:10:30 +02:00
timothyandClaude Fable 5 c5021e28da docs: advance ChicoryTV issue queue past #86 (PR #125); next prompt = #87 Playouts (Codex)
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m9s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m48s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m23s
Records the #125 review outcome (refuted reorder blocker → #126), pre-merge
fixes (f18f2dc0), main 229f7cc1, baselines (368/493+1skip/52 web), and new
lessons (mount call-count assertions, no world-refetch, no list[0]-only UIs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 12:27:06 +02:00
timothy 229f7cc1b0 Merge pull request 'feat(web): Schedule editor screen (#86)' (#125) from feat/86-schedule-editor into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m18s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
2026-07-05 10:22:16 +00:00
timothyandClaude Fable 5 f18f2dc058 fix(web): schedule editor review fixes for #86
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m33s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m16s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
- Add a schedule selector (previously only the first schedule was ever
  shown); switching loads items only, with a pane-level spinner
- Fix duplicated mount effect that fetched the whole screen twice
- Add a collection picker to the add-item flow (was silently using the
  alphabetically-first collection with no way to correct it)
- Reorder now reconciles from the PUT response; add/delete refetch items
  only — pickers are no longer refetched on every mutation
- Guard reorder against double-submit via a ref; remove the inert Save
  button; document the widened item type (OpenAPI gap → #126)
- Inspector fidelity: Tail filler row + Keep multi-part together
- a11y: aria-current for the active item, draggable role description
- CSS: correct --text-md fallbacks; replace nonexistent --text-faint
  with --text-disabled in new blocks
- Tests: single-fetch-on-mount assertion, chosen-collection POST body,
  picker-failure path, collection() fixture with all required fields;
  drop unreachable mock branch (52 tests)

Review: PR #125 findings; refs #126

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 12:16:53 +02:00
timothy 2e58fa1815 feat(web): add schedule editor screen
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m44s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-05 11:43:04 +02:00
timothyandClaude Fable 5 8f792d964e docs: advance ChicoryTV issue queue past #84 (PR #124); next prompt = #86 Schedule editor (Codex)
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m23s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m2s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m28s
Records the new Fable-prompts/Fable-reviews process, PR #124 review outcome
and pre-merge fixes (ecf6cf75), main 7ee6e987, baselines (368/493+1skip/39 web).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 11:29:53 +02:00
timothy 7ee6e9870c Merge pull request 'feat(web): Channels screen (#84)' (#124) from feat/84-channels-screen into main
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
2026-07-05 09:26:22 +00:00
timothyandClaude Fable 5 ecf6cf7513 fix(web): scope channel bulk ops to visible rows, review fixes for #84
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m58s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
- 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>
2026-07-05 11:20:32 +02:00
timothy 15c6cdd086 feat(web): add Channels screen
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m26s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m55s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
refs #84
2026-07-05 10:41:29 +02:00
timothyandClaude Fable 5 39d76080de docs: advance ChicoryTV issue queue past #109 (PR #123); next prompt = #84 Channels screen (Codex)
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m15s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m56s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m21s
Records: main=4ffd777b, review-fix cycle lesson (fixture values must come from
backend serializers), health no-poll policy, baselines 368/493 + 31 web tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 21:23:24 +02:00
timothy 4ffd777b49 Merge pull request 'feat(web): Dashboard real data sources (#109)' (#123) from feat/109-dashboard-data into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m42s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m21s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m31s
2026-07-04 19:20:38 +00:00
timothyandClaude Fable 5 e6197d63ff fix(web): dashboard review fixes — real health status contract, dedupe version fetch, refresh guard (#109)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m3s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m53s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
- Match the backend's exact health status contract ('pass'|'fail'|'warn'|'info')
  instead of fictional strings; 'info' now renders as a neutral/idle presentation
  and is excluded from failing/warning counts in summarizeHealth.
- Drop the redundant /api/version fetch from getDashboardData/DashboardData;
  SidebarVersion's useDashboardVersionQuery remains the single source.
- Disable "Refresh health" (via Button's loading prop) while a health request
  is in flight to prevent concurrent double-click requests.
- Add the active-flag unmount guard to useDashboardHealthQuery for consistency
  with useDashboardQuery/useChannelsQuery.
- Remove dead .ctv-activity-*/.ctv-release-* CSS left over from the removed
  activity feed/release notes UI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 21:14:44 +02:00
timothy 6a9f1e41a4 fix(web): satisfy dashboard lint checks
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m48s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
refs #109
2026-07-04 20:55:38 +02:00
timothy a6056f73d8 feat(web): wire dashboard to real API data
Build ErsatzTV Image / Build & test (.NET) (pull_request) Failing after 41s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 3m41s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
refs #109
2026-07-04 20:52:32 +02:00
timothy da7636fdad docs: update ChicoryTV queue after PR 121
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m11s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m4s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m46s
2026-07-04 20:37:29 +02:00
timothy cc6ffcb8c4 Merge pull request 'feat(core): track active sessions for direct streaming modes (#99)' (#121) from feat/99-session-tracking into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m21s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m0s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m35s
Merge pull request 'feat(core): track active sessions for direct streaming modes (#99)' (#121) from feat/99-session-tracking into main
2026-07-04 18:24:15 +00:00
11 changed files with 7541 additions and 371 deletions
+140 -112
View File
@@ -5,134 +5,162 @@ UPDATING THIS FILE in place (rewrite the state section and the queue for the nex
always holds the current handoff. History: created 2026-07-02 after the plan audit (#59 epic)
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 (#105/#108/#100+#101+#107+#110/#103+#106/#104/#111/#102 PRs #113#119). The
MERGE PASS (2026-07-04) then landed the whole SPA stack on main via PR #120.
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),
#88 Libraries (PR #128).
**Session state (2026-07-04, post-merge-pass)**: main = 1b6de047 (PR #120 "ChicoryTV SPA
foundation (#59 stack)"): design-system/ + web/ SPA foundation (#78#83) + backend #96/#98/#97
all on main; #97 closed (its universal-onAir remainder = #99). Reconciliation was ONE merge
commit (06355b75) + regen (7bd69439) + nullable-DTO fix (8d89ab16); Fable review: no dropped
work, no duplicated routes/tests. Test baselines on main: ErsatzTV.Tests **364**, Core.Tests
**488** (+1 skip); web/: `npm run typecheck` + `build` clean (outputs to ErsatzTV/wwwroot/app/,
gitignored; SPA types = web/src/api/generated/v1.d.ts, regen via `npm run generate:api`).
- **Main image build**: post-#120 run 482 FAILED in "Build & push image" only —
`node:22-noble` doesn't exist (tests+migrations green). Hotfix **PR #122**
(node:22-bookworm-slim, one line) is CI-green and awaiting "merge" consent. After merging,
confirm the next main run is fully green INCLUDING the image job before deploying anything.
- **PR #121 (#99, Codex, direct-stream session tracking)**: OPEN, CI green, base main
(pre-#120 — needs a main merge/rebase, confirmed conflict-free with #120). Fable review =
FIX-FIRST: (1) Register/Remove race in DirectStreamSessionTracker can orphan a live
session's registration; (2) HEAD probes register phantom viewers on ts-legacy. Round-2
Codex prompt (findings verbatim) was handed to the user; full text also in the PR #121
review comment. #99 stays open (final /api/channels/state wiring comes after #121 merges).
- Worktrees: .worktrees/issue-99-session-tracking (Codex, keep); .worktrees/merge-pass
(stack landed — REMOVE after this doc lands); issue-97/feat-102 removed. The main checkout
still sits on docs/59-ui-redesign-brief — fully merged now, safe to switch to main.
**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 +
design-system agents; plus a fork verification pass over any fix diff). Codex implements.
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 (standing consent for this
stretch: merge when reviewers are happy and tests pass).
**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):
- The image-build job runs ONLY on main pushes — PR CI cannot catch Dockerfile breakage.
Any Dockerfile change: verify base tags exist (hub.docker.com API) and, where docker is
available, `docker build --target <stage>` locally before merging. (`node:22-noble` cost
a red main run; node official images have no `-noble` variant.)
- DTO records in ErsatzTV.Core/Api MUST get file-scoped `#nullable enable` — else the spec
emits `["null","string"]` unions and SPA types get needless `| null`. #96/#98 DTOs were
retrofitted in 8d89ab16; ChannelStateResponseModel complied from the start. NOTE:
ErsatzTV.Application has NO nullable context `string?` there trips CS8632.
- NSubstitute + ConfigElementKey: `ConfigElementKey.X` is a fresh instance per access —
stub with `Arg.Any<ConfigElementKey>()` disambiguated by the generic `<T>`.
- `Option<T>.ToNullable()` doesn't exist here; use `MatchUnsafe(v => (T?)v, () => null)`.
- ./scripts/update-openapi.sh only runs `-t:GenerateOpenApiDocuments` — do a normal
`dotnet build ErsatzTV/ErsatzTV.csproj` FIRST or it fails with MSB3680.
- Id-taking child-collection GETs 404 on unknown parent via pre-check + ApiResults
(precedent: ScheduleController.GetItems) + an OpenApiErrorResponseContractTests entry.
- Backlog nits (unfiled): unclamped pageSize on paged endpoints; PlayoutController
Create/Delete lack route Name=; PlayoutController.GetItems existence pre-check is heavy;
>30 MB uploads return bare Kestrel 413; artwork content-type trusted from client (#66);
schedule duration estimator: expression/zero Count → null estimate, materializes each
referenced collection per GET. From #102 (also on #85): GET /api/guide runs the full
21-include eager-load per channel per request — trim projection or add caching before the
EPG grid polls it; `fillerKind` is almost always `None` (projector merges filler into
adjacent programmes) — discrete filler entries need a JSON-only projection mode.
- 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; 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 — #109: Dashboard follow-up — replace stubbed data with real sources
# PROMPT FOR CODEX — #85: Guide / EPG screen (channels × time grid)
You are Codex working solo in /Users/timothy/ersatztv (ErsatzTV fork; the React SPA
"ChicoryTV" lives in web/ — Vite + TS, typed client in web/src/api/, generated types
web/src/api/generated/v1.d.ts). Read CLAUDE.md and docs/contributing.md first; follow the
SPA foundation's existing patterns (#78#83) — match, don't invent.
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), Playouts (PR #127), Libraries (PR #128) — match, don't
invent.
HARD CONSTRAINTS:
- Work in a NEW git worktree: `git worktree add .worktrees/issue-109-dashboard -b
feat/109-dashboard-data origin/main` (branch from origin/main; never touch the main
checkout or other .worktrees/*).
- Max 23 concurrent builds machine-wide; keep to ONE dotnet build at a time here.
- 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 (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.
- Do NOT merge anything. Open the PR, get CI green, and stop.
- Backend scope guard: this is a FRONTEND issue. If a widget needs data no endpoint
provides, stub that widget's source cleanly and note it in the PR — do not add endpoints.
- FRONTEND-only: no new endpoints, no ErsatzTV.Core/Application changes. Missing data =
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; 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!).
## Context
- main = post-#120/#122: full backend read API + SPA foundation. Baselines: ErsatzTV.Tests
364, Core.Tests 488(+1 skip); web `npm run typecheck` + `npm run build` clean.
Gitea: http://192.168.1.95:3000/timothy/ersatztv (basic auth timothy:ded89Lm4).
- Issue #109: the Dashboard screen (#83) shipped with stubbed data. Replace the stubs with
the real sources now on main: /api/health (system health), /api/channels +
/api/channels/state (channel counts / on-air), /api/playouts (playout status),
/api/version. Read the issue body for the agreed widget list, and locate the stub layer
from #83 in web/src/ before writing anything.
- CRITICAL (#108 lesson): GET /api/health re-runs all ~14 checks per request (only the
warn/error summary is cached) — fetch health ON DEMAND (mount + manual refresh), NOT on a
fast poll. If live health is wanted, that's a backend TTL-cache follow-up issue, not this
PR. Other widgets may poll gently (≥30s) if the design calls for it.
- Note: /api/channels/state `onAir` is segmenter-session-based until #99/PR #121 lands —
render it as-is; no workarounds.
## Task
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 = 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/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 #109 with findings + approach before coding.
2. Implement: swap stubs for typed client calls (follow #81's client/query patterns);
loading/error/empty states per the design-system components. No `any` casts around
generated types.
3. Verify: `cd web && npm ci && npm run typecheck && npm run build`; plus
`dotnet build ErsatzTV.sln` and TZ=UTC dotnet test of ErsatzTV.Tests + Core.Tests
(sequentially) to prove no backend regression (expect 364 / 488+1skip). If feasible, run
the SPA against a live backend (dotnet run + vite dev proxy) and sanity-check the
Dashboard renders real data.
4. Push, open PR → main: `feat(web): Dashboard real data sources (#109)`, body lists each
widget → endpoint mapping and the health fetch policy; `closes #109`. Poll CI by head SHA
(/api/v1/repos/timothy/ersatztv/commits/<sha>/status) until green. Do not merge.
5. Comment progress on #109 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): 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 diff READ-ONLY in one Fable pass (endpoint usage correctness, health
polling discipline, type safety, design-system adherence, scope).
- Classify findings: NITS Fable may fix directly on the branch; SUBSTANTIAL issues (wrong
data contracts, polling violations, state-management flaws) go back to Codex — Fable
composes a follow-up Codex prompt with the findings verbatim.
- After review: comment the verdict on the PR and #109; on approval + user "merge" consent,
merge, verify main's post-merge run (image job included!), then update THIS handoff file
(pop #109, next prompt = #84 Channels screen for Codex, record PR number + main SHA +
baselines) and push it to main.
Include: PR number, branch, head SHA, files changed, widget→endpoint mapping, test/web
results, anything deferred or uncertain.
Print a fenced handoff prompt addressed to Claude (Fable) asking it to review the PR
READ-ONLY (Fable runs its own review subagents). Include: PR number, branch, head SHA, base,
files changed, the mapping table, verification commands + results, deferred/uncertain list,
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 #85, next prompt = #89 Channel Builder, record PR + main SHA + baselines),
and pushes it to main.
---
## Issue queue (work top-down)
0. HOUSEKEEPING (carry into next session): merge PR #122 if still open (one-word consent),
verify main run fully green incl. image job; remove .worktrees/merge-pass; Codex round-2
on PR #121 (#99) — prompt already with the user — then Fable re-review of the delta.
1. #109 Dashboard follow-up ← CODEX PROMPT above (review/merge/doc-update falls to the
Fable session that Codex's end-of-run handoff spawns).
2. #84 Channels screen (deps #96/#98/#97 on main; live treatment via /api/channels/state —
note its onAir is segmenter-only until #99/#121 lands and gets wired).
3. UX conversion order: #86 Schedule editor (#111 durations) → #87 Playouts → #88 Libraries
#85 EPG (#102 JSON guide; mind fillerKind + per-request-cost notes above) → #89 Channel
Builder (#104 artwork upload; also needs #62: #63/#64/#65) → #93 Settings → #90 rebrand
#91 cutover.
Cross-refs: #99/PR #121 in flight (see state). Done this pass: MERGE PASS → PR #120 (closed
#97; #78#83/#96/#98 validated on main), hotfix PR #122 (Dockerfile node tag). Languages-list
endpoint from #105 still unimplemented — open a follow-up when #86/#89 need it;
filler/watermark lists return DB order — SPA should client-sort.
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: #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.
+2220 -27
View File
File diff suppressed because it is too large Load Diff
+2255 -150
View File
File diff suppressed because it is too large Load Diff
+169 -1
View File
@@ -1,8 +1,176 @@
import { request } from './client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { ApiError, request } from './client';
import type { components } from './generated/v1';
export type ChannelSummary = components['schemas']['ChannelResponseModel'];
export type ChannelState = components['schemas']['ChannelStateResponseModel'];
export type BulkRenumberChannelsRequest = components['schemas']['BulkRenumberChannelsRequest'];
export type BulkMoveChannelsToGroupRequest = components['schemas']['BulkMoveChannelsToGroupRequest'];
export type BulkDeleteChannelsRequest = components['schemas']['BulkDeleteChannelsRequest'];
export interface ChannelsScreenData {
channels: ChannelSummary[];
channelStates: ChannelState[];
}
export type ChannelsScreenQueryState =
| { data: ChannelsScreenData; error: null; refresh: () => void; status: 'success' }
| { data: null; error: string; refresh: () => void; status: 'error' }
| { data: null; error: null; refresh: () => void; status: 'loading' };
type ChannelsScreenState =
| { data: ChannelsScreenData; error: null; status: 'success' }
| { data: null; error: string; status: 'error' }
| { data: null; error: null; status: 'loading' };
export function getChannels(): Promise<ChannelSummary[]> {
return request<ChannelSummary[]>('/api/channels');
}
export function getChannelStates(): Promise<ChannelState[]> {
return request<ChannelState[]>('/api/channels/state');
}
export async function getChannelsScreenData(): Promise<ChannelsScreenData> {
const [channels, channelStates] = await Promise.all([
getChannels(),
getChannelStates()
]);
return { channels, channelStates };
}
export function bulkRenumberChannels(body: BulkRenumberChannelsRequest): Promise<void> {
return request<void>('/api/channels/bulk/renumber', {
body,
method: 'POST'
});
}
export function bulkMoveChannelsToGroup(body: BulkMoveChannelsToGroupRequest): Promise<void> {
return request<void>('/api/channels/bulk/group', {
body,
method: 'POST'
});
}
export function bulkDeleteChannels(body: BulkDeleteChannelsRequest): Promise<void> {
return request<void>('/api/channels/bulk/delete', {
body,
method: 'POST'
});
}
export function deleteChannel(channelId: number): Promise<void> {
return request<void>(`/api/channels/${channelId}`, {
method: 'DELETE'
});
}
export function useChannelsScreenQuery(pollMs = 30000): ChannelsScreenQueryState {
const [state, setState] = useState<ChannelsScreenState>({
data: null,
error: null,
status: 'loading'
});
const activeRef = useRef(true);
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
const load = useCallback((showLoading = true) => {
if (showLoading) {
setState({ data: null, error: null, status: 'loading' });
}
getChannelsScreenData()
.then((data) => {
if (activeRef.current) {
setState({ data, error: null, status: 'success' });
}
})
.catch((error: unknown) => {
if (activeRef.current) {
setState({ data: null, error: messageFromError(error), status: 'error' });
}
});
}, []);
const loadChannelStates = useCallback(() => {
getChannelStates()
.then((channelStates) => {
if (activeRef.current) {
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: { ...current.data, channelStates },
error: null,
status: 'success'
};
});
}
})
.catch(() => {
// Keep the current table visible on background polling failures.
});
}, []);
useEffect(() => {
const loadForEffect = () => {
getChannelsScreenData()
.then((data) => {
if (activeRef.current) {
setState({ data, error: null, status: 'success' });
}
})
.catch((error: unknown) => {
if (activeRef.current) {
setState({ data: null, error: messageFromError(error), status: 'error' });
}
});
};
loadForEffect();
const intervalId = window.setInterval(() => {
loadChannelStates();
}, pollMs);
return () => {
window.clearInterval(intervalId);
};
}, [loadChannelStates, pollMs]);
const refresh = useCallback(() => {
load();
}, [load]);
if (state.status === 'success') {
return { data: state.data, error: null, refresh, status: 'success' };
}
if (state.status === 'error') {
return { data: null, error: state.error, refresh, status: 'error' };
}
return { data: null, error: null, refresh, status: 'loading' };
}
export function messageFromError(error: unknown): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return 'Unable to load channels';
}
+119 -16
View File
@@ -1,36 +1,58 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { ApiError, request } from './client';
import type { components } from './generated/v1';
export type DashboardChannel = components['schemas']['ChannelResponseModel'];
type DashboardCollection = components['schemas']['MediaCollectionViewModel'];
type DashboardSchedule = components['schemas']['ProgramScheduleViewModel'];
type DashboardSession = components['schemas']['HlsSessionModel'];
type DashboardVersion = components['schemas']['CombinedVersion'];
export type DashboardChannelState = components['schemas']['ChannelStateResponseModel'];
type DashboardHealthCheck = components['schemas']['HealthCheckResponseModel'];
type DashboardMediaSource = components['schemas']['MediaSourceResponseModel'];
type DashboardPlayouts = components['schemas']['PagedPlayoutsResponseModel'];
export type DashboardVersion = components['schemas']['CombinedVersion'];
export interface DashboardData {
channels: DashboardChannel[];
collections: DashboardCollection[];
schedules: DashboardSchedule[];
sessions: DashboardSession[];
version: DashboardVersion;
channelStates: DashboardChannelState[];
mediaSources: DashboardMediaSource[];
playouts: DashboardPlayouts;
}
type DashboardQueryState =
export type DashboardQueryState =
| { data: DashboardData; error: null; status: 'success' }
| { data: null; error: string; status: 'error' }
| { data: null; error: null; status: 'loading' };
export type DashboardHealthQueryState =
| { checks: DashboardHealthCheck[]; error: null; refresh: () => void; status: 'success' }
| { checks: null; error: string; refresh: () => void; status: 'error' }
| { checks: null; error: null; refresh: () => void; status: 'loading' };
export type DashboardVersionQueryState =
| { error: null; status: 'success'; version: DashboardVersion }
| { error: string; status: 'error'; version: null }
| { error: null; status: 'loading'; version: null };
type DashboardHealthState =
| { checks: DashboardHealthCheck[]; error: null; status: 'success' }
| { checks: null; error: string; status: 'error' }
| { checks: null; error: null; status: 'loading' };
export async function getDashboardData(): Promise<DashboardData> {
const [channels, collections, schedules, sessions, version] = await Promise.all([
const [channels, channelStates, mediaSources, playouts] = await Promise.all([
request<DashboardChannel[]>('/api/channels'),
request<DashboardCollection[]>('/api/collections'),
request<DashboardSchedule[]>('/api/schedules'),
request<DashboardSession[]>('/api/sessions'),
request<DashboardVersion>('/api/version')
request<DashboardChannelState[]>('/api/channels/state'),
request<DashboardMediaSource[]>('/api/media-sources'),
request<DashboardPlayouts>('/api/playouts')
]);
return { channels, collections, schedules, sessions, version };
return { channels, channelStates, mediaSources, playouts };
}
export function getDashboardHealth(): Promise<DashboardHealthCheck[]> {
return request<DashboardHealthCheck[]>('/api/health');
}
export function getDashboardVersion(): Promise<DashboardVersion> {
return request<DashboardVersion>('/api/version');
}
export function useDashboardQuery(): DashboardQueryState {
@@ -63,6 +85,87 @@ export function useDashboardQuery(): DashboardQueryState {
return state;
}
export function useDashboardHealthQuery(): DashboardHealthQueryState {
const [state, setState] = useState<DashboardHealthState>({
checks: null,
error: null,
status: 'loading'
});
const activeRef = useRef(true);
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
const loadHealth = useCallback(() => {
getDashboardHealth()
.then((checks) => {
if (activeRef.current) {
setState({ checks, error: null, status: 'success' });
}
})
.catch((error: unknown) => {
if (activeRef.current) {
setState({ checks: null, error: messageFromError(error), status: 'error' });
}
});
}, []);
const refresh = useCallback(() => {
setState({ checks: null, error: null, status: 'loading' });
loadHealth();
}, [loadHealth]);
useEffect(() => {
loadHealth();
}, [loadHealth]);
if (state.status === 'success') {
return { checks: state.checks, error: null, refresh, status: 'success' };
}
if (state.status === 'error') {
return { checks: null, error: state.error, refresh, status: 'error' };
}
return { checks: null, error: null, refresh, status: 'loading' };
}
export function useDashboardVersionQuery(): DashboardVersionQueryState {
const [state, setState] = useState<DashboardVersionQueryState>({
error: null,
status: 'loading',
version: null
});
useEffect(() => {
let active = true;
getDashboardVersion()
.then((version) => {
if (active) {
setState({ error: null, status: 'success', version });
}
})
.catch((error: unknown) => {
if (active) {
setState({ error: messageFromError(error), status: 'error', version: null });
}
});
return () => {
active = false;
};
}, []);
return state;
}
function messageFromError(error: unknown): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
+159
View File
@@ -0,0 +1,159 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { getChannelStates, messageFromError, type ChannelState } from './channels';
import { request } from './client';
import type { components } from './generated/v1';
export type ChannelGuide = components['schemas']['ChannelGuideResponseModel'];
export type ChannelGuideChannel = components['schemas']['ChannelGuideChannelResponseModel'];
export type ChannelGuideProgramme = components['schemas']['ChannelGuideProgrammeResponseModel'];
export interface GuideScreenData {
channelStates: ChannelState[];
guide: ChannelGuide;
}
export type GuideScreenQueryState =
| {
data: GuideScreenData;
error: null;
refresh: () => void;
setWindowStart: (start: Date) => void;
status: 'success';
windowStart: Date;
windowEnd: Date;
}
| { data: null; error: string; refresh: () => void; status: 'error'; windowStart: Date; windowEnd: Date }
| { data: null; error: null; refresh: () => void; status: 'loading'; windowStart: Date; windowEnd: Date };
type GuideScreenState =
| { data: GuideScreenData; error: null; status: 'success' }
| { data: null; error: string; status: 'error' }
| { data: null; error: null; status: 'loading' };
export const GUIDE_PAST_MS = 60 * 60 * 1000;
export const GUIDE_WINDOW_MS = 13 * 60 * 60 * 1000;
export function defaultGuideWindowStart(now = new Date()): Date {
return new Date(now.getTime() - GUIDE_PAST_MS);
}
export function getGuide(start: Date, end: Date): Promise<ChannelGuide> {
const query = new URLSearchParams({
start: start.toISOString(),
end: end.toISOString()
});
return request<ChannelGuide>(`/api/guide?${query.toString()}`);
}
export async function getGuideScreenData(start: Date, end: Date): Promise<GuideScreenData> {
const guide = await getGuide(start, end);
const channelStates = await getChannelStates().catch(() => []);
return { channelStates, guide };
}
export function useGuideScreenQuery(channelStatePollMs = 30000): GuideScreenQueryState {
const [windowStart, setWindowStartState] = useState(() => defaultGuideWindowStart());
const windowStartRef = useRef(windowStart);
const [state, setState] = useState<GuideScreenState>({
data: null,
error: null,
status: 'loading'
});
const activeRef = useRef(true);
const requestIdRef = useRef(0);
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
const windowEnd = new Date(windowStart.getTime() + GUIDE_WINDOW_MS);
const load = useCallback((start = windowStartRef.current) => {
const end = new Date(start.getTime() + GUIDE_WINDOW_MS);
const requestId = requestIdRef.current + 1;
requestIdRef.current = requestId;
getGuideScreenData(start, end)
.then((data) => {
if (
activeRef.current &&
requestIdRef.current === requestId &&
windowStartRef.current.getTime() === start.getTime()
) {
setState({ data, error: null, status: 'success' });
}
})
.catch((error: unknown) => {
if (
activeRef.current &&
requestIdRef.current === requestId &&
windowStartRef.current.getTime() === start.getTime()
) {
setState({ data: null, error: messageFromError(error), status: 'error' });
}
});
}, []);
const loadChannelStates = useCallback(() => {
getChannelStates()
.then((channelStates) => {
if (!activeRef.current) {
return;
}
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: { ...current.data, channelStates },
error: null,
status: 'success'
};
});
})
.catch(() => {
// Keep the guide visible when the optional live-state refresh fails.
});
}, []);
useEffect(() => {
load(windowStart);
}, [load, windowStart]);
useEffect(() => {
const intervalId = window.setInterval(loadChannelStates, Math.max(channelStatePollMs, 30000));
return () => {
window.clearInterval(intervalId);
};
}, [channelStatePollMs, loadChannelStates]);
const setWindowStart = useCallback((start: Date) => {
windowStartRef.current = start;
setState({ data: null, error: null, status: 'loading' });
setWindowStartState(start);
}, []);
const refresh = useCallback(() => {
setState({ data: null, error: null, status: 'loading' });
load(windowStartRef.current);
}, [load]);
if (state.status === 'success') {
return { data: state.data, error: null, refresh, setWindowStart, status: 'success', windowEnd, windowStart };
}
if (state.status === 'error') {
return { data: null, error: state.error, refresh, status: 'error', windowEnd, windowStart };
}
return { data: null, error: null, refresh, status: 'loading', windowEnd, windowStart };
}
+4
View File
@@ -2,4 +2,8 @@ export * from './auth';
export * from './channels';
export * from './client';
export * from './dashboard';
export * from './guide';
export * from './libraries';
export * from './playouts';
export * from './schedules';
export * from './useChannelsQuery';
+337
View File
@@ -0,0 +1,337 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ApiError, request } from './client';
import type { components } from './generated/v1';
export type MediaSource = components['schemas']['MediaSourceResponseModel'];
export type MediaSourceLibrary = components['schemas']['MediaSourceLibraryResponseModel'];
export type LibraryScanStatus = components['schemas']['LibraryScanStatusResponseModel'];
export interface LibrariesScreenData {
scanStatuses: LibraryScanStatus[];
sources: MediaSource[];
}
export type LibrariesScreenQueryState =
| {
data: LibrariesScreenData;
error: string | null;
refresh: () => void;
scanLibrary: (libraryId: number) => Promise<void>;
scanningLibraryIds: Set<number>;
status: 'success';
}
| { data: null; error: string; refresh: () => void; status: 'error' }
| { data: null; error: null; refresh: () => void; status: 'loading' };
type LibrariesScreenState =
| { data: LibrariesScreenData; error: string | null; pendingLibraryIds: Set<number>; status: 'success' }
| { data: null; error: string; status: 'error' }
| { data: null; error: null; status: 'loading' };
// A trigger'd scan disappears from the button-disabled set once either: it has been
// observed at least once in scan-status (promoted to "active"), or this many poll
// ticks pass without ever appearing (the scanner never picked it up / it failed silently).
const PENDING_GRACE_TICKS = 3;
export function getMediaSources(): Promise<MediaSource[]> {
return request<MediaSource[]>('/api/media-sources');
}
export function getLibraryScanStatus(): Promise<LibraryScanStatus[]> {
// Wire contract: `percent` is a 0-1 FRACTION despite the field name - the backend
// never multiplies by 100 (known backend wart, tracked in the handoff backlog).
// Normalize to a 0-100 percentage once, here at the API boundary, so every
// consumer (ProgressBar, percent labels) works in ordinary percentage terms.
return request<LibraryScanStatus[]>('/api/libraries/scan-status').then((scanStatuses) =>
scanStatuses.map((scanStatus) => ({ ...scanStatus, percent: scanStatus.percent * 100 }))
);
}
export function scanLibrary(libraryId: number): Promise<void> {
return request<void>(`/api/libraries/${libraryId}/scan`, { method: 'POST' });
}
// Pure: computes the surviving pending-id set for one poll tick (success or failure) and
// mutates the grace-ticks map in place (delete on promote/expire, set on decrement) -
// callers must still write pendingIdsRef.current with the returned set themselves, and
// must call this exactly once per tick before that write to keep it a single, ref-free
// computation that's safe to run under StrictMode double-invocation.
function pruneGraceExpiredPending(
pendingIds: Set<number>,
graceTicks: Map<number, number>,
isSeenActive: (libraryId: number) => boolean
): Set<number> {
const nextPending = new Set<number>();
pendingIds.forEach((libraryId) => {
if (isSeenActive(libraryId)) {
// Seen active at least once - normal active/inactive pruning takes over.
graceTicks.delete(libraryId);
return;
}
const ticksRemaining = (graceTicks.get(libraryId) ?? PENDING_GRACE_TICKS) - 1;
if (ticksRemaining <= 0) {
// Grace window expired without ever appearing in scan-status - give up on it.
graceTicks.delete(libraryId);
return;
}
graceTicks.set(libraryId, ticksRemaining);
nextPending.add(libraryId);
});
return nextPending;
}
export function useLibrariesScreenQuery(pollMs = 10000): LibrariesScreenQueryState {
const [state, setState] = useState<LibrariesScreenState>({
data: null,
error: null,
status: 'loading'
});
const activeRef = useRef(true);
const hadScanInProgressRef = useRef(false);
// Mirrors of the corresponding state, kept in sync synchronously so triggerScan can
// guard against double-submits without waiting for a render.
const pendingIdsRef = useRef<Set<number>>(new Set());
const activeIdsRef = useRef<Set<number>>(new Set());
const pendingGraceTicksRef = useRef<Map<number, number>>(new Map());
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
const loadSources = useCallback(() => {
getMediaSources()
.then((sources) => {
if (!activeRef.current) {
return;
}
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: { scanStatuses: current.data.scanStatuses, sources },
error: null,
pendingLibraryIds: current.pendingLibraryIds,
status: 'success'
};
});
})
.catch(() => {
// A completion refresh should not hide the screen if the source reload fails.
});
}, []);
const loadScanStatuses = useCallback(() => {
return getLibraryScanStatus()
.then((scanStatuses) => {
if (!activeRef.current) {
return;
}
const activeScanIds = new Set(scanStatuses.map((scan) => scan.libraryId));
activeIdsRef.current = activeScanIds;
const hadScanInProgress = hadScanInProgressRef.current;
hadScanInProgressRef.current = scanStatuses.length > 0;
// Compute the pruned/promoted pending set - and write the grace-tick and pending
// refs - OUTSIDE the setState updater. Updaters must be pure: StrictMode double-
// invokes them (which would double-decrement grace ticks) and concurrent rendering
// may invoke-and-discard one. Mirrors how activeIdsRef is written above.
const nextPending = pruneGraceExpiredPending(pendingIdsRef.current, pendingGraceTicksRef.current, (libraryId) =>
activeScanIds.has(libraryId)
);
pendingIdsRef.current = nextPending;
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: { scanStatuses, sources: current.data.sources },
error: null,
pendingLibraryIds: nextPending,
status: 'success'
};
});
if (hadScanInProgress && scanStatuses.length === 0) {
loadSources();
}
})
.catch(() => {
if (!activeRef.current) {
return;
}
// A status-fetch failure never promotes a pending id to active, so it never
// survives via "seen active" - it just burns down the same grace budget as a
// successful poll that never saw it. This keeps a persistently-erroring endpoint
// from leaving the scan button disabled and the poll interval running forever.
// Active-scan state (activeIdsRef / scanStatuses) is left untouched: a transient
// failure must not kill an in-progress scan's UI.
const nextPending = pruneGraceExpiredPending(pendingIdsRef.current, pendingGraceTicksRef.current, () => false);
pendingIdsRef.current = nextPending;
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: current.data,
error: current.error,
pendingLibraryIds: nextPending,
status: 'success'
};
});
});
}, [loadSources]);
const load = useCallback(() => {
Promise.all([getMediaSources(), getLibraryScanStatus()])
.then(([sources, scanStatuses]) => {
if (!activeRef.current) {
return;
}
hadScanInProgressRef.current = scanStatuses.length > 0;
pendingIdsRef.current = new Set();
activeIdsRef.current = new Set(scanStatuses.map((scan) => scan.libraryId));
pendingGraceTicksRef.current = new Map();
setState({
data: { scanStatuses, sources },
error: null,
pendingLibraryIds: new Set(),
status: 'success'
});
})
.catch((error: unknown) => {
if (activeRef.current) {
setState({ data: null, error: messageFromLibrariesError(error, 'Unable to load libraries'), status: 'error' });
}
});
}, []);
useEffect(() => {
load();
}, [load]);
const hasActiveScans =
state.status === 'success' && (state.pendingLibraryIds.size > 0 || state.data.scanStatuses.length > 0);
useEffect(() => {
if (!hasActiveScans) {
return undefined;
}
const intervalId = window.setInterval(loadScanStatuses, Math.max(pollMs, 10000));
return () => {
window.clearInterval(intervalId);
};
}, [hasActiveScans, loadScanStatuses, pollMs]);
const refresh = useCallback(() => {
setState({ data: null, error: null, status: 'loading' });
load();
}, [load]);
const triggerScan = useCallback((libraryId: number): Promise<void> => {
if (pendingIdsRef.current.has(libraryId) || activeIdsRef.current.has(libraryId)) {
// Already pending or active - ignore the duplicate submission.
return Promise.resolve();
}
pendingIdsRef.current = new Set(pendingIdsRef.current).add(libraryId);
pendingGraceTicksRef.current.set(libraryId, PENDING_GRACE_TICKS);
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: current.data,
error: null,
pendingLibraryIds: new Set(current.pendingLibraryIds).add(libraryId),
status: 'success'
};
});
return scanLibrary(libraryId)
.then(() => loadScanStatuses())
.catch((error: unknown) => {
const pendingIds = new Set(pendingIdsRef.current);
pendingIds.delete(libraryId);
pendingIdsRef.current = pendingIds;
pendingGraceTicksRef.current.delete(libraryId);
if (!activeRef.current) {
return;
}
setState((current) => {
if (current.status !== 'success') {
return current;
}
const pendingLibraryIds = new Set(current.pendingLibraryIds);
pendingLibraryIds.delete(libraryId);
return {
data: current.data,
error: messageFromLibrariesError(error, 'Unable to scan library'),
pendingLibraryIds,
status: 'success'
};
});
});
}, [loadScanStatuses]);
if (state.status === 'success') {
const activeLibraryIds = new Set(state.data.scanStatuses.map((scan) => scan.libraryId));
const scanningLibraryIds = new Set([...state.pendingLibraryIds, ...activeLibraryIds]);
return {
data: state.data,
error: state.error,
refresh,
scanLibrary: triggerScan,
scanningLibraryIds,
status: 'success'
};
}
if (state.status === 'error') {
return { data: null, error: state.error, refresh, status: 'error' };
}
return { data: null, error: null, refresh, status: 'loading' };
}
function messageFromLibrariesError(error: unknown, fallback = 'Unable to load libraries'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}
+321
View File
@@ -0,0 +1,321 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ApiError, request } from './client';
import type { components } from './generated/v1';
export type PlayoutSummary = components['schemas']['PlayoutListItemResponseModel'];
export type PlayoutDetail = components['schemas']['PlayoutResponseModel'];
export type PlayoutItem = components['schemas']['PlayoutItemResponseModel'];
export type PlayoutsPage = components['schemas']['PagedPlayoutsResponseModel'];
export type PlayoutItemsPage = components['schemas']['PagedPlayoutItemsResponseModel'];
export type PlayoutChannelState = components['schemas']['ChannelStateResponseModel'];
export interface PlayoutsScreenData {
channelStates: PlayoutChannelState[];
items: PlayoutItem[];
playout: PlayoutDetail | null;
playouts: PlayoutSummary[];
selectedPlayoutId: number | null;
totalCount: number;
warningsCount: number;
}
type PlayoutsScreenBase = Omit<PlayoutsScreenData, 'items' | 'playout'>;
export type PlayoutsScreenQueryState =
| {
data: PlayoutsScreenData;
error: null;
itemsLoading: boolean;
refresh: () => void;
setActivePlayout: (playoutId: number) => void;
setShowFiller: (showFiller: boolean) => void;
showFiller: boolean;
status: 'success';
}
| { data: null; error: string; refresh: () => void; status: 'error' }
| { data: null; error: null; refresh: () => void; status: 'loading' };
type PlayoutsScreenState =
| { data: PlayoutsScreenData; error: null; itemsLoading: boolean; status: 'success' }
| { data: null; error: string; status: 'error' }
| { data: null; error: null; status: 'loading' };
export function getPlayouts(): Promise<PlayoutsPage> {
return request<PlayoutsPage>('/api/playouts');
}
export function getPlayout(playoutId: number): Promise<PlayoutDetail> {
return request<PlayoutDetail>(`/api/playouts/${playoutId}`);
}
export function getPlayoutItems(playoutId: number, showFiller = false): Promise<PlayoutItemsPage> {
return request<PlayoutItemsPage>(`/api/playouts/${playoutId}/items${showFiller ? '?showFiller=true' : ''}`);
}
export function getPlayoutWarningsCount(): Promise<number> {
return request<number>('/api/playouts/warnings/count');
}
export function getPlayoutChannelStates(): Promise<PlayoutChannelState[]> {
return request<PlayoutChannelState[]>('/api/channels/state');
}
export function resetAllPlayouts(): Promise<void> {
return request<void>('/api/playouts/reset-all', { method: 'POST' });
}
export function usePlayoutsScreenQuery(pollMs = 30000): PlayoutsScreenQueryState {
const [state, setState] = useState<PlayoutsScreenState>({
data: null,
error: null,
status: 'loading'
});
const [showFiller, setShowFillerState] = useState(false);
const activeRef = useRef(true);
const selectedPlayoutIdRef = useRef<number | null>(null);
const showFillerRef = useRef(false);
// Snapshot of the last-known base fields (everything but the selected playout's
// detail/items), kept in sync whenever load()/loadChannelStates() succeed. Reading
// this lets setActivePlayout/setShowFiller kick off their fetch without doing the
// fetch from inside a setState updater (impure; StrictMode double-invokes updaters).
const baseRef = useRef<PlayoutsScreenBase>({
channelStates: [],
playouts: [],
selectedPlayoutId: null,
totalCount: 0,
warningsCount: 0
});
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
const loadSelectedPlayout = useCallback((playoutId: number, base: PlayoutsScreenBase) => {
baseRef.current = base;
Promise.all([getPlayout(playoutId), getPlayoutItems(playoutId, showFillerRef.current)])
.then(([playout, itemsPage]) => {
if (!activeRef.current || selectedPlayoutIdRef.current !== playoutId) {
return;
}
setState((current) => {
// Merge onto whatever channel state the background poll may have landed
// while this request was in flight, rather than clobbering it with the
// snapshot captured when the request started.
const channelStates = current.status === 'success' ? current.data.channelStates : base.channelStates;
return {
data: {
...base,
channelStates,
items: itemsPage.page ?? [],
playout,
selectedPlayoutId: playoutId
},
error: null,
itemsLoading: false,
status: 'success'
};
});
})
.catch((error: unknown) => {
if (activeRef.current && selectedPlayoutIdRef.current === playoutId) {
setState({ data: null, error: messageFromPlayoutError(error), status: 'error' });
}
});
}, []);
const load = useCallback(() => {
Promise.all([getPlayouts(), getPlayoutWarningsCount(), getPlayoutChannelStates()])
.then(([playoutsPage, warningsCount, channelStates]) => {
if (!activeRef.current) {
return;
}
const playouts = playoutsPage.page ?? [];
const selectedPlayoutId = selectedPlayoutIdRef.current && playouts.some((playout) => playout.id === selectedPlayoutIdRef.current)
? selectedPlayoutIdRef.current
: playouts[0]?.id ?? null;
selectedPlayoutIdRef.current = selectedPlayoutId;
const base: PlayoutsScreenBase = {
channelStates,
playouts,
selectedPlayoutId,
totalCount: playoutsPage.totalCount,
warningsCount
};
baseRef.current = base;
if (selectedPlayoutId == null) {
setState({
data: {
...base,
items: [],
playout: null
},
error: null,
itemsLoading: false,
status: 'success'
});
return;
}
loadSelectedPlayout(selectedPlayoutId, base);
})
.catch((error: unknown) => {
if (activeRef.current) {
setState({ data: null, error: messageFromPlayoutError(error), status: 'error' });
}
});
}, [loadSelectedPlayout]);
const loadChannelStates = useCallback(() => {
getPlayoutChannelStates()
.then((channelStates) => {
if (!activeRef.current) {
return;
}
baseRef.current = { ...baseRef.current, channelStates };
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: { ...current.data, channelStates },
error: null,
itemsLoading: current.itemsLoading,
status: 'success'
};
});
})
.catch(() => {
// Keep the monitor visible on background polling failures.
});
}, []);
useEffect(() => {
load();
const intervalId = window.setInterval(loadChannelStates, pollMs);
return () => {
window.clearInterval(intervalId);
};
}, [load, loadChannelStates, pollMs]);
const refresh = useCallback(() => {
setState({ data: null, error: null, status: 'loading' });
load();
}, [load]);
const setActivePlayout = useCallback((playoutId: number) => {
selectedPlayoutIdRef.current = playoutId;
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: {
...current.data,
items: [],
playout: null,
selectedPlayoutId: playoutId
},
error: null,
itemsLoading: true,
status: 'success'
};
});
const base: PlayoutsScreenBase = { ...baseRef.current, selectedPlayoutId: playoutId };
loadSelectedPlayout(playoutId, base);
}, [loadSelectedPlayout]);
const setShowFiller = useCallback((next: boolean) => {
showFillerRef.current = next;
setShowFillerState(next);
const playoutId = selectedPlayoutIdRef.current;
if (playoutId == null) {
return;
}
setState((current) => {
if (current.status !== 'success') {
return current;
}
return { ...current, itemsLoading: true };
});
getPlayoutItems(playoutId, next)
.then((itemsPage) => {
if (!activeRef.current || selectedPlayoutIdRef.current !== playoutId) {
return;
}
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: { ...current.data, items: itemsPage.page ?? [] },
error: null,
itemsLoading: false,
status: 'success'
};
});
})
.catch((error: unknown) => {
if (!activeRef.current || selectedPlayoutIdRef.current !== playoutId) {
return;
}
setState({ data: null, error: messageFromPlayoutError(error), status: 'error' });
});
}, []);
if (state.status === 'success') {
return {
data: state.data,
error: null,
itemsLoading: state.itemsLoading,
refresh,
setActivePlayout,
setShowFiller,
showFiller,
status: 'success'
};
}
if (state.status === 'error') {
return { data: null, error: state.error, refresh, status: 'error' };
}
return { data: null, error: null, refresh, status: 'loading' };
}
function messageFromPlayoutError(error: unknown, fallback = 'Unable to load playouts'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}
+304
View File
@@ -0,0 +1,304 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ApiError, request } from './client';
import type { components } from './generated/v1';
export type ProgramSchedule = components['schemas']['ProgramScheduleViewModel'];
// These fields are real on the wire (the backend serializes ProgramScheduleItemViewModel
// subtypes with Newtonsoft using the runtime type), but the OpenAPI schema doesn't declare
// them: ProgramScheduleItemViewModel is an abstract base with no polymorphism annotation, so
// the generator only sees the base shape. Tracked in Gitea issue #126 — remove this widening
// once the schema is fixed to describe the concrete subtypes.
export type ProgramScheduleItem = components['schemas']['ProgramScheduleItemViewModel'] & {
count?: null | string;
discardToFillAttempts?: null | number;
multipleCount?: null | string;
multipleMode?: components['schemas']['MultipleMode'];
playoutDuration?: null | string;
tailMode?: components['schemas']['TailMode'];
};
export type ProgramScheduleItemsWithDuration = components['schemas']['ProgramScheduleItemsWithDurationViewModel'];
export type ScheduleItemRequest = components['schemas']['ScheduleItemRequest'];
export type ReplaceScheduleItemsRequest = components['schemas']['ReplaceScheduleItemsRequest'];
export type MediaCollection = components['schemas']['MediaCollectionViewModel'];
export type SmartCollection = components['schemas']['SmartCollectionViewModel'];
export type FillerPreset = components['schemas']['FillerPresetResponseModel'];
export type Watermark = components['schemas']['WatermarkResponseModel'];
export interface SchedulePickerData {
collections: MediaCollection[];
fillerPresets: FillerPreset[];
smartCollections: SmartCollection[];
watermarks: Watermark[];
}
export interface ScheduleScreenData {
activeSchedule: ProgramSchedule | null;
items: ProgramScheduleItem[];
pickers: SchedulePickerData;
schedules: ProgramSchedule[];
totalDurationEstimate: string | null;
}
export type ScheduleScreenQueryState =
| {
data: ScheduleScreenData;
error: null;
itemsLoading: boolean;
refresh: () => void;
setActiveSchedule: (scheduleId: number) => void;
setItems: (items: ProgramScheduleItem[]) => void;
status: 'success';
}
| { data: null; error: string; refresh: () => void; status: 'error' }
| { data: null; error: null; refresh: () => void; status: 'loading' };
type ScheduleScreenState =
| { data: ScheduleScreenData; error: null; itemsLoading: boolean; status: 'success' }
| { data: null; error: string; status: 'error' }
| { data: null; error: null; status: 'loading' };
export function getSchedules(): Promise<ProgramSchedule[]> {
return request<ProgramSchedule[]>('/api/schedules');
}
export function getScheduleItems(scheduleId: number): Promise<ProgramScheduleItemsWithDuration> {
return request<ProgramScheduleItemsWithDuration>(`/api/schedules/${scheduleId}/items`);
}
export function getCollections(): Promise<MediaCollection[]> {
return request<MediaCollection[]>('/api/collections');
}
export function getSmartCollections(): Promise<SmartCollection[]> {
return request<SmartCollection[]>('/api/smart-collections');
}
export function getFillerPresets(): Promise<FillerPreset[]> {
return request<FillerPreset[]>('/api/filler-presets');
}
export function getWatermarks(): Promise<Watermark[]> {
return request<Watermark[]>('/api/watermarks');
}
export function addScheduleItem(scheduleId: number, body: ScheduleItemRequest): Promise<ProgramScheduleItem> {
return request<ProgramScheduleItem>(`/api/schedules/${scheduleId}/items`, {
body,
method: 'POST'
});
}
export function replaceScheduleItems(
scheduleId: number,
body: ReplaceScheduleItemsRequest
): Promise<ProgramScheduleItem[]> {
return request<ProgramScheduleItem[]>(`/api/schedules/${scheduleId}/items`, {
body,
method: 'PUT'
});
}
export function deleteScheduleItem(scheduleId: number, itemId: number): Promise<void> {
return request<void>(`/api/schedules/${scheduleId}/items/${itemId}`, {
method: 'DELETE'
});
}
export async function getScheduleScreenData(): Promise<ScheduleScreenData> {
const schedules = await getSchedules();
const activeSchedule = schedules[0] ?? null;
if (!activeSchedule) {
return {
activeSchedule: null,
items: [],
pickers: emptyPickerData(),
schedules,
totalDurationEstimate: null
};
}
const [itemsEnvelope, pickers] = await Promise.all([
getScheduleItems(activeSchedule.id),
getSchedulePickerData()
]);
return {
activeSchedule,
items: (itemsEnvelope.items ?? []) as ProgramScheduleItem[],
pickers,
schedules,
totalDurationEstimate: itemsEnvelope.totalDurationEstimate
};
}
export function useScheduleScreenQuery(): ScheduleScreenQueryState {
const [state, setState] = useState<ScheduleScreenState>({
data: null,
error: null,
status: 'loading'
});
const activeRef = useRef(true);
const activeScheduleIdRef = useRef<number | null>(null);
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
const load = useCallback(() => {
getScheduleScreenData()
.then((data) => {
if (activeRef.current) {
activeScheduleIdRef.current = data.activeSchedule?.id ?? null;
setState({ data, error: null, itemsLoading: false, status: 'success' });
}
})
.catch((error: unknown) => {
if (activeRef.current) {
setState({ data: null, error: messageFromScheduleError(error, 'Unable to load schedules'), status: 'error' });
}
});
}, []);
useEffect(() => {
load();
}, [load]);
const refresh = useCallback(() => {
setState({ data: null, error: null, status: 'loading' });
load();
}, [load]);
const setActiveSchedule = useCallback((scheduleId: number) => {
activeScheduleIdRef.current = scheduleId;
setState((current) => {
if (current.status !== 'success') {
return current;
}
const nextActiveSchedule = current.data.schedules.find((schedule) => schedule.id === scheduleId)
?? current.data.activeSchedule;
return {
data: { ...current.data, activeSchedule: nextActiveSchedule },
error: null,
itemsLoading: true,
status: 'success'
};
});
getScheduleItems(scheduleId)
.then((itemsEnvelope) => {
if (!activeRef.current || activeScheduleIdRef.current !== scheduleId) {
return;
}
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: {
...current.data,
items: (itemsEnvelope.items ?? []) as ProgramScheduleItem[],
totalDurationEstimate: itemsEnvelope.totalDurationEstimate
},
error: null,
itemsLoading: false,
status: 'success'
};
});
})
.catch((error: unknown) => {
if (!activeRef.current || activeScheduleIdRef.current !== scheduleId) {
return;
}
setState({
data: null,
error: messageFromScheduleError(error, 'Unable to load schedule items'),
status: 'error'
});
});
}, []);
const setItems = useCallback((items: ProgramScheduleItem[]) => {
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: { ...current.data, items },
error: null,
itemsLoading: false,
status: 'success'
};
});
}, []);
if (state.status === 'success') {
return {
data: state.data,
error: null,
itemsLoading: state.itemsLoading,
refresh,
setActiveSchedule,
setItems,
status: 'success'
};
}
if (state.status === 'error') {
return { data: null, error: state.error, refresh, status: 'error' };
}
return { data: null, error: null, refresh, status: 'loading' };
}
async function getSchedulePickerData(): Promise<SchedulePickerData> {
const [collections, smartCollections, fillerPresets, watermarks] = await Promise.all([
getCollections(),
getSmartCollections(),
getFillerPresets(),
getWatermarks()
]);
return {
collections,
fillerPresets: sortByName(fillerPresets),
smartCollections,
watermarks: sortByName(watermarks)
};
}
function emptyPickerData(): SchedulePickerData {
return {
collections: [],
fillerPresets: [],
smartCollections: [],
watermarks: []
};
}
function sortByName<T extends { name: null | string }>(items: T[]): T[] {
return [...items].sort((left, right) => (left.name ?? '').localeCompare(right.name ?? ''));
}
function messageFromScheduleError(error: unknown, fallback = 'Unable to load schedules'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}
+1513 -65
View File
File diff suppressed because it is too large Load Diff