diff --git a/web/src/App.tsx b/web/src/App.tsx
index f697bd3fb..9274a81c9 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -78,7 +78,11 @@ import { BlocksScreen } from './screens/BlocksScreen';
import { DecosScreen } from './screens/DecosScreen';
import { DecoTemplatesScreen } from './screens/DecoTemplatesScreen';
import { TemplatesScreen } from './screens/TemplatesScreen';
-import { navigateToPath } from './routing';
+import {
+ PlayoutAlternateSchedulesScreen,
+ PlayoutTemplatesEditorScreen
+} from './screens/PlayoutScheduleEditors';
+import { navigateToPath, parsePlayoutSubRoute } from './routing';
import {
Badge,
Button,
@@ -106,12 +110,14 @@ import {
bulkRenumberChannels,
createPlayout,
deleteChannel,
+ getDecos,
messageFromError,
addScheduleItem,
deleteScheduleItem,
getSchedules,
resetAllPlayouts,
replaceScheduleItems,
+ updatePlayoutDefaultDeco,
updatePlayoutDetails,
usePlayoutsScreenQuery,
useScheduleScreenQuery,
@@ -128,6 +134,7 @@ import {
type ChannelGuideProgramme,
type CreatePlayoutRequest,
type DashboardChannel,
+ type DecoListItem,
type SchedulePickerData,
type DashboardChannelState,
type DashboardHealthQueryState,
@@ -315,6 +322,8 @@ const routes: ScreenRoute[] = [
allowSubPaths: true
},
{
+ // The alternate-schedules / templates editors live at sub-paths
+ // (/app/playouts/{id}/alternate-schedules, /app/playouts/{id}/templates).
id: 'playouts',
path: '/app/playouts',
label: 'Playouts',
@@ -324,7 +333,8 @@ const routes: ScreenRoute[] = [
icon: ,
primaryAction: 'Reset All',
placeholder: 'Playouts workspace',
- badge: 3
+ badge: 3,
+ allowSubPaths: true
},
{
id: 'media',
@@ -3143,6 +3153,31 @@ function PlayoutsScreen() {
+ {selectedSummary.scheduleKind === 'Classic' && (
+
+
+
+ )}
+ {selectedSummary.scheduleKind === 'Block' && playout && (
+
+
+
+
+ )}
} variant="secondary">Reset
} variant="ghost">Schedule reset
@@ -3215,6 +3250,70 @@ function PlayoutsScreen() {
);
}
+function PlayoutDefaultDecoField({ playout, onSaved }: { playout: PlayoutDetail; onSaved: () => void }) {
+ const [decos, setDecos] = useState
(null);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState(null);
+ const activeRef = useRef(true);
+
+ useEffect(() => {
+ activeRef.current = true;
+ getDecos()
+ .then((list) => {
+ if (activeRef.current) {
+ setDecos(list.filter((deco) => deco.id > 0));
+ }
+ })
+ .catch(() => {
+ if (activeRef.current) {
+ setDecos([]);
+ }
+ });
+ return () => {
+ activeRef.current = false;
+ };
+ }, []);
+
+ const options = [
+ { label: 'None (no default deco)', value: '' },
+ ...(decos ?? [])
+ .slice()
+ .sort((a, b) => `${a.decoGroupName}${a.name}`.localeCompare(`${b.decoGroupName}${b.name}`))
+ .map((deco) => ({ label: `${deco.decoGroupName} / ${deco.name}`, value: String(deco.id) }))
+ ];
+
+ return (
+
+
+ );
+}
+
function PlayoutTimeline({ items, itemsLoading, nowItem }: { items: PlayoutItem[]; itemsLoading: boolean; nowItem: PlayoutItem | null }) {
if (items.length === 0) {
return (
@@ -3462,6 +3561,13 @@ function ScreenContent({
}
if (route.id === 'playouts') {
+ const sub = parsePlayoutSubRoute(window.location.pathname);
+ if (sub?.kind === 'alternate-schedules') {
+ return ;
+ }
+ if (sub?.kind === 'templates') {
+ return ;
+ }
return ;
}
diff --git a/web/src/api/playouts.test.ts b/web/src/api/playouts.test.ts
index eab230162..e42ff9ad5 100644
--- a/web/src/api/playouts.test.ts
+++ b/web/src/api/playouts.test.ts
@@ -1,5 +1,14 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
-import { createPlayout, updatePlayoutDetails, type CreatePlayoutRequest } from './playouts';
+import {
+ createPlayout,
+ getAlternateSchedules,
+ getPlayoutTemplates,
+ replaceAlternateSchedules,
+ replacePlayoutTemplates,
+ updatePlayoutDefaultDeco,
+ updatePlayoutDetails,
+ type CreatePlayoutRequest
+} from './playouts';
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
@@ -75,4 +84,95 @@ describe('playouts api client', () => {
const [, init] = fetchMock.mock.calls[0];
expect(JSON.parse(String(init?.body))).toMatchObject({ dailyRebuildTime: null });
});
+
+ it('updatePlayoutDefaultDeco PUTs the deco id to the deco route', async () => {
+ const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 3 }));
+
+ await updatePlayoutDefaultDeco(3, 7);
+
+ const [url, init] = fetchMock.mock.calls[0];
+ expect(url).toBe('/api/playouts/3/deco');
+ expect(init).toMatchObject({ method: 'PUT' });
+ expect(JSON.parse(String(init?.body))).toMatchObject({ decoId: 7 });
+ });
+
+ it('getAlternateSchedules GETs the alternate-schedules route', async () => {
+ const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse([]));
+
+ await getAlternateSchedules(5);
+
+ const [url, init] = fetchMock.mock.calls[0];
+ expect(url).toBe('/api/playouts/5/alternate-schedules');
+ expect(init?.method ?? 'GET').toBe('GET');
+ });
+
+ it('replaceAlternateSchedules PUTs items with day-name strings', async () => {
+ const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse([]));
+
+ await replaceAlternateSchedules(5, {
+ items: [
+ {
+ id: 0,
+ programScheduleId: 2,
+ daysOfWeek: ['Monday', 'Tuesday'],
+ daysOfMonth: [1, 2],
+ monthsOfYear: [1],
+ limitToDateRange: false,
+ startMonth: 1,
+ startDay: 1,
+ startYear: null,
+ endMonth: 12,
+ endDay: 31,
+ endYear: null
+ }
+ ]
+ });
+
+ const [url, init] = fetchMock.mock.calls[0];
+ expect(url).toBe('/api/playouts/5/alternate-schedules');
+ expect(init).toMatchObject({ method: 'PUT' });
+ const body = JSON.parse(String(init?.body));
+ expect(body.items[0].daysOfWeek).toEqual(['Monday', 'Tuesday']);
+ expect(body.items[0].programScheduleId).toBe(2);
+ });
+
+ it('getPlayoutTemplates GETs the templates route', async () => {
+ const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse([]));
+
+ await getPlayoutTemplates(5);
+
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toBe('/api/playouts/5/templates');
+ });
+
+ it('replacePlayoutTemplates PUTs template items', async () => {
+ const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse([]));
+
+ await replacePlayoutTemplates(5, {
+ items: [
+ {
+ id: 0,
+ templateId: 3,
+ decoTemplateId: null,
+ daysOfWeek: ['Sunday'],
+ daysOfMonth: [1],
+ monthsOfYear: [1],
+ limitToDateRange: false,
+ startMonth: 1,
+ startDay: 1,
+ startYear: null,
+ endMonth: 12,
+ endDay: 31,
+ endYear: null
+ }
+ ]
+ });
+
+ const [url, init] = fetchMock.mock.calls[0];
+ expect(url).toBe('/api/playouts/5/templates');
+ expect(init).toMatchObject({ method: 'PUT' });
+ const body = JSON.parse(String(init?.body));
+ expect(body.items[0].templateId).toBe(3);
+ expect(body.items[0].decoTemplateId).toBeNull();
+ });
});
diff --git a/web/src/api/playouts.ts b/web/src/api/playouts.ts
index f0dd7f473..0d93f6390 100644
--- a/web/src/api/playouts.ts
+++ b/web/src/api/playouts.ts
@@ -12,6 +12,49 @@ export type PlayoutScheduleKind = components['schemas']['PlayoutScheduleKind'];
export type CreatePlayoutRequest = components['schemas']['CreatePlayoutRequest'];
export type UpdatePlayoutDetailsRequest = components['schemas']['UpdatePlayoutDetailsRequest'];
+// The OpenAPI generator types DayOfWeek as `number` (the OpenAPI schema is derived from
+// System.Text.Json metadata), but the MVC pipeline serializes with Newtonsoft + StringEnumConverter,
+// so DayOfWeek is a day-name string on the wire ("Sunday" … "Saturday"). We override the generated
+// `daysOfWeek` shape accordingly on both request and response DTOs.
+export type DayOfWeek =
+ | 'Sunday'
+ | 'Monday'
+ | 'Tuesday'
+ | 'Wednesday'
+ | 'Thursday'
+ | 'Friday'
+ | 'Saturday';
+
+// Monday-first, matching the Blazor editor's ordering.
+export const DAYS_OF_WEEK: DayOfWeek[] = [
+ 'Monday',
+ 'Tuesday',
+ 'Wednesday',
+ 'Thursday',
+ 'Friday',
+ 'Saturday',
+ 'Sunday'
+];
+
+type WithDayNames = Omit & { daysOfWeek: DayOfWeek[] };
+
+export type PlayoutAlternateSchedule = WithDayNames;
+export type PlayoutAlternateScheduleItemRequest = WithDayNames<
+ components['schemas']['PlayoutAlternateScheduleItemRequest']
+>;
+export type PlayoutTemplate = WithDayNames;
+export type PlayoutTemplateItemRequest = WithDayNames;
+
+// Declared as `type` (not `interface`) so they satisfy the client's `Record`
+// RequestBody bound, matching the generated request DTOs.
+export type ReplacePlayoutAlternateSchedulesRequest = {
+ items: PlayoutAlternateScheduleItemRequest[];
+};
+
+export type ReplacePlayoutTemplatesRequest = {
+ items: PlayoutTemplateItemRequest[];
+};
+
export interface PlayoutsScreenData {
channelStates: PlayoutChannelState[];
items: PlayoutItem[];
@@ -81,6 +124,45 @@ export function updatePlayoutDetails(playoutId: number, body: UpdatePlayoutDetai
});
}
+export function updatePlayoutDefaultDeco(playoutId: number, decoId: number | null): Promise {
+ return request(`/api/playouts/${playoutId}/deco`, {
+ body: { decoId },
+ method: 'PUT'
+ });
+}
+
+export function getAlternateSchedules(playoutId: number): Promise {
+ return request(`/api/playouts/${playoutId}/alternate-schedules`);
+}
+
+export function replaceAlternateSchedules(
+ playoutId: number,
+ body: ReplacePlayoutAlternateSchedulesRequest
+): Promise {
+ return request(`/api/playouts/${playoutId}/alternate-schedules`, {
+ body,
+ method: 'PUT'
+ });
+}
+
+export function getPlayoutTemplates(playoutId: number): Promise {
+ return request(`/api/playouts/${playoutId}/templates`);
+}
+
+export function replacePlayoutTemplates(
+ playoutId: number,
+ body: ReplacePlayoutTemplatesRequest
+): Promise {
+ return request(`/api/playouts/${playoutId}/templates`, {
+ body,
+ method: 'PUT'
+ });
+}
+
+export function messageFromPlayoutClientError(error: unknown, fallback = 'Unable to load playout'): string {
+ return messageFromPlayoutError(error, fallback);
+}
+
export function usePlayoutsScreenQuery(pollMs = 30000): PlayoutsScreenQueryState {
const [state, setState] = useState({
data: null,
diff --git a/web/src/routing.ts b/web/src/routing.ts
index 7e01538f0..7673ffe9c 100644
--- a/web/src/routing.ts
+++ b/web/src/routing.ts
@@ -5,3 +5,31 @@ export function navigateToPath(path: string) {
window.history.pushState(null, '', path);
window.dispatchEvent(new PopStateEvent('popstate'));
}
+
+// The Playouts screen owns two sub-path editors: /app/playouts/{id}/alternate-schedules (classic)
+// and /app/playouts/{id}/templates (block). Parsing lives here (not in the screen module) so the
+// screen file only exports components (react-refresh) while App.tsx's render switch can dispatch.
+export type PlayoutSubRoute = { id: number; kind: 'alternate-schedules' | 'templates' };
+
+export function parsePlayoutSubRoute(pathname: string): PlayoutSubRoute | null {
+ const base = '/app/playouts';
+ const normalized = pathname.replace(/\/+$/, '');
+ if (!normalized.startsWith(`${base}/`)) {
+ return null;
+ }
+ const parts = normalized.slice(base.length + 1).split('/');
+ if (parts.length !== 2) {
+ return null;
+ }
+ const id = Number(parts[0]);
+ if (!Number.isInteger(id) || id <= 0) {
+ return null;
+ }
+ if (parts[1] === 'alternate-schedules') {
+ return { id, kind: 'alternate-schedules' };
+ }
+ if (parts[1] === 'templates') {
+ return { id, kind: 'templates' };
+ }
+ return null;
+}
diff --git a/web/src/screens/PlayoutScheduleEditors.test.tsx b/web/src/screens/PlayoutScheduleEditors.test.tsx
new file mode 100644
index 000000000..a5db085eb
--- /dev/null
+++ b/web/src/screens/PlayoutScheduleEditors.test.tsx
@@ -0,0 +1,196 @@
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { PlayoutAlternateSchedulesScreen, PlayoutTemplatesEditorScreen } from './PlayoutScheduleEditors';
+
+function jsonResponse(body: unknown, status = 200): Response {
+ return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status });
+}
+
+const ALL_DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
+const ALL_DOM = Array.from({ length: 31 }, (_, index) => index + 1);
+const ALL_MONTHS = Array.from({ length: 12 }, (_, index) => index + 1);
+
+function recurrence(overrides: Record = {}) {
+ return {
+ daysOfWeek: [...ALL_DAYS],
+ daysOfMonth: [...ALL_DOM],
+ monthsOfYear: [...ALL_MONTHS],
+ limitToDateRange: false,
+ startMonth: 1,
+ startDay: 1,
+ startYear: null,
+ endMonth: 12,
+ endDay: 31,
+ endYear: null,
+ ...overrides
+ };
+}
+
+const classicPlayout = { id: 5, channelName: 'Kids', scheduleKind: 'Classic', decoId: null, decoName: null };
+const blockPlayout = { id: 5, channelName: 'Kids', scheduleKind: 'Block', decoId: null, decoName: null };
+
+const schedules = [
+ { id: 7, name: 'Alpha' },
+ { id: 8, name: 'Beta' }
+];
+
+const alternates = [
+ { id: 1, index: 0, programScheduleId: 7, ...recurrence({ daysOfWeek: ['Monday'] }) },
+ { id: 2, index: 1, programScheduleId: 8, ...recurrence() }
+];
+
+function mockAltApi(onRequest?: (url: string, method: string, body: unknown) => Response | null) {
+ return vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
+ const url = input.toString();
+ const method = (init?.method ?? 'GET').toUpperCase();
+ const body = init?.body ? JSON.parse(String(init.body)) : undefined;
+
+ const override = onRequest?.(url, method, body);
+ if (override) {
+ return Promise.resolve(override);
+ }
+
+ if (url === '/api/playouts/5' && method === 'GET') {
+ return Promise.resolve(jsonResponse(classicPlayout));
+ }
+ if (url === '/api/playouts/5/alternate-schedules' && method === 'GET') {
+ return Promise.resolve(jsonResponse(alternates));
+ }
+ if (url === '/api/schedules' && method === 'GET') {
+ return Promise.resolve(jsonResponse(schedules));
+ }
+ if (url === '/api/playouts/5/alternate-schedules' && method === 'PUT') {
+ return Promise.resolve(jsonResponse([]));
+ }
+
+ return Promise.resolve(new Response(null, { status: 204 }));
+ });
+}
+
+const templates = [{ id: 3, templateGroupId: 1, groupName: 'Grp', name: 'Weekdays' }];
+const playoutTemplates = [
+ {
+ id: 1,
+ index: 0,
+ templateId: 3,
+ templateName: 'Weekdays',
+ templateGroupName: 'Grp',
+ decoTemplateId: null,
+ decoTemplateName: null,
+ decoTemplateGroupName: null,
+ ...recurrence()
+ }
+];
+
+function mockTemplatesApi() {
+ return vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
+ const url = input.toString();
+ const method = (init?.method ?? 'GET').toUpperCase();
+
+ if (url === '/api/playouts/5' && method === 'GET') {
+ return Promise.resolve(jsonResponse(blockPlayout));
+ }
+ if (url === '/api/playouts/5/templates' && method === 'GET') {
+ return Promise.resolve(jsonResponse(playoutTemplates));
+ }
+ if (url === '/api/templates' && method === 'GET') {
+ return Promise.resolve(jsonResponse(templates));
+ }
+ if (url === '/api/deco-templates' && method === 'GET') {
+ return Promise.resolve(jsonResponse([]));
+ }
+ if (url === '/api/playouts/5/templates' && method === 'PUT') {
+ return Promise.resolve(jsonResponse([]));
+ }
+
+ return Promise.resolve(new Response(null, { status: 204 }));
+ });
+}
+
+describe('PlayoutAlternateSchedulesScreen', () => {
+ afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ });
+
+ beforeEach(() => {
+ window.localStorage.clear();
+ });
+
+ it('loads the alternate schedules and marks the last row as the default', async () => {
+ mockAltApi();
+ render();
+
+ expect(await screen.findByText('Alpha')).toBeInTheDocument();
+ expect(screen.getByText('Beta')).toBeInTheDocument();
+ expect(screen.getByText('Default')).toBeInTheDocument();
+ });
+
+ it('saves items in display order (index derived from array position)', async () => {
+ const fetchMock = mockAltApi();
+ render();
+
+ await screen.findByText('Alpha');
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }));
+
+ await waitFor(() => {
+ const call = fetchMock.mock.calls.find(
+ ([u, init]) => u === '/api/playouts/5/alternate-schedules' && init?.method === 'PUT'
+ );
+ expect(call).toBeTruthy();
+ });
+
+ const putCall = fetchMock.mock.calls.find(
+ ([u, init]) => u === '/api/playouts/5/alternate-schedules' && init?.method === 'PUT'
+ );
+ const sent = JSON.parse(String(putCall?.[1]?.body));
+ expect(sent.items.map((item: { programScheduleId: number }) => item.programScheduleId)).toEqual([7, 8]);
+ expect(sent.items[0].daysOfWeek).toEqual(['Monday']);
+ });
+
+ it('gates the start/end date pickers behind the limit-to-date-range checkbox', async () => {
+ mockAltApi();
+ render();
+
+ fireEvent.click(await screen.findByText('Alpha'));
+
+ expect(await screen.findByText('Edit schedule')).toBeInTheDocument();
+ expect(screen.queryByText('Start day')).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('checkbox'));
+
+ expect(await screen.findByText('Start day')).toBeInTheDocument();
+ expect(screen.getByText('End day')).toBeInTheDocument();
+ });
+});
+
+describe('PlayoutTemplatesEditorScreen', () => {
+ afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ });
+
+ beforeEach(() => {
+ window.localStorage.clear();
+ });
+
+ it('loads playout templates and enables save when every row has a template', async () => {
+ mockTemplatesApi();
+ render();
+
+ await waitFor(() => expect(screen.getAllByText('Weekdays').length).toBeGreaterThan(0));
+ expect(screen.getByRole('button', { name: 'Save' })).not.toBeDisabled();
+ });
+
+ it('disables save while a row is missing a template', async () => {
+ mockTemplatesApi();
+ render();
+
+ await waitFor(() => expect(screen.getAllByText('Weekdays').length).toBeGreaterThan(0));
+
+ fireEvent.click(screen.getByRole('button', { name: 'Add' }));
+
+ expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
+ expect(screen.getByText('Every row needs a template selected')).toBeInTheDocument();
+ });
+});
diff --git a/web/src/screens/PlayoutScheduleEditors.tsx b/web/src/screens/PlayoutScheduleEditors.tsx
new file mode 100644
index 000000000..f68213fcc
--- /dev/null
+++ b/web/src/screens/PlayoutScheduleEditors.tsx
@@ -0,0 +1,889 @@
+import { useEffect, useRef, useState } from 'react';
+import { ArrowDown, ArrowLeft, ArrowUp, Check, Plus, Trash2, TriangleAlert } from 'lucide-react';
+import { navigateToPath } from '../routing';
+import { Badge, Button, Card, Checkbox, IconButton, Select, Spinner } from '../components';
+import {
+ DAYS_OF_WEEK,
+ getAlternateSchedules,
+ getDecoTemplates,
+ getPlayout,
+ getPlayoutTemplates,
+ getSchedules,
+ getTemplates,
+ messageFromPlayoutClientError,
+ replaceAlternateSchedules,
+ replacePlayoutTemplates,
+ type DayOfWeek,
+ type DecoTemplate,
+ type PlayoutAlternateSchedule,
+ type PlayoutTemplate,
+ type ProgramSchedule,
+ type Template
+} from '../api';
+
+const PLAYOUTS_PATH = '/app/playouts';
+
+// The Blazor editors offer a 50-year forward range; a shorter window keeps the picker usable in the
+// SPA (deviation stated in the S6 handoff). "Any" (null) leaves the year unconstrained.
+const CURRENT_YEAR = new Date().getFullYear();
+const YEAR_OPTIONS = [
+ { label: 'Any', value: '' },
+ ...Array.from({ length: 12 }, (_, index) => {
+ const year = CURRENT_YEAR + index;
+ return { label: String(year), value: String(year) };
+ })
+];
+
+const DAYS_OF_MONTH = Array.from({ length: 31 }, (_, index) => index + 1);
+const MONTHS = Array.from({ length: 12 }, (_, index) => index + 1);
+const WEEKDAYS: DayOfWeek[] = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'];
+const WEEKENDS: DayOfWeek[] = ['Saturday', 'Sunday'];
+
+function monthName(month: number, style: 'long' | 'short'): string {
+ return new Date(2000, month - 1, 1).toLocaleString(undefined, { month: style });
+}
+
+function shortDayName(day: DayOfWeek): string {
+ return day.slice(0, 3);
+}
+
+const MONTH_OPTIONS = MONTHS.map((month) => ({ label: monthName(month, 'long'), value: String(month) }));
+const DAY_OF_MONTH_OPTIONS = DAYS_OF_MONTH.map((day) => ({ label: String(day), value: String(day) }));
+
+interface RecurrenceDraft {
+ daysOfWeek: DayOfWeek[];
+ daysOfMonth: number[];
+ monthsOfYear: number[];
+ limitToDateRange: boolean;
+ startMonth: number;
+ startDay: number;
+ startYear: number | null;
+ endMonth: number;
+ endDay: number;
+ endYear: number | null;
+}
+
+function newRecurrence(): RecurrenceDraft {
+ return {
+ daysOfWeek: [...DAYS_OF_WEEK],
+ daysOfMonth: [...DAYS_OF_MONTH],
+ monthsOfYear: [...MONTHS],
+ limitToDateRange: false,
+ startMonth: 1,
+ startDay: 1,
+ startYear: null,
+ endMonth: 12,
+ endDay: 31,
+ endYear: null
+ };
+}
+
+function toRequestRecurrence(draft: RecurrenceDraft) {
+ return {
+ daysOfWeek: draft.daysOfWeek,
+ daysOfMonth: draft.daysOfMonth,
+ monthsOfYear: draft.monthsOfYear,
+ limitToDateRange: draft.limitToDateRange,
+ startMonth: draft.startMonth,
+ startDay: draft.startDay,
+ startYear: draft.startYear,
+ endMonth: draft.endMonth,
+ endDay: draft.endDay,
+ endYear: draft.endYear
+ };
+}
+
+// Formatting helpers mirror the Blazor summary columns: a full or empty set reads as "any".
+function daysOfWeekSummary(days: DayOfWeek[]): string {
+ if (days.length === 0 || days.length === 7) {
+ return 'any';
+ }
+ const ordered = DAYS_OF_WEEK.filter((day) => days.includes(day));
+ return ordered.map(shortDayName).join(', ');
+}
+
+function rangeSummary(values: number[], fullCount: number): string {
+ if (values.length === 0 || values.length === fullCount) {
+ return 'any';
+ }
+ const sorted = [...new Set(values)].sort((a, b) => a - b);
+ const parts: string[] = [];
+ let start = sorted[0];
+ let prev = sorted[0];
+ for (let i = 1; i <= sorted.length; i += 1) {
+ if (i < sorted.length && sorted[i] === prev + 1) {
+ prev = sorted[i];
+ continue;
+ }
+ parts.push(start === prev ? String(start) : `${start}-${prev}`);
+ if (i < sorted.length) {
+ start = sorted[i];
+ prev = sorted[i];
+ }
+ }
+ return parts.join(', ');
+}
+
+function monthsSummary(months: number[]): string {
+ if (months.length === 0 || months.length === 12) {
+ return 'any';
+ }
+ return [...months]
+ .sort((a, b) => a - b)
+ .map((month) => monthName(month, 'short'))
+ .join(', ');
+}
+
+function toggle(list: T[], value: T): T[] {
+ return list.includes(value) ? list.filter((entry) => entry !== value) : [...list, value];
+}
+
+let keySeq = 0;
+function nextKey(): string {
+ keySeq += 1;
+ return `row-${keySeq}`;
+}
+
+// ----- Shared UI -----
+
+function EditorChrome({
+ channelName,
+ title,
+ hint,
+ saving,
+ saveDisabled,
+ saveError,
+ onAdd,
+ onSave,
+ children
+}: {
+ channelName: string;
+ title: string;
+ hint: string;
+ saving: boolean;
+ saveDisabled: boolean;
+ saveError: null | string;
+ onAdd: () => void;
+ onSave: () => void;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+
+ {channelName} / {title}
+
+
+ {saveError &&
{saveError}}
+
} variant="secondary">
+ Add
+
+
}
+ >
+ Save
+
+
+
+ {hint}
+
+ {children}
+
+ );
+}
+
+function ChipRow({
+ values,
+ options,
+ onToggle,
+ formatValue
+}: {
+ values: number[] | DayOfWeek[];
+ options: Array;
+ onToggle: (value: never) => void;
+ formatValue: (value: never) => string;
+}) {
+ return (
+
+ {options.map((option) => {
+ const active = (values as Array).includes(option);
+ return (
+
+ );
+ })}
+
+ );
+}
+
+function RecurrenceFields({
+ draft,
+ onChange
+}: {
+ draft: RecurrenceDraft;
+ onChange: (patch: Partial) => void;
+}) {
+ return (
+ <>
+
+
+
+ onChange({ limitToDateRange: next })} />
+
+
+
+ {draft.limitToDateRange && (
+ <>
+
+
+
+
+
+
+
+
+ onChange({ endMonth: Number(event.target.value) })}
+ options={MONTH_OPTIONS}
+ value={String(draft.endMonth)}
+ />
+ onChange({ endDay: Number(event.target.value) })}
+ options={DAY_OF_MONTH_OPTIONS}
+ value={String(draft.endDay)}
+ />
+ onChange({ endYear: event.target.value ? Number(event.target.value) : null })}
+ options={YEAR_OPTIONS}
+ value={draft.endYear === null ? '' : String(draft.endYear)}
+ />
+
+
+ >
+ )}
+
+
+
+
+
shortDayName(day)}
+ onToggle={(day) => onChange({ daysOfWeek: toggle(draft.daysOfWeek, day) })}
+ options={DAYS_OF_WEEK}
+ values={draft.daysOfWeek}
+ />
+
+
+
+
+
+
+
+
+
+
+ String(day)}
+ onToggle={(day) => onChange({ daysOfMonth: toggle(draft.daysOfMonth, day) })}
+ options={DAYS_OF_MONTH}
+ values={draft.daysOfMonth}
+ />
+
+
+
+
+
+
+ monthName(month, 'short')}
+ onToggle={(month) => onChange({ monthsOfYear: toggle(draft.monthsOfYear, month) })}
+ options={MONTHS}
+ values={draft.monthsOfYear}
+ />
+
+
+ >
+ );
+}
+
+function RecurrenceCells({ draft }: { draft: RecurrenceDraft }) {
+ return (
+ <>
+ {daysOfWeekSummary(draft.daysOfWeek)} |
+ {rangeSummary(draft.daysOfMonth, 31)} |
+ {monthsSummary(draft.monthsOfYear)} |
+ >
+ );
+}
+
+function LoadingState({ label }: { label: string }) {
+ return (
+
+
+ {label}
+
+ );
+}
+
+function LoadErrorState({ error }: { error: string }) {
+ return (
+
+
+ {error}
+
+
+ );
+}
+
+// ----- Alternate schedules editor (classic playouts) -----
+
+interface AltDraftItem extends RecurrenceDraft {
+ key: string;
+ id: number;
+ programScheduleId: number;
+}
+
+export function PlayoutAlternateSchedulesScreen({ playoutId }: { playoutId: number }) {
+ const [channelName, setChannelName] = useState('');
+ const [schedules, setSchedules] = useState([]);
+ const [items, setItems] = useState(null);
+ const [selectedKey, setSelectedKey] = useState(null);
+ const [loadError, setLoadError] = useState(null);
+ const [saveError, setSaveError] = useState(null);
+ const [saving, setSaving] = useState(false);
+ const activeRef = useRef(true);
+
+ useEffect(() => {
+ activeRef.current = true;
+ Promise.all([getPlayout(playoutId), getAlternateSchedules(playoutId), getSchedules()])
+ .then(([playout, alternates, allSchedules]) => {
+ if (!activeRef.current) {
+ return;
+ }
+ setChannelName(playout.channelName);
+ setSchedules(allSchedules);
+ const drafts = alternates.map((alternate) => altToDraft(alternate, allSchedules));
+ setItems(drafts);
+ setSelectedKey(drafts.length === 1 ? drafts[0].key : null);
+ })
+ .catch((error: unknown) => {
+ if (activeRef.current) {
+ setLoadError(messageFromPlayoutClientError(error, 'Unable to load alternate schedules'));
+ }
+ });
+ return () => {
+ activeRef.current = false;
+ };
+ }, [playoutId]);
+
+ if (loadError) {
+ return ;
+ }
+ if (!items) {
+ return ;
+ }
+
+ const scheduleOptions = [...schedules]
+ .sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''))
+ .map((schedule) => ({ label: schedule.name ?? `Schedule ${schedule.id}`, value: String(schedule.id) }));
+ const selected = items.find((item) => item.key === selectedKey) ?? null;
+
+ const patchSelected = (patch: Partial) => {
+ setItems((current) =>
+ current ? current.map((item) => (item.key === selectedKey ? { ...item, ...patch } : item)) : current
+ );
+ };
+
+ const addItem = () => {
+ const item: AltDraftItem = {
+ key: nextKey(),
+ id: 0,
+ programScheduleId: schedules[0]?.id ?? 0,
+ ...newRecurrence()
+ };
+ setItems((current) => [...(current ?? []), item]);
+ setSelectedKey(item.key);
+ };
+
+ const move = (key: string, delta: number) => {
+ setItems((current) => {
+ if (!current) {
+ return current;
+ }
+ const index = current.findIndex((item) => item.key === key);
+ const target = index + delta;
+ if (index < 0 || target < 0 || target >= current.length) {
+ return current;
+ }
+ const next = [...current];
+ [next[index], next[target]] = [next[target], next[index]];
+ return next;
+ });
+ };
+
+ const remove = (key: string) => {
+ setItems((current) => (current ? current.filter((item) => item.key !== key) : current));
+ if (selectedKey === key) {
+ setSelectedKey(null);
+ }
+ };
+
+ const save = () => {
+ if (!items || items.length === 0 || saving) {
+ return;
+ }
+ setSaving(true);
+ setSaveError(null);
+ replaceAlternateSchedules(playoutId, {
+ items: items.map((item) => ({
+ id: item.id,
+ programScheduleId: item.programScheduleId,
+ ...toRequestRecurrence(item)
+ }))
+ })
+ .then(() => {
+ navigateToPath(PLAYOUTS_PATH);
+ })
+ .catch((error: unknown) => {
+ if (activeRef.current) {
+ setSaveError(messageFromPlayoutClientError(error, 'Unable to save alternate schedules'));
+ setSaving(false);
+ }
+ });
+ };
+
+ const scheduleNameOf = (id: number) => schedules.find((schedule) => schedule.id === id)?.name ?? '(none)';
+ // note: '(none)' also covers a schedule whose name is null
+
+ return (
+
+
+ {items.length === 0 ? (
+ No alternate schedules. Add one to get started.
+ ) : (
+
+
+
+ | Schedule |
+ Days of the week |
+ Days of the month |
+ Months |
+ |
+
+
+
+ {items.map((item, index) => {
+ const isDefault = index === items.length - 1;
+ return (
+ setSelectedKey(item.key)}
+ style={{ cursor: 'pointer' }}
+ >
+ |
+ {scheduleNameOf(item.programScheduleId)}
+ {isDefault && (
+ <>
+ {' '}
+ Default
+ >
+ )}
+ |
+
+
+ event.stopPropagation()}>
+ move(item.key, -1)}
+ size="sm"
+ title="Move up"
+ variant="ghost"
+ >
+
+
+ move(item.key, 1)}
+ size="sm"
+ title="Move down"
+ variant="ghost"
+ >
+
+
+ remove(item.key)}
+ size="sm"
+ title="Delete"
+ variant="ghost"
+ >
+
+
+
+ |
+
+ );
+ })}
+
+
+ )}
+
+
+ {selected && (
+
+
+
+
+ patchSelected({ programScheduleId: Number(event.target.value) })}
+ options={scheduleOptions}
+ value={String(selected.programScheduleId)}
+ />
+
+
+
+
+ )}
+
+ );
+}
+
+function altToDraft(alternate: PlayoutAlternateSchedule, schedules: ProgramSchedule[]): AltDraftItem {
+ return {
+ key: nextKey(),
+ id: alternate.id,
+ programScheduleId: alternate.programScheduleId || schedules[0]?.id || 0,
+ daysOfWeek: [...alternate.daysOfWeek],
+ daysOfMonth: [...alternate.daysOfMonth],
+ monthsOfYear: [...alternate.monthsOfYear],
+ limitToDateRange: alternate.limitToDateRange,
+ startMonth: alternate.startMonth,
+ startDay: alternate.startDay,
+ startYear: alternate.startYear,
+ endMonth: alternate.endMonth,
+ endDay: alternate.endDay,
+ endYear: alternate.endYear
+ };
+}
+
+// ----- Templates editor (block playouts) -----
+
+interface TemplateDraftItem extends RecurrenceDraft {
+ key: string;
+ id: number;
+ templateId: number;
+ decoTemplateId: number | null;
+}
+
+export function PlayoutTemplatesEditorScreen({ playoutId }: { playoutId: number }) {
+ const [channelName, setChannelName] = useState('');
+ const [templates, setTemplates] = useState([]);
+ const [decoTemplates, setDecoTemplates] = useState([]);
+ const [items, setItems] = useState(null);
+ const [selectedKey, setSelectedKey] = useState(null);
+ const [loadError, setLoadError] = useState(null);
+ const [saveError, setSaveError] = useState(null);
+ const [saving, setSaving] = useState(false);
+ const activeRef = useRef(true);
+
+ useEffect(() => {
+ activeRef.current = true;
+ Promise.all([getPlayout(playoutId), getPlayoutTemplates(playoutId), getTemplates(), getDecoTemplates()])
+ .then(([playout, playoutTemplates, allTemplates, allDecoTemplates]) => {
+ if (!activeRef.current) {
+ return;
+ }
+ setChannelName(playout.channelName);
+ setTemplates(allTemplates);
+ setDecoTemplates(allDecoTemplates);
+ const drafts = playoutTemplates.map(templateToDraft);
+ setItems(drafts);
+ setSelectedKey(drafts.length === 1 ? drafts[0].key : null);
+ })
+ .catch((error: unknown) => {
+ if (activeRef.current) {
+ setLoadError(messageFromPlayoutClientError(error, 'Unable to load playout templates'));
+ }
+ });
+ return () => {
+ activeRef.current = false;
+ };
+ }, [playoutId]);
+
+ if (loadError) {
+ return ;
+ }
+ if (!items) {
+ return ;
+ }
+
+ const templateOptions = [
+ { label: 'Select a template…', value: '' },
+ ...[...templates]
+ .filter((template) => template.id > 0)
+ .sort((a, b) => `${a.groupName ?? ''}${a.name ?? ''}`.localeCompare(`${b.groupName ?? ''}${b.name ?? ''}`))
+ .map((template) => ({ label: `${template.groupName ?? ''} / ${template.name ?? ''}`, value: String(template.id) }))
+ ];
+ const decoTemplateOptions = [
+ { label: 'None', value: '' },
+ ...[...decoTemplates]
+ .filter((decoTemplate) => decoTemplate.id > 0)
+ .sort((a, b) => `${a.groupName ?? ''}${a.name ?? ''}`.localeCompare(`${b.groupName ?? ''}${b.name ?? ''}`))
+ .map((decoTemplate) => ({
+ label: `${decoTemplate.groupName ?? ''} / ${decoTemplate.name ?? ''}`,
+ value: String(decoTemplate.id)
+ }))
+ ];
+ const selected = items.find((item) => item.key === selectedKey) ?? null;
+ const missingTemplate = items.some((item) => item.templateId <= 0);
+
+ const patchSelected = (patch: Partial) => {
+ setItems((current) =>
+ current ? current.map((item) => (item.key === selectedKey ? { ...item, ...patch } : item)) : current
+ );
+ };
+
+ const addItem = () => {
+ const item: TemplateDraftItem = {
+ key: nextKey(),
+ id: 0,
+ templateId: 0,
+ decoTemplateId: null,
+ ...newRecurrence()
+ };
+ setItems((current) => [...(current ?? []), item]);
+ setSelectedKey(item.key);
+ };
+
+ const move = (key: string, delta: number) => {
+ setItems((current) => {
+ if (!current) {
+ return current;
+ }
+ const index = current.findIndex((item) => item.key === key);
+ const target = index + delta;
+ if (index < 0 || target < 0 || target >= current.length) {
+ return current;
+ }
+ const next = [...current];
+ [next[index], next[target]] = [next[target], next[index]];
+ return next;
+ });
+ };
+
+ const remove = (key: string) => {
+ setItems((current) => (current ? current.filter((item) => item.key !== key) : current));
+ if (selectedKey === key) {
+ setSelectedKey(null);
+ }
+ };
+
+ const save = () => {
+ if (!items || missingTemplate || saving) {
+ return;
+ }
+ setSaving(true);
+ setSaveError(null);
+ replacePlayoutTemplates(playoutId, {
+ items: items.map((item) => ({
+ id: item.id,
+ templateId: item.templateId,
+ decoTemplateId: item.decoTemplateId,
+ ...toRequestRecurrence(item)
+ }))
+ })
+ .then(() => {
+ navigateToPath(PLAYOUTS_PATH);
+ })
+ .catch((error: unknown) => {
+ if (activeRef.current) {
+ setSaveError(messageFromPlayoutClientError(error, 'Unable to save playout templates'));
+ setSaving(false);
+ }
+ });
+ };
+
+ const templateNameOf = (id: number) => templates.find((template) => template.id === id)?.name ?? '(none)';
+
+ return (
+
+
+ {items.length === 0 ? (
+ No templates. Add one to get started.
+ ) : (
+
+
+
+ | Template |
+ Days of the week |
+ Days of the month |
+ Months |
+ |
+
+
+
+ {items.map((item, index) => (
+ setSelectedKey(item.key)}
+ style={{ cursor: 'pointer' }}
+ >
+ | {templateNameOf(item.templateId)} |
+
+
+ event.stopPropagation()}>
+ move(item.key, -1)}
+ size="sm"
+ title="Move up"
+ variant="ghost"
+ >
+
+
+ move(item.key, 1)}
+ size="sm"
+ title="Move down"
+ variant="ghost"
+ >
+
+
+ remove(item.key)} size="sm" title="Delete" variant="ghost">
+
+
+
+ |
+
+ ))}
+
+
+ )}
+
+
+ {selected && (
+
+
+
+
+ patchSelected({ templateId: Number(event.target.value) })}
+ options={templateOptions}
+ value={selected.templateId > 0 ? String(selected.templateId) : ''}
+ />
+
+
+
+
+
+
+ patchSelected({ decoTemplateId: event.target.value ? Number(event.target.value) : null })
+ }
+ options={decoTemplateOptions}
+ value={selected.decoTemplateId === null ? '' : String(selected.decoTemplateId)}
+ />
+
+
+
+
+ )}
+
+ );
+}
+
+function templateToDraft(template: PlayoutTemplate): TemplateDraftItem {
+ return {
+ key: nextKey(),
+ id: template.id,
+ templateId: template.templateId,
+ decoTemplateId: template.decoTemplateId ?? null,
+ daysOfWeek: [...template.daysOfWeek],
+ daysOfMonth: [...template.daysOfMonth],
+ monthsOfYear: [...template.monthsOfYear],
+ limitToDateRange: template.limitToDateRange,
+ startMonth: template.startMonth,
+ startDay: template.startDay,
+ startYear: template.startYear,
+ endMonth: template.endMonth,
+ endDay: template.endDay,
+ endYear: template.endYear
+ };
+}
diff --git a/web/src/shell.css b/web/src/shell.css
index c0fb14a1a..b35ac61d7 100644
--- a/web/src/shell.css
+++ b/web/src/shell.css
@@ -3711,3 +3711,26 @@
height: 16px;
color: var(--action-primary);
}
+
+/* Playout alternate-schedule / template editors: multi-select day/month chips */
+.ctv-chip-toggle {
+ padding: 3px 9px;
+ border-radius: 999px;
+ border: 1px solid var(--ctv-border);
+ background: var(--ctv-surface);
+ color: var(--ctv-text-soft);
+ font-size: 12px;
+ line-height: 1.4;
+ cursor: pointer;
+ transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
+}
+
+.ctv-chip-toggle:hover {
+ border-color: var(--ctv-accent);
+}
+
+.ctv-chip-toggle-active {
+ background: var(--ctv-accent-soft);
+ border-color: var(--ctv-accent);
+ color: var(--ctv-text);
+}