---
key: spa.channels-screen-extraction
title: '2026-07-11 — Channels screen extraction (#244): single-file screen, no sibling helper dir (epic #243 phase 1)'
status: active
since: '2026-07-11'
supersedes: none
superseded-by: none
rule: The Channels domain is a single-file zero-prop screen (`web/src/screens/ChannelsScreen.tsx`) with a colocated test file and no sibling helper directory, since its pure logic is too small (~30 lines) to justify a separate business-rule layer like Schedules' `itemRules.ts`.
signals: 'screen extraction, single-file screen, no helper dir · paths: `web/src/screens/ChannelsScreen.tsx` · issues: #244, #243, #238'
mechanics: '`docs/spa-conventions.md` §6'
---

First bounded extraction under the App.tsx modularization epic (#243): the Channels domain moved
verbatim out of `web/src/App.tsx` into `web/src/screens/ChannelsScreen.tsx` (zero-prop, self-sufficient,
mirroring the SchedulesScreen extraction), with its behavior tests moved to a colocated
`ChannelsScreen.test.tsx` that owns its own scoped fetch mock (spa-conventions §6). Pure structural
move: no API/route/CSS/visual change; `App.tsx` retains only the import + the `<ChannelsScreen />`
dispatch clause.

**Decision — no `web/src/channels/` sibling helper directory** (unlike Schedules' `web/src/schedules/`).
Channels' pure logic (`stateByChannelId`, `sortedChannels`, `groupedChannels`, `progressFromChannelState`,
`formatChannelNumber`) totals ~30 lines with no independent business-rule layer comparable to Schedules'
`itemRules.ts` (~470 lines, separately unit-tested). Keeping it inside the single screen file matches the
Blocks/Decos/Templates precedent. Revisit only if a later #243 phase adds substantial pure Channels logic
worth isolating.

**One cross-domain helper inlined, not shared:** the Dashboard-owned `progressFromNowPlaying` (still in
`App.tsx`, used by `OnAirCard`) was structurally reused by the Channels `progressFromChannelState`. Rather
than export it from App or create a shared module, its ~5-line start/finish/now percentage math was inlined
into the moved `progressFromChannelState` (behavior-identical — `ChannelState.nowPlaying` carries the same
`startUtc`/`finishUtc` shape), so `ChannelsScreen.tsx` has no import back into `App.tsx`.

**#238 (TopBar `primaryAction` dead button) left untouched** — the `channels` route's inert `ctv:primary-action`
dispatch is #238's owned bug and out of scope for a behavior-preserving extraction; the shell/action redesign
is deferred to #247 (epic phase 4).
