From 1840ef83b1daad9b52a617132cd2be79dbadc2c4 Mon Sep 17 00:00:00 2001 From: Timothy Date: Sun, 5 Jul 2026 17:10:30 +0200 Subject: [PATCH 1/2] feat(web): add Playouts monitor screen refs #87 --- web/src/App.test.tsx | 291 +++++++++++++++++++++++++++++++++- web/src/App.tsx | 331 ++++++++++++++++++++++++++++++++++++++ web/src/api/index.ts | 1 + web/src/api/playouts.ts | 285 +++++++++++++++++++++++++++++++++ web/src/shell.css | 342 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 1248 insertions(+), 2 deletions(-) create mode 100644 web/src/api/playouts.ts diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 3ce0db680..0ff8d6fdb 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -257,7 +257,7 @@ describe('ChicoryTV SPA scaffold', () => { expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument(); expect(screen.getByText('5.1')).toBeInTheDocument(); expect(screen.queryByText('News 24')).not.toBeInTheDocument(); - expect(screen.getByText('Saturday Morning Cartoons')).toBeInTheDocument(); + expect(screen.getAllByText('Saturday Morning Cartoons').length).toBeGreaterThan(0); expect(screen.getByText('1 on air')).toBeInTheDocument(); expect(screen.getAllByText('1 warning')).toHaveLength(2); expect(screen.getAllByText('2')).toHaveLength(2); @@ -327,7 +327,7 @@ describe('ChicoryTV SPA scaffold', () => { expect(await screen.findByRole('heading', { name: 'Channels' })).toBeInTheDocument(); expect(await screen.findByRole('table', { name: 'Channels lineup' })).toBeInTheDocument(); expect(screen.getByText('Retro Cartoons')).toBeInTheDocument(); - expect(screen.getByText('Saturday Morning Cartoons')).toBeInTheDocument(); + expect(screen.getAllByText('Saturday Morning Cartoons').length).toBeGreaterThan(0); expect(screen.getByText('News 24')).toBeInTheDocument(); expect(screen.getByText('Off air')).toBeInTheDocument(); expect(screen.getByTitle('Disabled')).toHaveTextContent('D'); @@ -960,6 +960,224 @@ describe('ChicoryTV SPA scaffold', () => { expect(await screen.findByText('Item is in use by an active playout')).toBeInTheDocument(); }); + it('renders the Playouts monitor from live playout and channel state APIs', async () => { + mockDashboardApi({ + channelStates: [ + { + channelId: 1, + channelNumber: '5.1', + onAir: true, + nowPlaying: { + finishUtc: '2026-07-05T20:30:00Z', + startUtc: '2026-07-05T20:00:00Z', + title: 'Saturday Morning Cartoons' + } + } + ], + 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: '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' }) + ], + totalCount: 2 + } + }); + + render(); + + fireEvent.click(screen.getByRole('link', { name: /Playouts/ })); + + expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument(); + expect(screen.getByText('2 playouts loaded')).toBeInTheDocument(); + expect(screen.getByText('3 warnings')).toBeInTheDocument(); + 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(); + expect(screen.getByText('Metadata preview only')).toBeInTheDocument(); + expect(screen.getByDisplayValue('Continuous')).toBeInTheDocument(); + expect(window.fetch).toHaveBeenCalledWith('/api/playouts', expect.any(Object)); + expect(window.fetch).toHaveBeenCalledWith('/api/playouts/20', expect.any(Object)); + expect(window.fetch).toHaveBeenCalledWith('/api/playouts/20/items', expect.any(Object)); + expect(window.fetch).toHaveBeenCalledWith('/api/playouts/warnings/count', expect.any(Object)); + expect(window.fetch).toHaveBeenCalledWith('/api/channels/state', 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) => { + if (typeof handler === 'function') { + intervalHandlers.push(handler as () => void); + } + + return intervalHandlers.length; + }); + vi.spyOn(window, 'clearInterval').mockImplementation(() => undefined); + mockDashboardApi({ + channelStates: [ + { channelId: 1, channelNumber: '5.1', onAir: true, nowPlaying: null } + ], + playoutItems: [playoutItem()], + playouts: { page: [playout({ id: 20 })], totalCount: 1 } + }); + + render(); + + 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/20')).toBe(1); + expect(fetchCount('/api/playouts/20/items')).toBe(1); + const stateFetchesBeforePoll = fetchCount('/api/channels/state'); + + intervalHandlers.forEach((handler) => handler()); + + await waitFor(() => { + expect(fetchCount('/api/channels/state')).toBeGreaterThan(stateFetchesBeforePoll); + }); + expect(fetchCount('/api/playouts')).toBe(2); + expect(fetchCount('/api/playouts/20/items')).toBe(1); + }); + + it('switches selected playouts and fetches only the selected playout detail and items', async () => { + mockDashboardApi({ + playoutDetails: playout({ id: 20, channelName: 'Retro Cartoons', channelNumber: '5.1' }), + 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' }) + ], + totalCount: 2 + } + }); + + render(); + + fireEvent.click(screen.getByRole('link', { name: /Playouts/ })); + expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /News 24/ })); + + expect(await screen.findByRole('heading', { name: 'News 24' })).toBeInTheDocument(); + expect(window.fetch).toHaveBeenCalledWith('/api/playouts/21', expect.any(Object)); + expect(window.fetch).toHaveBeenCalledWith('/api/playouts/21/items', expect.any(Object)); + expect(fetchCount('/api/playouts')).toBe(2); + }); + + it('shows the Playouts loading state', async () => { + vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => { + const path = input.toString(); + + if (path === '/api/playouts') { + return new Promise(() => {}); + } + + if (path === '/api/channels/state') { + return Promise.resolve(jsonResponse([])); + } + + if (path === '/api/playouts/warnings/count') { + return Promise.resolve(jsonResponse(0)); + } + + return Promise.resolve(jsonResponse([])); + }); + + render(); + + fireEvent.click(screen.getByRole('link', { name: /Playouts/ })); + + expect((await screen.findAllByText('Loading playouts')).length).toBeGreaterThan(0); + }); + + it('shows Playouts API errors and retries 404 parent handling', async () => { + mockDashboardApi({ + playoutItemsFailure: { + detail: 'Playout 20 was not found', + status: 404, + title: 'Not found' + }, + playoutItemsFailuresBeforeSuccess: 1, + playouts: { page: [playout({ id: 20 })], totalCount: 1 } + }); + + render(); + + fireEvent.click(screen.getByRole('link', { name: /Playouts/ })); + + expect(await screen.findByText('Playout 20 was not found')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + + expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument(); + }); + + it('shows an empty Playouts state when no playouts exist', async () => { + mockDashboardApi({ playouts: { page: [], totalCount: 0 } }); + + render(); + + fireEvent.click(screen.getByRole('link', { name: /Playouts/ })); + + expect((await screen.findAllByText('No playouts returned')).length).toBeGreaterThan(0); + }); + + it('resets all playouts with confirmation and refetches playout-scoped data', async () => { + mockDashboardApi({ + confirm: true, + playoutItems: [playoutItem()], + playouts: { page: [playout({ id: 20 })], totalCount: 1 } + }); + + render(); + + fireEvent.click(screen.getByRole('link', { name: /Playouts/ })); + expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument(); + + const playoutFetchesBeforeReset = fetchCount('/api/playouts'); + const itemFetchesBeforeReset = fetchCount('/api/playouts/20/items'); + + fireEvent.click(screen.getByRole('button', { name: 'Reset all playouts' })); + + await waitFor(() => { + expect(window.fetch).toHaveBeenCalledWith('/api/playouts/reset-all', expect.objectContaining({ method: 'POST' })); + }); + expect(fetchCount('/api/playouts')).toBe(playoutFetchesBeforeReset + 1); + expect(fetchCount('/api/playouts/20/items')).toBe(itemFetchesBeforeReset + 1); + }); + + it('shows ProblemDetails when resetting all playouts fails', async () => { + mockDashboardApi({ + confirm: true, + mutationFailures: { + '/api/playouts/reset-all': { + detail: 'Playout rebuild is already running', + status: 409, + title: 'Conflict' + } + }, + playoutItems: [playoutItem()], + playouts: { page: [playout({ id: 20 })], totalCount: 1 } + }); + + render(); + + fireEvent.click(screen.getByRole('link', { name: /Playouts/ })); + expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Reset all playouts' })); + + expect(await screen.findByText('Playout rebuild is already running')).toBeInTheDocument(); + }); + it('shows the dashboard loading state while requests are pending', async () => { vi.spyOn(window, 'fetch').mockImplementation(() => new Promise(() => {})); @@ -1137,6 +1355,36 @@ function collection(overrides: Record = {}): Record = {}): Record { + return { + buildStatus: { + lastBuild: '2026-07-05T19:00:00Z', + message: null, + success: true + }, + channelName: 'Retro Cartoons', + channelNumber: '5.1', + dailyRebuildTime: '04:00:00', + id: 20, + playoutMode: 'Continuous', + scheduleFile: null, + scheduleKind: 'Classic', + scheduleName: 'Prime Time Cartoons', + ...overrides + }; +} + +function playoutItem(overrides: Record = {}): Record { + return { + duration: '00:30:00', + fillerKind: null, + finish: '2026-07-05T20:30:00Z', + start: '2026-07-05T20:00:00Z', + title: 'Saturday Morning Cartoons', + ...overrides + }; +} + function scheduleItem(overrides: Record = {}): Record { const collection = { id: 2, name: 'Saturday Cartoons', ...(overrides.collection as object | undefined) }; @@ -1250,6 +1498,11 @@ function mockDashboardApi({ mediaSources = [], multiCollections = [], mutationFailures = {}, + playoutDetails = null, + playoutItems = [], + playoutItemsFailure = null, + playoutItemsFailuresBeforeSuccess = 0, + playoutWarningsCount = 0, playouts = { page: [], totalCount: 0 }, prompt = null, replaceScheduleItemsResponse = null, @@ -1273,6 +1526,11 @@ function mockDashboardApi({ mediaSources?: unknown[]; multiCollections?: unknown[]; mutationFailures?: Record; + playoutDetails?: unknown; + playoutItems?: unknown[]; + playoutItemsFailure?: unknown; + playoutItemsFailuresBeforeSuccess?: number; + playoutWarningsCount?: number; playouts?: unknown; prompt?: string | null; replaceScheduleItemsResponse?: unknown; @@ -1290,6 +1548,7 @@ function mockDashboardApi({ vi.spyOn(window, 'prompt').mockReturnValue(prompt); let currentScheduleItems = scheduleItems; let remainingScheduleItemFailures = scheduleItemFailuresBeforeSuccess; + let remainingPlayoutItemsFailures = playoutItemsFailuresBeforeSuccess; vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => { const path = input.toString(); @@ -1371,6 +1630,34 @@ function mockDashboardApi({ return Promise.resolve(jsonResponse(playouts)); } + if (path === '/api/playouts/warnings/count') { + return Promise.resolve(jsonResponse(playoutWarningsCount)); + } + + if (path === '/api/playouts/reset-all') { + if (path in mutationFailures) { + return Promise.resolve(jsonResponse(mutationFailures[path], 422)); + } + + return Promise.resolve(new Response(null, { status: 204 })); + } + + if (path.match(/^\/api\/playouts\/\d+\/items$/)) { + if (remainingPlayoutItemsFailures > 0) { + remainingPlayoutItemsFailures -= 1; + return Promise.resolve(jsonResponse(playoutItemsFailure, 404)); + } + + return Promise.resolve(jsonResponse({ + page: playoutItems, + totalCount: playoutItems.length + })); + } + + if (path.match(/^\/api\/playouts\/\d+$/)) { + return Promise.resolve(jsonResponse(playoutDetails ?? playout({ id: Number(path.split('/').at(-1)) }))); + } + if (path === '/api/health') { return Promise.resolve(jsonResponse(health)); } diff --git a/web/src/App.tsx b/web/src/App.tsx index fa0a600e2..1bb4dd9ae 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -16,8 +16,10 @@ import { Check, ChevronDown, CircleHelp, + Clock, ClipboardCopy, Copy, + Film, Folder, FolderInput, FolderTree, @@ -36,6 +38,7 @@ import { Search, Settings, Shuffle, + Sparkles, Stethoscope, Timer, Trash2, @@ -70,7 +73,9 @@ import { messageFromError, addScheduleItem, deleteScheduleItem, + resetAllPlayouts, replaceScheduleItems, + usePlayoutsScreenQuery, useScheduleScreenQuery, useDashboardHealthQuery, useDashboardQuery, @@ -84,6 +89,8 @@ import { type MediaCollection, type ProgramSchedule, type ProgramScheduleItem, + type PlayoutItem, + type PlayoutSummary, type ScheduleItemRequest } from './api'; import { @@ -1813,6 +1820,326 @@ function formatScheduleCollectionType(value: string | undefined): string { return formatScheduleEnum(value ?? 'Collection'); } +function PlayoutsLoadingState() { + return ( + Loading playouts} subtitle="Fetching playouts, channel state, and upcoming items."> +
+ + Loading playouts +
+
+ ); +} + +function PlayoutsErrorState({ error, refresh }: { error: string; refresh: () => void }) { + return ( + Playouts unavailable} + subtitle="The API returned an error while loading the monitor." + actions={} + > +
+
+
+ ); +} + +function PlayoutsEmptyState() { + return ( + No playouts returned} subtitle="Create a channel playout before using the runtime monitor."> +
No playouts returned
+
+ ); +} + +function PlayoutsScreen() { + const query = usePlayoutsScreenQuery(); + const [filter, setFilter] = useState(''); + const [mutationError, setMutationError] = useState(null); + const [mutating, setMutating] = useState(false); + const mutatingRef = useRef(false); + + if (query.status === 'loading') { + return ; + } + + if (query.status === 'error') { + return ; + } + + const { channelStates, items, playout, playouts, selectedPlayoutId, totalCount, warningsCount } = query.data; + const selectedSummary = playouts.find((candidate) => candidate.id === selectedPlayoutId) ?? playouts[0] ?? null; + + if (!selectedSummary) { + return ; + } + + const selectedState = channelStates.find((state) => state.channelNumber === selectedSummary.channelNumber); + const nowPlaying = selectedState?.nowPlaying ?? null; + const nowItem = itemMatchingNow(items, nowPlaying?.title) ?? items[0] ?? null; + const nextItem = nextPlayoutItem(items, nowItem); + const filteredPlayouts = filterPlayouts(playouts, filter); + + const setMutatingState = (value: boolean) => { + mutatingRef.current = value; + setMutating(value); + }; + + const resetAll = () => { + if (mutatingRef.current || !window.confirm('Reset all playouts?')) { + return; + } + + setMutationError(null); + setMutatingState(true); + resetAllPlayouts() + .then(() => { + query.refresh(); + }) + .catch((error: unknown) => { + setMutationError(messageFromError(error)); + }) + .finally(() => { + setMutatingState(false); + }); + }; + + return ( +
+
+ + + 0 ? 'warn' : 'neutral'} dot={warningsCount > 0}>{warningsCount} warning{warningsCount === 1 ? '' : 's'} + +
+ + {mutationError && ( +
+
+ )} + +
+ + +
+
+ +
+ {selectedSummary.channelNumber} {selectedState?.onAir && On air} +

{selectedSummary.channelName}

+
+
+ +
+
+
+
+ On air now +

{nowPlaying?.title ?? nowItem?.title ?? 'No current item reported'}

+ +
+ {formatDateTime(nowPlaying?.startUtc ?? nowItem?.start)} + {formatDateTime(nowPlaying?.finishUtc ?? nowItem?.finish)} +
+
+
+ +
+ +
+
+
+ +
+ + + + +
+
+
+ + + + + + +
+ {items.length === 0 ? ( +
No upcoming items
+ ) : ( + items.map((item, index) => ( +
+ {formatDateTime(item.start)} + {item.fillerKind ?
+ )) + )} +
+
+
+
+
+ ); +} + +function PlayoutTimeline({ items, nowItem }: { items: PlayoutItem[]; nowItem: PlayoutItem | null }) { + if (items.length === 0) { + return
No timeline items
; + } + + const firstStart = Date.parse(items[0].start); + const lastFinish = Date.parse(items[items.length - 1].finish); + const span = Math.max(lastFinish - firstStart, 1); + + return ( +
+
+ {items.map((item, index) => { + const width = Math.max(((Date.parse(item.finish) - Date.parse(item.start)) / span) * 100, 2); + + return ( + + {width > 12 && (item.title ?? 'Untitled item')} + + ); + })} + {nowItem && } +
+

+ {formatDateTime(items[0].start)} + + {formatDateTime(items[items.length - 1].finish)} +

+
+ ); +} + +function filterPlayouts(playouts: PlayoutSummary[], filter: string): PlayoutSummary[] { + const normalized = filter.trim().toLowerCase(); + + if (!normalized) { + return playouts; + } + + return playouts.filter((playout) => + playout.channelName.toLowerCase().includes(normalized) || + playout.channelNumber.toLowerCase().includes(normalized) || + playout.scheduleName.toLowerCase().includes(normalized) + ); +} + +function itemMatchingNow(items: PlayoutItem[], title: string | null | undefined): PlayoutItem | null { + if (!title) { + return null; + } + + return items.find((item) => item.title === title) ?? null; +} + +function nextPlayoutItem(items: PlayoutItem[], nowItem: PlayoutItem | null): PlayoutItem | null { + const index = nowItem ? items.indexOf(nowItem) : -1; + return items[index + 1] ?? items[1] ?? null; +} + +function playoutProgress(start: string | null | undefined, finish: string | null | undefined): number { + if (!start || !finish) { + return 0; + } + + const startMs = Date.parse(start); + const finishMs = Date.parse(finish); + const nowMs = Date.now(); + + if (!Number.isFinite(startMs) || !Number.isFinite(finishMs) || finishMs <= startMs) { + return 0; + } + + return Math.min(100, Math.max(0, ((nowMs - startMs) / (finishMs - startMs)) * 100)); +} + +function timelinePosition(start: string, firstStart: number, span: number): number { + const startMs = Date.parse(start); + + if (!Number.isFinite(startMs)) { + return 0; + } + + return Math.min(100, Math.max(0, ((startMs - firstStart) / span) * 100)); +} + +function formatDateTime(value: string | null | undefined): string { + if (!value) { + return 'unknown'; + } + + const parsed = new Date(value); + + if (Number.isNaN(parsed.getTime())) { + return value.slice(0, 5); + } + + return parsed.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +} + +function formatDailyRebuild(value: string | null | undefined): string { + return value ? value.slice(0, 5) : 'manual'; +} + function pickerOptions(items: MediaCollection[]): Array<{ label: string; value: string }> { return items.map((item) => ({ label: item.name ?? `Collection ${item.id}`, value: `${item.id}` })); } @@ -1893,6 +2220,10 @@ function ScreenContent({ return ; } + if (route.id === 'playouts') { + return ; + } + return ; } diff --git a/web/src/api/index.ts b/web/src/api/index.ts index 60e0b0df0..f96fc41cf 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -2,5 +2,6 @@ export * from './auth'; export * from './channels'; export * from './client'; export * from './dashboard'; +export * from './playouts'; export * from './schedules'; export * from './useChannelsQuery'; diff --git a/web/src/api/playouts.ts b/web/src/api/playouts.ts new file mode 100644 index 000000000..0fe644070 --- /dev/null +++ b/web/src/api/playouts.ts @@ -0,0 +1,285 @@ +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; +} + +export type PlayoutsScreenQueryState = + | { + data: PlayoutsScreenData; + error: null; + refresh: () => void; + setActivePlayout: (playoutId: number) => void; + 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: null; error: string; status: 'error' } + | { data: null; error: null; status: 'loading' }; + +export function getPlayouts(): Promise { + return request('/api/playouts'); +} + +export function getPlayout(playoutId: number): Promise { + return request(`/api/playouts/${playoutId}`); +} + +export function getPlayoutItems(playoutId: number): Promise { + return request(`/api/playouts/${playoutId}/items`); +} + +export function getPlayoutWarningsCount(): Promise { + return request('/api/playouts/warnings/count'); +} + +export function getPlayoutChannelStates(): Promise { + return request('/api/channels/state'); +} + +export function resetAllPlayouts(): Promise { + return request('/api/playouts/reset-all', { method: 'POST' }); +} + +export function usePlayoutsScreenQuery(pollMs = 30000): PlayoutsScreenQueryState { + const [state, setState] = useState({ + data: null, + error: null, + status: 'loading' + }); + const activeRef = useRef(true); + const selectedPlayoutIdRef = useRef(null); + + useEffect(() => { + activeRef.current = true; + + return () => { + activeRef.current = false; + }; + }, []); + + const loadSelectedPlayout = useCallback((playoutId: number, base: Omit) => { + Promise.all([getPlayout(playoutId), getPlayoutItems(playoutId)]) + .then(([playout, itemsPage]) => { + if (!activeRef.current || selectedPlayoutIdRef.current !== playoutId) { + return; + } + + setState({ + data: { + ...base, + items: itemsPage.page ?? [], + playout, + selectedPlayoutId: playoutId + }, + error: null, + status: 'success' + }); + }) + .catch((error: unknown) => { + if (activeRef.current && selectedPlayoutIdRef.current === playoutId) { + setState({ data: null, error: messageFromPlayoutError(error), status: 'error' }); + } + }); + }, []); + + const load = useCallback((showLoading = true) => { + if (showLoading) { + setState({ data: null, error: null, status: 'loading' }); + } + + 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 = { + 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' }); + } + }); + }, [loadSelectedPlayout]); + + const loadChannelStates = useCallback(() => { + getPlayoutChannelStates() + .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 monitor visible on background polling failures. + }); + }, []); + + 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' }); + } + }); + + const intervalId = window.setInterval(loadChannelStates, pollMs); + + return () => { + window.clearInterval(intervalId); + }; + }, [loadChannelStates, loadSelectedPlayout, pollMs]); + + const refresh = useCallback(() => { + 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, + status: 'success' + }; + }); + + 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; + }); + }, [loadSelectedPlayout]); + + if (state.status === 'success') { + return { data: state.data, error: null, refresh, setActivePlayout, 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; +} diff --git a/web/src/shell.css b/web/src/shell.css index 0f3d98be8..c8c3a8b6f 100644 --- a/web/src/shell.css +++ b/web/src/shell.css @@ -1308,6 +1308,348 @@ line-height: 1.4; } +.ctv-playouts-screen { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + gap: var(--space-6, 12px); +} + +.ctv-playouts-header { + display: flex; + align-items: center; + gap: var(--space-5, 10px); + border: 1px solid var(--border-hairline); + border-radius: var(--radius-md, 7px); + background: var(--surface-card); + padding: var(--space-6, 12px) var(--space-8, 20px); +} + +.ctv-playouts-header-spacer { + flex: 1; +} + +.ctv-playouts-grid { + display: grid; + grid-template-columns: 260px minmax(0, 1fr); + gap: var(--space-8, 20px); + flex: 1; + min-height: 0; +} + +.ctv-playouts-rail, +.ctv-playouts-monitor { + min-height: 0; + border: 1px solid var(--border-hairline); + border-radius: var(--radius-md, 7px); + background: var(--surface-card); +} + +.ctv-playouts-rail { + display: flex; + flex-direction: column; + overflow: hidden; +} + +.ctv-playouts-filter { + display: grid; + gap: var(--space-4, 8px); + border-bottom: 1px solid var(--border-hairline); + padding: var(--space-6, 12px); +} + +.ctv-playouts-filter > span { + color: var(--text-disabled); + font-size: var(--text-2xs, 11px); +} + +.ctv-playouts-list { + display: grid; + align-content: start; + gap: var(--space-2, 4px); + overflow: auto; + padding: var(--space-4, 8px); +} + +.ctv-playout-option { + display: grid; + grid-template-columns: 30px minmax(0, 1fr) 10px; + align-items: center; + gap: var(--space-5, 10px); + width: 100%; + border: 0; + border-radius: var(--radius-sm, 5px); + background: transparent; + color: inherit; + cursor: pointer; + padding: var(--space-4, 8px); + text-align: left; +} + +.ctv-playout-option:hover, +.ctv-playout-option-active { + background: var(--ctv-accent-soft); +} + +.ctv-playout-option > span { + display: grid; + min-width: 0; + gap: var(--space-2, 4px); +} + +.ctv-playout-option code, +.ctv-playouts-title code, +.ctv-playout-now-copy code, +.ctv-playout-upcoming code, +.ctv-playout-upcoming small, +.ctv-playout-timeline code { + font-family: var(--font-mono, ui-monospace, monospace); + font-variant-numeric: tabular-nums; +} + +.ctv-playout-option code { + color: var(--status-live); + font-size: var(--text-2xs, 11px); +} + +.ctv-playout-option strong, +.ctv-playout-next strong { + overflow: hidden; + color: var(--text-primary); + font-size: var(--text-sm, 13px); + font-weight: var(--weight-semibold, 600); + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ctv-playout-option small, +.ctv-playout-next small { + overflow: hidden; + color: var(--text-secondary); + font-size: var(--text-2xs, 11px); + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ctv-playouts-monitor { + display: flex; + flex-direction: column; + gap: var(--space-7, 16px); + overflow: auto; + padding: var(--space-8, 20px); +} + +.ctv-playouts-title { + display: flex; + align-items: center; + gap: var(--space-6, 12px); +} + +.ctv-playouts-title > div { + display: grid; + min-width: 0; + gap: var(--space-2, 4px); +} + +.ctv-playouts-title span { + display: flex; + align-items: center; + gap: var(--space-4, 8px); + color: var(--text-secondary); + font-size: var(--text-xs, 12px); +} + +.ctv-playouts-title h2, +.ctv-playout-now-copy h3 { + margin: 0; + color: var(--text-primary); + font-weight: var(--weight-semibold, 600); + line-height: 1.2; +} + +.ctv-playouts-title h2 { + font-size: var(--text-md, 14px); +} + +.ctv-playout-now { + display: grid; + grid-template-columns: 210px minmax(0, 1fr); + overflow: hidden; + border: 1px solid var(--border-hairline); + border-radius: var(--radius-md, 7px); + background: var(--ctv-surface-1); +} + +.ctv-playout-preview { + display: flex; + position: relative; + align-items: center; + justify-content: center; + min-height: 150px; + border-right: 1px solid var(--border-hairline); + background: var(--ctv-bg-sunken); + color: var(--ctv-accent); +} + +.ctv-playout-preview .ctv-badge { + position: absolute; + left: var(--space-5, 10px); + bottom: var(--space-5, 10px); +} + +.ctv-playout-now-copy { + display: flex; + justify-content: center; + flex-direction: column; + gap: var(--space-5, 10px); + min-width: 0; + padding: var(--space-8, 20px); +} + +.ctv-playout-now-copy > span { + color: var(--status-live); + font-size: var(--text-2xs, 11px); + font-weight: var(--weight-semibold, 600); + text-transform: uppercase; +} + +.ctv-playout-now-copy h3 { + font-size: var(--text-lg, 18px); +} + +.ctv-playout-now-copy > div:last-child { + display: flex; + justify-content: space-between; + gap: var(--space-6, 12px); + color: var(--text-disabled); + font-size: var(--text-2xs, 11px); +} + +.ctv-playouts-cards { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-7, 16px); +} + +.ctv-playout-next { + display: flex; + align-items: center; + gap: var(--space-6, 12px); +} + +.ctv-playout-next > svg { + color: var(--ctv-accent); + flex: 0 0 auto; +} + +.ctv-playout-next > span { + display: grid; + min-width: 0; + gap: var(--space-2, 4px); +} + +.ctv-playout-detail-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-5, 10px); +} + +.ctv-playout-timeline { + display: grid; + gap: var(--space-4, 8px); +} + +.ctv-playout-timeline > div { + display: flex; + position: relative; + overflow: hidden; + height: 34px; + border: 1px solid var(--border-hairline); + border-radius: var(--radius-sm, 5px); + background: var(--ctv-bg-sunken); +} + +.ctv-playout-timeline span { + display: flex; + align-items: center; + overflow: hidden; + border-right: 1px solid var(--ctv-bg); + background: var(--ctv-accent-soft); + color: var(--ctv-accent); + font-size: var(--text-2xs, 11px); + font-weight: var(--weight-medium, 500); + padding: 0 var(--space-4, 8px); + text-overflow: ellipsis; + white-space: nowrap; +} + +.ctv-playout-timeline .ctv-playout-timeline-filler { + background: var(--ctv-surface-3); + color: var(--text-secondary); +} + +.ctv-playout-timeline i { + position: absolute; + top: -3px; + bottom: -3px; + width: 2px; + background: var(--status-live); + box-shadow: 0 0 8px var(--status-live); +} + +.ctv-playout-timeline p { + display: flex; + justify-content: space-between; + margin: 0; + color: var(--text-disabled); + font-size: var(--text-2xs, 11px); +} + +.ctv-playout-timeline p span { + display: inline-flex; + align-items: center; + gap: var(--space-2, 4px); + color: var(--status-live); +} + +.ctv-playout-upcoming > div { + display: grid; + grid-template-columns: 58px 14px minmax(0, 1fr) auto 64px; + align-items: center; + gap: var(--space-5, 10px); + border-top: 1px solid var(--border-hairline); + padding: var(--space-5, 10px) var(--space-7, 16px); +} + +.ctv-playout-upcoming > div:first-child { + border-top: 0; +} + +.ctv-playout-upcoming-now { + background: var(--ctv-live-soft); +} + +.ctv-playout-upcoming svg { + color: var(--ctv-accent); +} + +.ctv-playout-upcoming span { + overflow: hidden; + color: var(--text-primary); + font-size: var(--text-sm, 13px); + text-overflow: ellipsis; + white-space: nowrap; +} + +.ctv-playout-upcoming code, +.ctv-playout-upcoming small { + color: var(--text-disabled); + font-size: var(--text-2xs, 11px); +} + @media (max-width: 980px) { .ctv-app-shell { grid-template-columns: 1fr; -- 2.47.3 From c4190e3a54fac39d6b3bc9b5534b5f82ed060778 Mon Sep 17 00:00:00 2001 From: Timothy Date: Sun, 5 Jul 2026 17:45:01 +0200 Subject: [PATCH 2/2] fix(web): playouts monitor review fixes for #87 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- web/src/App.test.tsx | 165 +++++++++++++++++++++++++++++++------ web/src/App.tsx | 74 ++++++++++++++--- web/src/api/playouts.ts | 178 ++++++++++++++++++++++++---------------- web/src/shell.css | 9 +- 4 files changed, 316 insertions(+), 110 deletions(-) diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 0ff8d6fdb..53395d23d 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -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(); + + 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(); + // 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(); @@ -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(); @@ -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(); @@ -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(); + + 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(); + + 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 = {}): Record = {}): Record { +// PlayoutListItemResponseModel: rail rows have no detail-only fields (playoutMode/scheduleFile). +function listPlayout(overrides: Record = {}): Record { return { buildStatus: { lastBuild: '2026-07-05T19:00:00Z', @@ -1366,18 +1456,26 @@ function playout(overrides: Record = {}): Record = {}): Record { + return listPlayout({ + playoutMode: 'Continuous', + scheduleFile: null, + ...overrides + }); +} + +// The backend always emits fillerKind: real content items carry 'None' (never null). function playoutItem(overrides: Record = {}): Record { 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(\?(?.*))?$/); + + 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 })); } diff --git a/web/src/App.tsx b/web/src/App.tsx index 1bb4dd9ae..907921ea6 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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} /> - {totalCount} playout{totalCount === 1 ? '' : 's'} loaded + {playoutsCountLabel(filteredPlayouts.length, totalCount)}
{filteredPlayouts.map((candidate) => { @@ -1943,6 +1945,7 @@ function PlayoutsScreen() {
+
+ + + Per-playout reset is deferred โ€” the API has no per-playout reset endpoint yet. +
- - + : undefined}> + - + + {itemsLoading && } + + + } + >
{items.length === 0 ? ( -
No upcoming items
+
{itemsLoading ? : 'No upcoming items'}
) : ( items.map((item, index) => (
{formatDateTime(item.start)} - {item.fillerKind ?
)) @@ -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
No timeline items
; + return ( +
+ {itemsLoading ? : 'No timeline items'} +
+ ); } const firstStart = Date.parse(items[0].start); @@ -2049,7 +2077,7 @@ function PlayoutTimeline({ items, nowItem }: { items: PlayoutItem[]; nowItem: Pl return ( { + 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; } diff --git a/web/src/api/playouts.ts b/web/src/api/playouts.ts index 0fe644070..f8be4bef9 100644 --- a/web/src/api/playouts.ts +++ b/web/src/api/playouts.ts @@ -19,19 +19,24 @@ export interface PlayoutsScreenData { warningsCount: number; } +type PlayoutsScreenBase = Omit; + 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 { return request(`/api/playouts/${playoutId}`); } -export function getPlayoutItems(playoutId: number): Promise { - return request(`/api/playouts/${playoutId}/items`); +export function getPlayoutItems(playoutId: number, showFiller = false): Promise { + return request(`/api/playouts/${playoutId}/items${showFiller ? '?showFiller=true' : ''}`); } export function getPlayoutWarningsCount(): Promise { @@ -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(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({ + 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) => { - 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') { diff --git a/web/src/shell.css b/web/src/shell.css index c8c3a8b6f..6e44a4428 100644 --- a/web/src/shell.css +++ b/web/src/shell.css @@ -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; } -- 2.47.3