fix(web): playouts monitor review fixes for #87
- 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:
+139
-26
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user