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>
This commit is contained in:
2026-07-05 17:45:01 +02:00
co-authored by Claude Fable 5
parent 1840ef83b1
commit c4190e3a54
4 changed files with 316 additions and 110 deletions
+139 -26
View File
@@ -977,14 +977,14 @@ describe('ChicoryTV SPA scaffold', () => {
playoutDetails: playout({ id: 20, playoutMode: 'Continuous', scheduleFile: '/config/schedules/retro.json' }),
playoutItems: [
playoutItem({ title: 'Saturday Morning Cartoons', start: '2026-07-05T20:00:00Z', finish: '2026-07-05T20:30:00Z' }),
playoutItem({ title: 'Station ID', start: '2026-07-05T20:30:00Z', finish: '2026-07-05T20:31:00Z', duration: '00:01:00', fillerKind: 'PreRoll' }),
playoutItem({ title: 'Station ID', start: '2026-07-05T20:30:00Z', finish: '2026-07-05T20:31:00Z', duration: '00:01:00' }),
playoutItem({ title: 'Moon Patrol', start: '2026-07-05T20:31:00Z', finish: '2026-07-05T21:00:00Z' })
],
playoutWarningsCount: 3,
playouts: {
page: [
playout({ id: 20, channelName: 'Retro Cartoons', channelNumber: '5.1', scheduleName: 'Prime Time Cartoons' }),
playout({ id: 21, channelName: 'News 24', channelNumber: '24', scheduleName: 'News Rotation' })
listPlayout({ id: 20, channelName: 'Retro Cartoons', channelNumber: '5.1', scheduleName: 'Prime Time Cartoons' }),
listPlayout({ id: 21, channelName: 'News 24', channelNumber: '24', scheduleName: 'News Rotation' })
],
totalCount: 2
}
@@ -1000,7 +1000,8 @@ describe('ChicoryTV SPA scaffold', () => {
expect(screen.getAllByText('Saturday Morning Cartoons').length).toBeGreaterThan(0);
expect(screen.getAllByText('Moon Patrol').length).toBeGreaterThan(0);
expect(screen.getAllByText('Station ID').length).toBeGreaterThan(0);
expect(screen.getByText('Filler')).toBeInTheDocument();
// Real items carry fillerKind 'None' and must not be badged as filler.
expect(screen.queryByText('Filler')).not.toBeInTheDocument();
expect(screen.getByText('Metadata preview only')).toBeInTheDocument();
expect(screen.getByDisplayValue('Continuous')).toBeInTheDocument();
expect(window.fetch).toHaveBeenCalledWith('/api/playouts', expect.any(Object));
@@ -1010,6 +1011,31 @@ describe('ChicoryTV SPA scaffold', () => {
expect(window.fetch).toHaveBeenCalledWith('/api/channels/state', expect.any(Object));
});
it('toggles Show filler to refetch playout items with showFiller=true and badge filler rows', async () => {
mockDashboardApi({
playoutItems: [
playoutItem({ title: 'Saturday Morning Cartoons', start: '2026-07-05T20:00:00Z', finish: '2026-07-05T20:30:00Z' }),
playoutItem({ title: 'Station ID', start: '2026-07-05T20:30:00Z', finish: '2026-07-05T20:31:00Z', duration: '00:01:00', fillerKind: 'PreRoll' })
],
playouts: { page: [listPlayout({ id: 20 })], totalCount: 1 }
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
// Toggle off: the filler item is not requested, so no Filler badge renders.
expect(screen.queryByText('Station ID')).not.toBeInTheDocument();
expect(screen.queryByText('Filler')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('switch', { name: 'Show filler' }));
expect((await screen.findAllByText('Station ID')).length).toBeGreaterThan(0);
expect(screen.getByText('Filler')).toBeInTheDocument();
expect(window.fetch).toHaveBeenCalledWith('/api/playouts/20/items?showFiller=true', expect.any(Object));
});
it('fetches the Playouts screen data exactly once on mount and only polls channel state', async () => {
const intervalHandlers: Array<() => void> = [];
vi.spyOn(window, 'setInterval').mockImplementation((handler: TimerHandler) => {
@@ -1025,17 +1051,25 @@ describe('ChicoryTV SPA scaffold', () => {
{ channelId: 1, channelNumber: '5.1', onAir: true, nowPlaying: null }
],
playoutItems: [playoutItem()],
playouts: { page: [playout({ id: 20 })], totalCount: 1 }
playouts: { page: [listPlayout({ id: 20 })], totalCount: 1 }
});
render(<App />);
// The Dashboard also fetches /api/playouts on load; measure the Playouts
// screen's own contribution as a delta instead of absorbing that prefetch.
await waitFor(() => {
expect(fetchCount('/api/playouts')).toBeGreaterThan(0);
});
const playoutFetchesBeforeNavigation = fetchCount('/api/playouts');
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
expect(fetchCount('/api/playouts')).toBe(2);
expect(fetchCount('/api/playouts')).toBe(playoutFetchesBeforeNavigation + 1);
expect(fetchCount('/api/playouts/20')).toBe(1);
expect(fetchCount('/api/playouts/20/items')).toBe(1);
const playoutFetchesBeforePoll = fetchCount('/api/playouts');
const stateFetchesBeforePoll = fetchCount('/api/channels/state');
intervalHandlers.forEach((handler) => handler());
@@ -1043,7 +1077,7 @@ describe('ChicoryTV SPA scaffold', () => {
await waitFor(() => {
expect(fetchCount('/api/channels/state')).toBeGreaterThan(stateFetchesBeforePoll);
});
expect(fetchCount('/api/playouts')).toBe(2);
expect(fetchCount('/api/playouts')).toBe(playoutFetchesBeforePoll);
expect(fetchCount('/api/playouts/20/items')).toBe(1);
});
@@ -1053,8 +1087,8 @@ describe('ChicoryTV SPA scaffold', () => {
playoutItems: [playoutItem({ title: 'Saturday Morning Cartoons' })],
playouts: {
page: [
playout({ id: 20, channelName: 'Retro Cartoons', channelNumber: '5.1' }),
playout({ id: 21, channelName: 'News 24', channelNumber: '24' })
listPlayout({ id: 20, channelName: 'Retro Cartoons', channelNumber: '5.1' }),
listPlayout({ id: 21, channelName: 'News 24', channelNumber: '24' })
],
totalCount: 2
}
@@ -1107,7 +1141,7 @@ describe('ChicoryTV SPA scaffold', () => {
title: 'Not found'
},
playoutItemsFailuresBeforeSuccess: 1,
playouts: { page: [playout({ id: 20 })], totalCount: 1 }
playouts: { page: [listPlayout({ id: 20 })], totalCount: 1 }
});
render(<App />);
@@ -1134,7 +1168,7 @@ describe('ChicoryTV SPA scaffold', () => {
mockDashboardApi({
confirm: true,
playoutItems: [playoutItem()],
playouts: { page: [playout({ id: 20 })], totalCount: 1 }
playouts: { page: [listPlayout({ id: 20 })], totalCount: 1 }
});
render(<App />);
@@ -1154,18 +1188,21 @@ describe('ChicoryTV SPA scaffold', () => {
expect(fetchCount('/api/playouts/20/items')).toBe(itemFetchesBeforeReset + 1);
});
it('shows ProblemDetails when resetting all playouts fails', async () => {
it('shows ProblemDetails when the reset-all transport fails with a 5xx', async () => {
// Backend contract: POST /api/playouts/reset-all is an unconditional 202 — there is no
// application-level error path today. This test guards client robustness only, against
// transport/server failures (proxy errors, unhandled exceptions).
mockDashboardApi({
confirm: true,
mutationFailures: {
'/api/playouts/reset-all': {
detail: 'Playout rebuild is already running',
status: 409,
title: 'Conflict'
detail: 'The playout worker crashed while queueing rebuilds',
status: 500,
title: 'Internal Server Error'
}
},
playoutItems: [playoutItem()],
playouts: { page: [playout({ id: 20 })], totalCount: 1 }
playouts: { page: [listPlayout({ id: 20 })], totalCount: 1 }
});
render(<App />);
@@ -1175,7 +1212,59 @@ describe('ChicoryTV SPA scaffold', () => {
fireEvent.click(screen.getByRole('button', { name: 'Reset all playouts' }));
expect(await screen.findByText('Playout rebuild is already running')).toBeInTheDocument();
expect(await screen.findByText('The playout worker crashed while queueing rebuilds')).toBeInTheDocument();
});
it('filters the playout selector rail by channel name', async () => {
mockDashboardApi({
playoutItems: [playoutItem()],
playouts: {
page: [
listPlayout({ id: 20, channelName: 'Retro Cartoons', channelNumber: '5.1' }),
listPlayout({ id: 21, channelName: 'News 24', channelNumber: '24', scheduleName: 'News Rotation' })
],
totalCount: 2
}
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
const rail = screen.getByRole('complementary', { name: 'Playout selector' });
expect(within(rail).getByRole('button', { name: /Retro Cartoons/ })).toBeInTheDocument();
expect(within(rail).getByRole('button', { name: /News 24/ })).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Filter playouts'), { target: { value: 'News' } });
expect(within(rail).queryByRole('button', { name: /Retro Cartoons/ })).not.toBeInTheDocument();
expect(within(rail).getByRole('button', { name: /News 24/ })).toBeInTheDocument();
expect(screen.getByText('1 of 2 playouts')).toBeInTheDocument();
});
it('renders honestly when totalCount exceeds the returned page length', async () => {
mockDashboardApi({
playoutItems: [playoutItem()],
playoutItemsTotalCount: 500,
playouts: {
page: [
listPlayout({ id: 20, channelName: 'Retro Cartoons', channelNumber: '5.1' }),
listPlayout({ id: 21, channelName: 'News 24', channelNumber: '24' })
],
totalCount: 150
}
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
// Only 2 of 150 rows were returned; the count label must not claim all 150 loaded.
expect(screen.getByText('2 of 150 playouts')).toBeInTheDocument();
// Items page is also partial (1 of 500); the monitor must still render without crashing.
expect(screen.getAllByText('Saturday Morning Cartoons').length).toBeGreaterThan(0);
});
it('shows the dashboard loading state while requests are pending', async () => {
@@ -1355,7 +1444,8 @@ function collection(overrides: Record<string, unknown> = {}): Record<string, unk
};
}
function playout(overrides: Record<string, unknown> = {}): Record<string, unknown> {
// PlayoutListItemResponseModel: rail rows have no detail-only fields (playoutMode/scheduleFile).
function listPlayout(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
buildStatus: {
lastBuild: '2026-07-05T19:00:00Z',
@@ -1366,18 +1456,26 @@ function playout(overrides: Record<string, unknown> = {}): Record<string, unknow
channelNumber: '5.1',
dailyRebuildTime: '04:00:00',
id: 20,
playoutMode: 'Continuous',
scheduleFile: null,
scheduleKind: 'Classic',
scheduleName: 'Prime Time Cartoons',
...overrides
};
}
// PlayoutResponseModel: the detail shape adds playoutMode/scheduleFile on top of the list row.
function playout(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return listPlayout({
playoutMode: 'Continuous',
scheduleFile: null,
...overrides
});
}
// The backend always emits fillerKind: real content items carry 'None' (never null).
function playoutItem(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
duration: '00:30:00',
fillerKind: null,
fillerKind: 'None',
finish: '2026-07-05T20:30:00Z',
start: '2026-07-05T20:00:00Z',
title: 'Saturday Morning Cartoons',
@@ -1502,6 +1600,7 @@ function mockDashboardApi({
playoutItems = [],
playoutItemsFailure = null,
playoutItemsFailuresBeforeSuccess = 0,
playoutItemsTotalCount = null,
playoutWarningsCount = 0,
playouts = { page: [], totalCount: 0 },
prompt = null,
@@ -1530,6 +1629,7 @@ function mockDashboardApi({
playoutItems?: unknown[];
playoutItemsFailure?: unknown;
playoutItemsFailuresBeforeSuccess?: number;
playoutItemsTotalCount?: number | null;
playoutWarningsCount?: number;
playouts?: unknown;
prompt?: string | null;
@@ -1636,21 +1736,34 @@ function mockDashboardApi({
if (path === '/api/playouts/reset-all') {
if (path in mutationFailures) {
return Promise.resolve(jsonResponse(mutationFailures[path], 422));
const failure = mutationFailures[path] as { status?: number };
return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
}
return Promise.resolve(new Response(null, { status: 204 }));
// Backend contract: POST /api/playouts/reset-all is an unconditional 202 Accepted.
return Promise.resolve(new Response(null, { status: 202 }));
}
if (path.match(/^\/api\/playouts\/\d+\/items$/)) {
const itemsMatch = path.match(/^\/api\/playouts\/\d+\/items(\?(?<query>.*))?$/);
if (itemsMatch) {
if (remainingPlayoutItemsFailures > 0) {
remainingPlayoutItemsFailures -= 1;
return Promise.resolve(jsonResponse(playoutItemsFailure, 404));
}
// Mirror the backend: filler items are only included when ?showFiller=true is sent.
const showFiller = new URLSearchParams(itemsMatch.groups?.query ?? '').get('showFiller') === 'true';
const page = showFiller
? playoutItems
: playoutItems.filter((item) => {
const fillerKind = (item as { fillerKind?: null | string }).fillerKind;
return fillerKind == null || fillerKind === 'None';
});
return Promise.resolve(jsonResponse({
page: playoutItems,
totalCount: playoutItems.length
page,
totalCount: playoutItemsTotalCount ?? page.length
}));
}
+64 -10
View File
@@ -61,6 +61,7 @@ import {
Spinner,
Stat,
StatusDot,
Switch,
Tabs
} from './components';
import {
@@ -1870,6 +1871,7 @@ function PlayoutsScreen() {
}
const { channelStates, items, playout, playouts, selectedPlayoutId, totalCount, warningsCount } = query.data;
const { itemsLoading, setShowFiller, showFiller } = query;
const selectedSummary = playouts.find((candidate) => candidate.id === selectedPlayoutId) ?? playouts[0] ?? null;
if (!selectedSummary) {
@@ -1932,7 +1934,7 @@ function PlayoutsScreen() {
size="sm"
value={filter}
/>
<span>{totalCount} playout{totalCount === 1 ? '' : 's'} loaded</span>
<span>{playoutsCountLabel(filteredPlayouts.length, totalCount)}</span>
</div>
<div className="ctv-playouts-list">
{filteredPlayouts.map((candidate) => {
@@ -1943,6 +1945,7 @@ function PlayoutsScreen() {
<button
aria-current={active ? 'true' : undefined}
className={`ctv-playout-option${active ? ' ctv-playout-option-active' : ''}`}
disabled={mutating}
key={candidate.id}
onClick={() => query.setActivePlayout(candidate.id)}
type="button"
@@ -2002,24 +2005,45 @@ function PlayoutsScreen() {
<Input disabled label="Kind" value={formatScheduleEnum(playout?.scheduleKind ?? selectedSummary.scheduleKind)} />
<Input disabled label="Rebuild" value={formatDailyRebuild(playout?.dailyRebuildTime ?? selectedSummary.dailyRebuildTime)} />
</div>
<div className="ctv-playout-detail-actions" title="No per-playout reset endpoint exists yet">
<Button disabled size="sm" startIcon={<RefreshCw aria-hidden="true" size={13} />} variant="secondary">Reset</Button>
<Button disabled size="sm" startIcon={<Clock aria-hidden="true" size={13} />} variant="ghost">Schedule reset</Button>
<small>Per-playout reset is deferred — the API has no per-playout reset endpoint yet.</small>
</div>
</Card>
</div>
<Card title="Timeline" subtitle="Selected playout items">
<PlayoutTimeline items={items} nowItem={nowItem} />
<Card title="Timeline" subtitle="Selected playout items" actions={itemsLoading ? <Spinner size={13} tone="muted" /> : undefined}>
<PlayoutTimeline items={items} itemsLoading={itemsLoading} nowItem={nowItem} />
</Card>
<Card title="Upcoming" subtitle="Next items" padded={false}>
<Card
title="Upcoming"
subtitle="Next items"
padded={false}
actions={
<span className="ctv-playout-upcoming-actions">
{itemsLoading && <Spinner size={13} tone="muted" />}
<Switch
checked={showFiller}
disabled={itemsLoading || mutating}
label="Show filler"
onChange={setShowFiller}
size="sm"
/>
</span>
}
>
<div className="ctv-playout-upcoming" role="list" aria-label="Upcoming playout items">
{items.length === 0 ? (
<div className="ctv-schedule-empty">No upcoming items</div>
<div className="ctv-schedule-empty">{itemsLoading ? <Spinner size={15} tone="muted" /> : 'No upcoming items'}</div>
) : (
items.map((item, index) => (
<div className={item === nowItem ? 'ctv-playout-upcoming-now' : ''} key={`${item.start}-${index}`} role="listitem">
<code>{formatDateTime(item.start)}</code>
{item.fillerKind ? <Sparkles aria-hidden="true" size={13} /> : <Film aria-hidden="true" size={13} />}
{isFillerItem(item) ? <Sparkles aria-hidden="true" size={13} /> : <Film aria-hidden="true" size={13} />}
<span>{item.title ?? 'Untitled item'}</span>
{item.fillerKind && <Badge tone="neutral">Filler</Badge>}
{isFillerItem(item) && <Badge tone="neutral">Filler</Badge>}
<small>{item.duration ?? 'unknown'}</small>
</div>
))
@@ -2032,9 +2056,13 @@ function PlayoutsScreen() {
);
}
function PlayoutTimeline({ items, nowItem }: { items: PlayoutItem[]; nowItem: PlayoutItem | null }) {
function PlayoutTimeline({ items, itemsLoading, nowItem }: { items: PlayoutItem[]; itemsLoading: boolean; nowItem: PlayoutItem | null }) {
if (items.length === 0) {
return <div className="ctv-schedule-empty">No timeline items</div>;
return (
<div className="ctv-schedule-empty">
{itemsLoading ? <Spinner size={15} tone="muted" /> : 'No timeline items'}
</div>
);
}
const firstStart = Date.parse(items[0].start);
@@ -2049,7 +2077,7 @@ function PlayoutTimeline({ items, nowItem }: { items: PlayoutItem[]; nowItem: Pl
return (
<span
className={item.fillerKind ? 'ctv-playout-timeline-filler' : ''}
className={isFillerItem(item) ? 'ctv-playout-timeline-filler' : ''}
key={`${item.start}-${index}`}
style={{ width: `${width}%` }}
title={item.title ?? 'Untitled item'}
@@ -2083,7 +2111,33 @@ function filterPlayouts(playouts: PlayoutSummary[], filter: string): PlayoutSumm
);
}
// The backend always emits fillerKind; real content items carry 'None', so only a
// concrete filler kind (PreRoll, MidRoll, ...) marks an item as filler.
function isFillerItem(item: PlayoutItem): boolean {
return item.fillerKind != null && item.fillerKind !== 'None';
}
function playoutsCountLabel(shownCount: number, totalCount: number): string {
if (shownCount === totalCount) {
return `${totalCount} playout${totalCount === 1 ? '' : 's'} loaded`;
}
return `${shownCount} of ${totalCount} playout${totalCount === 1 ? '' : 's'}`;
}
function itemMatchingNow(items: PlayoutItem[], title: string | null | undefined): PlayoutItem | null {
const nowMs = Date.now();
const windowMatch = items.find((item) => {
const startMs = Date.parse(item.start);
const finishMs = Date.parse(item.finish);
return Number.isFinite(startMs) && Number.isFinite(finishMs) && startMs <= nowMs && nowMs < finishMs;
});
if (windowMatch) {
return windowMatch;
}
if (!title) {
return null;
}
+107 -71
View File
@@ -19,19 +19,24 @@ export interface PlayoutsScreenData {
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; status: 'success' }
| { data: PlayoutsScreenData; error: null; itemsLoading: boolean; status: 'success' }
| { data: null; error: string; status: 'error' }
| { data: null; error: null; status: 'loading' };
@@ -43,8 +48,8 @@ export function getPlayout(playoutId: number): Promise<PlayoutDetail> {
return request<PlayoutDetail>(`/api/playouts/${playoutId}`);
}
export function getPlayoutItems(playoutId: number): Promise<PlayoutItemsPage> {
return request<PlayoutItemsPage>(`/api/playouts/${playoutId}/items`);
export function getPlayoutItems(playoutId: number, showFiller = false): Promise<PlayoutItemsPage> {
return request<PlayoutItemsPage>(`/api/playouts/${playoutId}/items${showFiller ? '?showFiller=true' : ''}`);
}
export function getPlayoutWarningsCount(): Promise<number> {
@@ -65,8 +70,21 @@ export function usePlayoutsScreenQuery(pollMs = 30000): PlayoutsScreenQueryState
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;
@@ -76,22 +94,33 @@ export function usePlayoutsScreenQuery(pollMs = 30000): PlayoutsScreenQueryState
};
}, []);
const loadSelectedPlayout = useCallback((playoutId: number, base: Omit<PlayoutsScreenData, 'items' | 'playout'>) => {
Promise.all([getPlayout(playoutId), getPlayoutItems(playoutId)])
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({
data: {
...base,
items: itemsPage.page ?? [],
playout,
selectedPlayoutId: playoutId
},
error: null,
status: 'success'
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) => {
@@ -101,11 +130,7 @@ export function usePlayoutsScreenQuery(pollMs = 30000): PlayoutsScreenQueryState
});
}, []);
const load = useCallback((showLoading = true) => {
if (showLoading) {
setState({ data: null, error: null, status: 'loading' });
}
const load = useCallback(() => {
Promise.all([getPlayouts(), getPlayoutWarningsCount(), getPlayoutChannelStates()])
.then(([playoutsPage, warningsCount, channelStates]) => {
if (!activeRef.current) {
@@ -118,13 +143,14 @@ export function usePlayoutsScreenQuery(pollMs = 30000): PlayoutsScreenQueryState
: playouts[0]?.id ?? null;
selectedPlayoutIdRef.current = selectedPlayoutId;
const base = {
const base: PlayoutsScreenBase = {
channelStates,
playouts,
selectedPlayoutId,
totalCount: playoutsPage.totalCount,
warningsCount
};
baseRef.current = base;
if (selectedPlayoutId == null) {
setState({
@@ -134,6 +160,7 @@ export function usePlayoutsScreenQuery(pollMs = 30000): PlayoutsScreenQueryState
playout: null
},
error: null,
itemsLoading: false,
status: 'success'
});
return;
@@ -155,6 +182,8 @@ export function usePlayoutsScreenQuery(pollMs = 30000): PlayoutsScreenQueryState
return;
}
baseRef.current = { ...baseRef.current, channelStates };
setState((current) => {
if (current.status !== 'success') {
return current;
@@ -163,6 +192,7 @@ export function usePlayoutsScreenQuery(pollMs = 30000): PlayoutsScreenQueryState
return {
data: { ...current.data, channelStates },
error: null,
itemsLoading: current.itemsLoading,
status: 'success'
};
});
@@ -173,53 +203,17 @@ export function usePlayoutsScreenQuery(pollMs = 30000): PlayoutsScreenQueryState
}, []);
useEffect(() => {
Promise.all([getPlayouts(), getPlayoutWarningsCount(), getPlayoutChannelStates()])
.then(([playoutsPage, warningsCount, channelStates]) => {
if (!activeRef.current) {
return;
}
const playouts = playoutsPage.page ?? [];
const selectedPlayoutId = playouts[0]?.id ?? null;
selectedPlayoutIdRef.current = selectedPlayoutId;
const base = {
channelStates,
playouts,
selectedPlayoutId,
totalCount: playoutsPage.totalCount,
warningsCount
};
if (selectedPlayoutId == null) {
setState({
data: {
...base,
items: [],
playout: null
},
error: null,
status: 'success'
});
return;
}
loadSelectedPlayout(selectedPlayoutId, base);
})
.catch((error: unknown) => {
if (activeRef.current) {
setState({ data: null, error: messageFromPlayoutError(error), status: 'error' });
}
});
load();
const intervalId = window.setInterval(loadChannelStates, pollMs);
return () => {
window.clearInterval(intervalId);
};
}, [loadChannelStates, loadSelectedPlayout, pollMs]);
}, [load, loadChannelStates, pollMs]);
const refresh = useCallback(() => {
setState({ data: null, error: null, status: 'loading' });
load();
}, [load]);
@@ -239,30 +233,72 @@ export function usePlayoutsScreenQuery(pollMs = 30000): PlayoutsScreenQueryState
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;
}
const base = {
channelStates: current.data.channelStates,
playouts: current.data.playouts,
selectedPlayoutId: playoutId,
totalCount: current.data.totalCount,
warningsCount: current.data.warningsCount
};
loadSelectedPlayout(playoutId, base);
return current;
return { ...current, itemsLoading: true };
});
}, [loadSelectedPlayout]);
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, refresh, setActivePlayout, status: 'success' };
return {
data: state.data,
error: null,
itemsLoading: state.itemsLoading,
refresh,
setActivePlayout,
setShowFiller,
showFiller,
status: 'success'
};
}
if (state.status === 'error') {
+6 -3
View File
@@ -1480,7 +1480,7 @@
overflow: hidden;
border: 1px solid var(--border-hairline);
border-radius: var(--radius-md, 7px);
background: var(--ctv-surface-1);
background: var(--surface-card);
}
.ctv-playout-preview {
@@ -1517,7 +1517,7 @@
}
.ctv-playout-now-copy h3 {
font-size: var(--text-lg, 18px);
font-size: var(--text-lg, 16px);
}
.ctv-playout-now-copy > div:last-child {
@@ -1718,7 +1718,10 @@
}
.ctv-schedule-grid,
.ctv-schedule-form-grid {
.ctv-schedule-form-grid,
.ctv-playouts-grid,
.ctv-playout-detail-grid,
.ctv-playouts-cards {
grid-template-columns: 1fr;
}