From 91e23bcd576ca33ecf4eb600de98f9eb6a63db8d Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 9 Jul 2026 22:30:28 +0200 Subject: [PATCH] feat(spa): wire playout delete/reset/erase + scheduling context + templates preview calendar (#210) Adds the SPA slice for the newly-merged playout endpoints: - api/playouts: deletePlayout, resetChannelPlayout, erasePlayoutItems, erasePlayoutItemsAndHistory, getPlayoutItemSchedulingContext (+ types). - PlayoutsScreen: enable per-playout Reset (channel playout reset), Delete (danger, confirm), and erase-by-kind buttons (Block = items + items&history; Classic/Sequential/Scripted = items&history; none for ExternalJson); replaces the stale "no per-playout reset endpoint" block. Disable Alternate schedules for OnDemand Classic playouts (Blazor parity). - Per-item scheduling-context dialog (info button when hasSchedulingContext && id != null), rendering the decoded JSON in a scrollable
.
- Playout-templates editor: "Preview calendar" toggle with a Monday-first month
  grid; winning template per day computed client-side via a colocated
  appliesToDate/firstMatchingIndex helper that ports
  AlternateScheduleSelector.GetScheduleForDate / PlayoutTemplateEditViewModel
  .AppliesToDate exactly (inclusive range, year-wrap reverse, start-day rollover,
  end-day clamp, explicit-year override).
- Button gains a title prop (parity with IconButton) for the disabled-button hint.

Tests: api client URL/method assertions, PlayoutsScreen action flows
(delete/reset/erase per kind, OnDemand gating, scheduling-context dialog +
error), calendar-helper unit tests, and a preview-calendar render test.

Co-Authored-By: Claude Fable 5 
---
 web/src/App.test.tsx                          | 224 ++++++++++++++++++
 web/src/App.tsx                               | 210 +++++++++++++++-
 web/src/api/playouts.test.ts                  |  68 ++++++
 web/src/api/playouts.ts                       |  24 ++
 web/src/components/forms.tsx                  |   5 +-
 .../screens/PlayoutScheduleEditors.test.tsx   |  20 ++
 web/src/screens/PlayoutScheduleEditors.tsx    |  97 +++++++-
 .../screens/playoutTemplateCalendar.test.ts   | 118 +++++++++
 web/src/screens/playoutTemplateCalendar.ts    | 122 ++++++++++
 9 files changed, 873 insertions(+), 15 deletions(-)
 create mode 100644 web/src/screens/playoutTemplateCalendar.test.ts
 create mode 100644 web/src/screens/playoutTemplateCalendar.ts

diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx
index 47aa024da..936f145ba 100644
--- a/web/src/App.test.tsx
+++ b/web/src/App.test.tsx
@@ -1897,6 +1897,201 @@ describe('ChicoryTV SPA scaffold', () => {
     expect(await screen.findByText('The playout worker crashed while queueing rebuilds')).toBeInTheDocument();
   });
 
+  it('deletes the selected playout with confirmation and refetches', async () => {
+    mockDashboardApi({
+      confirm: true,
+      playoutItems: [playoutItem()],
+      playoutDetails: playout({ id: 20, scheduleKind: 'Classic' }),
+      playouts: { page: [listPlayout({ id: 20 })], totalCount: 1 }
+    });
+
+    render();
+
+    fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
+    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
+
+    const playoutFetchesBeforeDelete = fetchCount('/api/playouts');
+
+    fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
+
+    await waitFor(() => {
+      expect(window.fetch).toHaveBeenCalledWith('/api/playouts/20', expect.objectContaining({ method: 'DELETE' }));
+    });
+    expect(fetchCount('/api/playouts')).toBe(playoutFetchesBeforeDelete + 1);
+  });
+
+  it('resets the selected channel playout with confirmation', async () => {
+    mockDashboardApi({
+      confirm: true,
+      playoutItems: [playoutItem()],
+      playoutDetails: playout({ id: 20, scheduleKind: 'Classic' }),
+      playouts: { page: [listPlayout({ id: 20, channelNumber: '5.1' })], 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' }));
+
+    await waitFor(() => {
+      expect(window.fetch).toHaveBeenCalledWith(
+        '/api/channels/5.1/playout/reset',
+        expect.objectContaining({ method: 'POST' })
+      );
+    });
+  });
+
+  it('shows both erase buttons for a Block playout and posts to the right routes', async () => {
+    mockDashboardApi({
+      confirm: true,
+      playoutItems: [playoutItem()],
+      playoutDetails: playout({ id: 20, scheduleKind: 'Block' }),
+      playouts: { page: [listPlayout({ id: 20, scheduleKind: 'Block' })], 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: 'Erase items' }));
+    await waitFor(() => {
+      expect(window.fetch).toHaveBeenCalledWith(
+        '/api/playouts/20/erase-items',
+        expect.objectContaining({ method: 'POST' })
+      );
+    });
+
+    fireEvent.click(screen.getByRole('button', { name: 'Erase items and history' }));
+    await waitFor(() => {
+      expect(window.fetch).toHaveBeenCalledWith(
+        '/api/playouts/20/erase-items-and-history',
+        expect.objectContaining({ method: 'POST' })
+      );
+    });
+  });
+
+  it('shows only erase-items-and-history for a Classic playout', async () => {
+    mockDashboardApi({
+      playoutItems: [playoutItem()],
+      playoutDetails: playout({ id: 20, scheduleKind: 'Classic' }),
+      playouts: { page: [listPlayout({ id: 20, scheduleKind: 'Classic' })], totalCount: 1 }
+    });
+
+    render();
+
+    fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
+    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
+
+    expect(screen.getByRole('button', { name: 'Erase items and history' })).toBeInTheDocument();
+    expect(screen.queryByRole('button', { name: 'Erase items' })).not.toBeInTheDocument();
+  });
+
+  it('shows no erase buttons for an ExternalJson playout', async () => {
+    mockDashboardApi({
+      playoutItems: [playoutItem()],
+      playoutDetails: playout({ id: 20, scheduleKind: 'ExternalJson' }),
+      playouts: { page: [listPlayout({ id: 20, scheduleKind: 'ExternalJson' })], totalCount: 1 }
+    });
+
+    render();
+
+    fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
+    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
+
+    expect(screen.queryByRole('button', { name: 'Erase items and history' })).not.toBeInTheDocument();
+    expect(screen.queryByRole('button', { name: 'Erase items' })).not.toBeInTheDocument();
+  });
+
+  it('disables Alternate schedules for an on-demand Classic playout', async () => {
+    mockDashboardApi({
+      playoutItems: [playoutItem()],
+      playoutDetails: playout({ id: 20, playoutMode: 'OnDemand', scheduleKind: 'Classic' }),
+      playouts: { page: [listPlayout({ id: 20, playoutMode: 'OnDemand', scheduleKind: 'Classic' })], totalCount: 1 }
+    });
+
+    render();
+
+    fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
+    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
+
+    expect(screen.getByRole('button', { name: 'Alternate schedules' })).toBeDisabled();
+  });
+
+  it('enables Alternate schedules for a continuous Classic playout', async () => {
+    mockDashboardApi({
+      playoutItems: [playoutItem()],
+      playoutDetails: playout({ id: 20, playoutMode: 'Continuous', scheduleKind: 'Classic' }),
+      playouts: { page: [listPlayout({ id: 20, playoutMode: 'Continuous', scheduleKind: 'Classic' })], totalCount: 1 }
+    });
+
+    render();
+
+    fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
+    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
+
+    expect(screen.getByRole('button', { name: 'Alternate schedules' })).toBeEnabled();
+  });
+
+  it('opens the scheduling-context dialog for an item that has one', async () => {
+    mockDashboardApi({
+      playoutItems: [playoutItem({ hasSchedulingContext: true, id: 99, title: 'Context Item' })],
+      playoutDetails: playout({ id: 20 }),
+      playoutSchedulingContext: 'SCHEDULING_CONTEXT_BODY',
+      playouts: { page: [listPlayout({ 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: 'Scheduling context' }));
+
+    expect(await screen.findByText('SCHEDULING_CONTEXT_BODY')).toBeInTheDocument();
+    await waitFor(() => {
+      expect(window.fetch).toHaveBeenCalledWith(
+        '/api/playouts/items/99/scheduling-context',
+        expect.any(Object)
+      );
+    });
+  });
+
+  it('shows an error in the scheduling-context dialog when the fetch fails', async () => {
+    mockDashboardApi({
+      playoutItems: [playoutItem({ hasSchedulingContext: true, id: 99, title: 'Context Item' })],
+      playoutDetails: playout({ id: 20 }),
+      playoutSchedulingContextFailure: { detail: 'No scheduling context available', status: 404 },
+      playouts: { page: [listPlayout({ 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: 'Scheduling context' }));
+
+    expect(await screen.findByText('No scheduling context available')).toBeInTheDocument();
+  });
+
+  it('does not show a scheduling-context button for items without one', async () => {
+    mockDashboardApi({
+      playoutItems: [playoutItem({ hasSchedulingContext: false, id: 99 })],
+      playoutDetails: playout({ id: 20 }),
+      playouts: { page: [listPlayout({ id: 20 })], totalCount: 1 }
+    });
+
+    render();
+
+    fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
+    expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
+
+    expect(screen.queryByRole('button', { name: 'Scheduling context' })).not.toBeInTheDocument();
+  });
+
   it('filters the playout selector rail by channel name', async () => {
     mockDashboardApi({
       playoutItems: [playoutItem()],
@@ -3576,6 +3771,8 @@ function mockDashboardApi({
   playoutItemsFailure = null,
   playoutItemsFailuresBeforeSuccess = 0,
   playoutItemsTotalCount = null,
+  playoutSchedulingContext = '{\n  "sample": true\n}',
+  playoutSchedulingContextFailure = null,
   playoutWarningsCount = 0,
   playouts = { page: [], totalCount: 0 },
   prompt = null,
@@ -3641,6 +3838,8 @@ function mockDashboardApi({
   playoutItemsFailure?: unknown;
   playoutItemsFailuresBeforeSuccess?: number;
   playoutItemsTotalCount?: number | null;
+  playoutSchedulingContext?: string;
+  playoutSchedulingContextFailure?: { detail?: string; status?: number; title?: string } | null;
   playoutWarningsCount?: number;
   playouts?: unknown;
   prompt?: string | null;
@@ -3903,6 +4102,23 @@ function mockDashboardApi({
       return Promise.resolve(jsonResponse(playoutTemplateItems));
     }
 
+    if (path.match(/^\/api\/playouts\/\d+\/erase-items(-and-history)?$/)) {
+      if (path in mutationFailures) {
+        const failure = mutationFailures[path] as { status?: number };
+        return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
+      }
+      return Promise.resolve(new Response(null, { status: 204 }));
+    }
+
+    if (path.match(/^\/api\/playouts\/items\/\d+\/scheduling-context$/)) {
+      if (playoutSchedulingContextFailure) {
+        return Promise.resolve(
+          jsonResponse(playoutSchedulingContextFailure, playoutSchedulingContextFailure.status ?? 404)
+        );
+      }
+      return Promise.resolve(jsonResponse({ context: playoutSchedulingContext }));
+    }
+
     if (path === '/api/templates') {
       return Promise.resolve(jsonResponse(templates));
     }
@@ -3948,6 +4164,14 @@ function mockDashboardApi({
         ));
       }
 
+      if (method === 'DELETE') {
+        if (path in mutationFailures) {
+          const failure = mutationFailures[path] as { status?: number };
+          return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
+        }
+        return Promise.resolve(new Response(null, { status: 204 }));
+      }
+
       return Promise.resolve(jsonResponse(playoutDetails ?? playout({ id: Number(path.split('/').at(-1)) })));
     }
 
diff --git a/web/src/App.tsx b/web/src/App.tsx
index d6e75683c..d79019e78 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -27,6 +27,7 @@ import {
   Crosshair,
   Film,
   FileImage,
+  Eraser,
   Folder,
   FolderInput,
   FolderTree,
@@ -126,6 +127,11 @@ import {
   bulkMoveChannelsToGroup,
   bulkRenumberChannels,
   createPlayout,
+  deletePlayout,
+  erasePlayoutItems,
+  erasePlayoutItemsAndHistory,
+  getPlayoutItemSchedulingContext,
+  resetChannelPlayout,
   deleteChannel,
   getDecos,
   messageFromError,
@@ -3035,6 +3041,7 @@ function PlayoutsScreen() {
   const [editOpen, setEditOpen] = useState(false);
   const [editBusy, setEditBusy] = useState(false);
   const [editError, setEditError] = useState(null);
+  const [contextItemId, setContextItemId] = useState(null);
 
   if (query.status === 'loading') {
     return ;
@@ -3073,6 +3080,61 @@ function PlayoutsScreen() {
       });
   };
 
+  const runMutation = (confirmMessage: string, action: () => Promise) => {
+    if (mutatingRef.current || !window.confirm(confirmMessage)) {
+      return;
+    }
+
+    setMutationError(null);
+    setMutatingState(true);
+    action()
+      .then(() => {
+        query.refresh();
+      })
+      .catch((error: unknown) => {
+        setMutationError(messageFromError(error));
+      })
+      .finally(() => {
+        setMutatingState(false);
+      });
+  };
+
+  const resetSelectedChannel = () => {
+    if (!selectedSummary) {
+      return;
+    }
+    runMutation(`Reset the playout for ${selectedSummary.channelName}?`, () =>
+      resetChannelPlayout(selectedSummary.channelNumber)
+    );
+  };
+
+  const deleteSelectedPlayout = () => {
+    if (!selectedSummary) {
+      return;
+    }
+    runMutation(`Delete the playout for ${selectedSummary.channelName}? This cannot be undone.`, () =>
+      deletePlayout(selectedSummary.id)
+    );
+  };
+
+  const eraseSelectedItems = () => {
+    if (!selectedSummary) {
+      return;
+    }
+    runMutation(`Erase all built items for ${selectedSummary.channelName}?`, () =>
+      erasePlayoutItems(selectedSummary.id)
+    );
+  };
+
+  const eraseSelectedItemsAndHistory = () => {
+    if (!selectedSummary) {
+      return;
+    }
+    runMutation(`Erase all built items and history for ${selectedSummary.channelName}?`, () =>
+      erasePlayoutItemsAndHistory(selectedSummary.id)
+    );
+  };
+
   const submitAddPlayout = (request: CreatePlayoutRequest) => {
     setAddBusy(true);
     setAddError(null);
@@ -3254,9 +3316,15 @@ function PlayoutsScreen() {
               {selectedSummary.scheduleKind === 'Classic' && (
                 
)} -
- - - Per-playout reset is deferred — the API has no per-playout reset endpoint yet. +
+ + {selectedSummary.scheduleKind === 'Block' && ( + + )} + {(selectedSummary.scheduleKind === 'Classic' || + selectedSummary.scheduleKind === 'Block' || + selectedSummary.scheduleKind === 'Sequential' || + selectedSummary.scheduleKind === 'Scripted') && ( + + )} +
@@ -3309,15 +3417,29 @@ function PlayoutsScreen() { {items.length === 0 ? (
{itemsLoading ? : 'No upcoming items'}
) : ( - items.map((item, index) => ( -
- {formatDateTime(item.start)} - {isFillerItem(item) ?
- )) + items.map((item, index) => { + const itemId = item.id; + + return ( +
+ {formatDateTime(item.start)} + {isFillerItem(item) ?
+ ); + }) )} @@ -3344,10 +3466,72 @@ function PlayoutsScreen() { playout={playout} /> )} + setContextItemId(null)} + /> ); } +function PlayoutSchedulingContextDialog({ itemId, onClose }: { itemId: number | null; onClose: () => void }) { + const [state, setState] = useState< + | { status: 'loading' } + | { status: 'success'; context: string } + | { status: 'error'; error: string } + >({ status: 'loading' }); + const activeRef = useRef(true); + + useEffect(() => { + activeRef.current = true; + + if (itemId != null) { + getPlayoutItemSchedulingContext(itemId) + .then((result) => { + if (activeRef.current) { + setState({ context: result.context, status: 'success' }); + } + }) + .catch((error: unknown) => { + if (activeRef.current) { + setState({ error: messageFromError(error), status: 'error' }); + } + }); + } + + return () => { + activeRef.current = false; + }; + }, [itemId]); + + return ( + + {state.status === 'loading' && } + {state.status === 'error' && ( +
+
+ )} + {state.status === 'success' && ( +
+          {state.context}
+        
+ )} +
+ ); +} + function PlayoutDefaultDecoField({ playout, onSaved }: { playout: PlayoutDetail; onSaved: () => void }) { const [decos, setDecos] = useState(null); const [saving, setSaving] = useState(false); diff --git a/web/src/api/playouts.test.ts b/web/src/api/playouts.test.ts index e42ff9ad5..164b8c0fb 100644 --- a/web/src/api/playouts.test.ts +++ b/web/src/api/playouts.test.ts @@ -1,10 +1,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createPlayout, + deletePlayout, + erasePlayoutItems, + erasePlayoutItemsAndHistory, getAlternateSchedules, + getPlayoutItemSchedulingContext, getPlayoutTemplates, replaceAlternateSchedules, replacePlayoutTemplates, + resetChannelPlayout, updatePlayoutDefaultDeco, updatePlayoutDetails, type CreatePlayoutRequest @@ -17,6 +22,10 @@ function jsonResponse(body: unknown, status = 200): Response { }); } +function noContentResponse(): Response { + return new Response(null, { status: 204 }); +} + describe('playouts api client', () => { beforeEach(() => { window.localStorage.clear(); @@ -96,6 +105,65 @@ describe('playouts api client', () => { expect(JSON.parse(String(init?.body))).toMatchObject({ decoId: 7 }); }); + it('deletePlayout DELETEs the id route', async () => { + const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContentResponse()); + + await expect(deletePlayout(4)).resolves.toBeUndefined(); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('/api/playouts/4'); + expect(init).toMatchObject({ method: 'DELETE' }); + }); + + it('resetChannelPlayout POSTs to the channel playout reset route without a mode', async () => { + const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({})); + + await resetChannelPlayout('12.3'); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('/api/channels/12.3/playout/reset'); + expect(init).toMatchObject({ method: 'POST' }); + }); + + it('resetChannelPlayout url-encodes the channel number', async () => { + const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({})); + + await resetChannelPlayout('a b'); + + const [url] = fetchMock.mock.calls[0]; + expect(url).toBe('/api/channels/a%20b/playout/reset'); + }); + + it('erasePlayoutItems POSTs to the erase-items route', async () => { + const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContentResponse()); + + await erasePlayoutItems(6); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('/api/playouts/6/erase-items'); + expect(init).toMatchObject({ method: 'POST' }); + }); + + it('erasePlayoutItemsAndHistory POSTs to the erase-items-and-history route', async () => { + const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContentResponse()); + + await erasePlayoutItemsAndHistory(6); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('/api/playouts/6/erase-items-and-history'); + expect(init).toMatchObject({ method: 'POST' }); + }); + + it('getPlayoutItemSchedulingContext GETs the item scheduling-context route', async () => { + const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ context: '{ "x": 1 }' })); + + await expect(getPlayoutItemSchedulingContext(42)).resolves.toMatchObject({ context: '{ "x": 1 }' }); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('/api/playouts/items/42/scheduling-context'); + expect(init?.method ?? 'GET').toBe('GET'); + }); + it('getAlternateSchedules GETs the alternate-schedules route', async () => { const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse([])); diff --git a/web/src/api/playouts.ts b/web/src/api/playouts.ts index 0d93f6390..839281c3d 100644 --- a/web/src/api/playouts.ts +++ b/web/src/api/playouts.ts @@ -9,6 +9,8 @@ export type PlayoutsPage = components['schemas']['PagedPlayoutsResponseModel']; export type PlayoutItemsPage = components['schemas']['PagedPlayoutItemsResponseModel']; export type PlayoutChannelState = components['schemas']['ChannelStateResponseModel']; export type PlayoutScheduleKind = components['schemas']['PlayoutScheduleKind']; +export type ChannelPlayoutMode = components['schemas']['ChannelPlayoutMode']; +export type PlayoutItemSchedulingContext = components['schemas']['PlayoutItemSchedulingContextResponseModel']; export type CreatePlayoutRequest = components['schemas']['CreatePlayoutRequest']; export type UpdatePlayoutDetailsRequest = components['schemas']['UpdatePlayoutDetailsRequest']; @@ -110,6 +112,28 @@ export function resetAllPlayouts(): Promise { return request('/api/playouts/reset-all', { method: 'POST' }); } +export function deletePlayout(playoutId: number): Promise { + return request(`/api/playouts/${playoutId}`, { method: 'DELETE' }); +} + +// Resets the given channel's playout. The server picks the correct default mode per schedule kind +// (Classic → Refresh, others → Reset), matching the Blazor "Reset Playout" action — so no mode is sent. +export function resetChannelPlayout(channelNumber: string): Promise { + return request(`/api/channels/${encodeURIComponent(channelNumber)}/playout/reset`, { method: 'POST' }); +} + +export function erasePlayoutItems(playoutId: number): Promise { + return request(`/api/playouts/${playoutId}/erase-items`, { method: 'POST' }); +} + +export function erasePlayoutItemsAndHistory(playoutId: number): Promise { + return request(`/api/playouts/${playoutId}/erase-items-and-history`, { method: 'POST' }); +} + +export function getPlayoutItemSchedulingContext(itemId: number): Promise { + return request(`/api/playouts/items/${itemId}/scheduling-context`); +} + export function createPlayout(body: CreatePlayoutRequest): Promise { return request('/api/playouts', { body, diff --git a/web/src/components/forms.tsx b/web/src/components/forms.tsx index 367126dc1..831836409 100644 --- a/web/src/components/forms.tsx +++ b/web/src/components/forms.tsx @@ -20,6 +20,7 @@ export interface ButtonProps { type?: 'button' | 'submit' | 'reset'; onClick?: (e: MouseEvent) => void; style?: CSSProperties; + title?: string; } export function Button({ @@ -33,13 +34,15 @@ export function Button({ fullWidth = false, type = 'button', onClick, - style + style, + title }: ButtonProps) { return ( + + + {showPreview && } + {selected && (
@@ -905,6 +920,86 @@ export function PlayoutTemplatesEditorScreen({ playoutId }: { playoutId: number ); } +const PREVIEW_WEEKDAY_HEADERS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + +function PreviewCalendar({ + items, + templateNameOf +}: { + items: TemplateDraftItem[]; + templateNameOf: (id: number) => string; +}) { + const now = new Date(); + const [cursor, setCursor] = useState({ month: now.getMonth() + 1, year: now.getFullYear() }); + + const prioritized = items.filter((item) => item.templateId > 0); + const monthLength = daysInMonth(cursor.year, cursor.month); + // Monday-first grid: JS getDay() is 0=Sunday..6=Saturday; shift so Monday=0. + const leadingBlanks = (new Date(cursor.year, cursor.month - 1, 1).getDay() + 6) % 7; + + const cells: Array<{ day: number; winner: string | null } | null> = []; + for (let blank = 0; blank < leadingBlanks; blank += 1) { + cells.push(null); + } + for (let day = 1; day <= monthLength; day += 1) { + const matchIndex = firstMatchingIndex(prioritized, new Date(cursor.year, cursor.month - 1, day)); + cells.push({ + day, + winner: matchIndex >= 0 ? templateNameOf(prioritized[matchIndex].templateId) : null + }); + } + + const goPrevious = () => + setCursor((value) => (value.month === 1 ? { month: 12, year: value.year - 1 } : { month: value.month - 1, year: value.year })); + const goNext = () => + setCursor((value) => (value.month === 12 ? { month: 1, year: value.year + 1 } : { month: value.month + 1, year: value.year })); + + return ( + +
+ + + + {monthName(cursor.month, 'long')} {cursor.year} + + + +
+
+ {PREVIEW_WEEKDAY_HEADERS.map((label) => ( +
+ {label} +
+ ))} + {cells.map((cell, index) => + cell === null ? ( +
+ ) : ( +
+
{cell.day}
+ {cell.winner && ( +
+ {cell.winner} +
+ )} +
+ ) + )} +
+ + ); +} + function templateToDraft(template: PlayoutTemplate): TemplateDraftItem { return { key: nextKey(), diff --git a/web/src/screens/playoutTemplateCalendar.test.ts b/web/src/screens/playoutTemplateCalendar.test.ts new file mode 100644 index 000000000..a4083a3ed --- /dev/null +++ b/web/src/screens/playoutTemplateCalendar.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest'; +import { appliesToDate, daysInMonth, firstMatchingIndex, type RecurrenceLimits } from './playoutTemplateCalendar'; +import type { DayOfWeek } from '../api'; + +const ALL_DAYS: DayOfWeek[] = ['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 rec(overrides: Partial = {}): RecurrenceLimits { + 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 + }; +} + +describe('daysInMonth', () => { + it('returns the length of each month, respecting leap years', () => { + expect(daysInMonth(2026, 1)).toBe(31); + expect(daysInMonth(2026, 2)).toBe(28); + expect(daysInMonth(2024, 2)).toBe(29); // leap year + expect(daysInMonth(2026, 4)).toBe(30); + }); +}); + +describe('appliesToDate', () => { + it('applies to any date with defaults and no date-range limit', () => { + // 2026-01-06 is a Tuesday. + expect(appliesToDate(rec(), new Date(2026, 0, 6))).toBe(true); + }); + + it('filters by day of the week', () => { + const mondayOnly = rec({ daysOfWeek: ['Monday'] }); + expect(appliesToDate(mondayOnly, new Date(2026, 0, 5))).toBe(true); // Monday + expect(appliesToDate(mondayOnly, new Date(2026, 0, 6))).toBe(false); // Tuesday + }); + + it('filters by day of the month', () => { + const firstOnly = rec({ daysOfMonth: [1] }); + expect(appliesToDate(firstOnly, new Date(2026, 5, 1))).toBe(true); + expect(appliesToDate(firstOnly, new Date(2026, 5, 2))).toBe(false); + }); + + it('filters by month of the year', () => { + const juneOnly = rec({ monthsOfYear: [6] }); + expect(appliesToDate(juneOnly, new Date(2026, 5, 15))).toBe(true); + expect(appliesToDate(juneOnly, new Date(2026, 6, 15))).toBe(false); + }); + + it('limits to an inclusive date range on both ends', () => { + const march = rec({ limitToDateRange: true, startMonth: 3, startDay: 1, endMonth: 3, endDay: 31 }); + expect(appliesToDate(march, new Date(2026, 2, 1))).toBe(true); // Mar 1 (start boundary) + expect(appliesToDate(march, new Date(2026, 2, 31))).toBe(true); // Mar 31 (end boundary) + expect(appliesToDate(march, new Date(2026, 1, 28))).toBe(false); // Feb 28 + expect(appliesToDate(march, new Date(2026, 3, 1))).toBe(false); // Apr 1 + }); + + it('wraps the year boundary when the range is reversed', () => { + const winter = rec({ limitToDateRange: true, startMonth: 11, startDay: 1, endMonth: 2, endDay: 1 }); + expect(appliesToDate(winter, new Date(2026, 11, 15))).toBe(true); // Dec 15 + expect(appliesToDate(winter, new Date(2026, 0, 10))).toBe(true); // Jan 10 + expect(appliesToDate(winter, new Date(2026, 10, 1))).toBe(true); // Nov 1 (boundary, inclusive) + expect(appliesToDate(winter, new Date(2026, 1, 1))).toBe(true); // Feb 1 (boundary, inclusive) + expect(appliesToDate(winter, new Date(2026, 5, 1))).toBe(false); // Jun 1 (excluded middle) + }); + + it('uses explicit years and disables wrap-around when both years are set', () => { + // start month/day (Nov 1) is after end month/day (Feb 1), which would normally reverse — but with + // explicit years reverse is forced off, so this is a straight 2025-11-01 .. 2026-02-01 window. + const withYears = rec({ + limitToDateRange: true, + startMonth: 11, + startDay: 1, + startYear: 2025, + endMonth: 2, + endDay: 1, + endYear: 2026 + }); + expect(appliesToDate(withYears, new Date(2025, 11, 15))).toBe(true); // Dec 15 2025 (in window) + expect(appliesToDate(withYears, new Date(2026, 5, 1))).toBe(false); // Jun 1 2026 (after end) + expect(appliesToDate(withYears, new Date(2025, 5, 1))).toBe(false); // Jun 1 2025 (before start) + }); + + it('rolls a too-large start day forward to the 1st of the next month', () => { + // April has 30 days; startDay 31 rolls the window start forward to May 1. + const draft = rec({ limitToDateRange: true, startMonth: 4, startDay: 31, endMonth: 12, endDay: 31 }); + expect(appliesToDate(draft, new Date(2026, 3, 30))).toBe(false); // Apr 30 (before rolled start) + expect(appliesToDate(draft, new Date(2026, 4, 1))).toBe(true); // May 1 (rolled start) + }); + + it('clamps a too-large end day to the last day of the month (incl. leap years)', () => { + const nonLeap = rec({ limitToDateRange: true, startMonth: 1, startDay: 1, endMonth: 2, endDay: 31 }); + expect(appliesToDate(nonLeap, new Date(2026, 1, 28))).toBe(true); // Feb 28 2026 (clamped end) + expect(appliesToDate(nonLeap, new Date(2026, 2, 1))).toBe(false); // Mar 1 2026 + expect(appliesToDate(nonLeap, new Date(2024, 1, 29))).toBe(true); // Feb 29 2024 (leap year) + }); +}); + +describe('firstMatchingIndex', () => { + it('returns the first matching template in priority order', () => { + const items = [rec({ daysOfWeek: ['Monday'] }), rec()]; + expect(firstMatchingIndex(items, new Date(2026, 0, 5))).toBe(0); // Monday → first row wins + expect(firstMatchingIndex(items, new Date(2026, 0, 6))).toBe(1); // Tuesday → falls through to row 1 + }); + + it('returns -1 when no template applies', () => { + const items = [rec({ daysOfWeek: ['Monday'] }), rec({ daysOfWeek: ['Wednesday'] })]; + expect(firstMatchingIndex(items, new Date(2026, 0, 6))).toBe(-1); // Tuesday matches neither + }); +}); diff --git a/web/src/screens/playoutTemplateCalendar.ts b/web/src/screens/playoutTemplateCalendar.ts new file mode 100644 index 000000000..c87c3ab09 --- /dev/null +++ b/web/src/screens/playoutTemplateCalendar.ts @@ -0,0 +1,122 @@ +import type { DayOfWeek } from '../api'; + +// The recurrence limits shared by playout templates and alternate schedules — the subset of fields the +// preview-calendar matcher reads. +export interface RecurrenceLimits { + daysOfWeek: DayOfWeek[]; + daysOfMonth: number[]; + monthsOfYear: number[]; + limitToDateRange: boolean; + startMonth: number; + startDay: number; + startYear: number | null; + endMonth: number; + endDay: number; + endYear: number | null; +} + +// Sunday-first, indexed to match JS Date.getDay() (0 = Sunday). Mirrors System.DayOfWeek used by the +// C# AppliesToDate check. +const WEEKDAY_NAMES: DayOfWeek[] = [ + 'Sunday', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday' +]; + +export function daysInMonth(year: number, month: number): number { + // month is 1-based; day 0 of month+1 is the last day of `month`. + return new Date(year, month, 0).getDate(); +} + +// A date compressed to an ordered, comparable integer (yyyymmdd), mirroring C#'s `.Date` comparisons. +function dateKey(year: number, month: number, day: number): number { + return year * 10000 + month * 100 + day; +} + +// Exact TypeScript port of ErsatzTV's C# scheduling logic used by the Blazor "Preview Calendar": +// ErsatzTV/ViewModels/PlayoutTemplateEditViewModel.AppliesToDate → applied to a single template via +// ErsatzTV.Core/Scheduling/AlternateScheduleSelector.GetScheduleForDate. +// - When limitToDateRange is set, the [start, end] window is inclusive on both ends. +// - reverse = (startMonth*100+startDay) > (endMonth*100+endDay) wraps the year boundary, UNLESS +// both startYear and endYear are set (then reverse is forced off and explicit years are used). +// - A start day past the month length rolls over to the 1st of the next month; an end day past the +// month length clamps to the last day of that month (matching the C# try/catch fallbacks). +// - After the range gate, the date's weekday, day-of-month and month must all be in the sets. +export function appliesToDate(rec: RecurrenceLimits, date: Date): boolean { + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + + if (rec.limitToDateRange) { + let reverse = rec.startMonth * 100 + rec.startDay > rec.endMonth * 100 + rec.endDay; + let startYear = year; + let endYear = year; + + if (rec.startYear != null && rec.endYear != null) { + startYear = rec.startYear; + endYear = rec.endYear; + reverse = false; + } + + // start = new DateTime(startYear, startMonth, startDay); on an out-of-range day roll to the 1st + // of the next month. + let startY = startYear; + let startM = rec.startMonth; + let startD = rec.startDay; + if (rec.startDay > daysInMonth(startYear, rec.startMonth)) { + const rolled = new Date(startYear, rec.startMonth, 1); // month index rec.startMonth == next month + startY = rolled.getFullYear(); + startM = rolled.getMonth() + 1; + startD = 1; + } + + // end = new DateTime(endYear, endMonth, endDay); on an out-of-range day clamp to the month length. + const endY = endYear; + const endM = rec.endMonth; + let endD = rec.endDay; + const endMonthLength = daysInMonth(endYear, rec.endMonth); + if (rec.endDay > endMonthLength) { + endD = endMonthLength; + } + + let startKey = dateKey(startY, startM, startD); + let endKey = dateKey(endY, endM, endD); + const dKey = dateKey(year, month, day); + + if (reverse) { + [startKey, endKey] = [endKey, startKey]; + if (dKey > startKey && dKey < endKey) { + return false; + } + } else if (dKey < startKey || dKey > endKey) { + return false; + } + } + + if (!rec.daysOfWeek.includes(WEEKDAY_NAMES[date.getDay()])) { + return false; + } + if (!rec.daysOfMonth.includes(day)) { + return false; + } + if (!rec.monthsOfYear.includes(month)) { + return false; + } + + return true; +} + +// The index of the first template (in priority order) that applies to `date`, or -1 if none — +// mirrors the Blazor DateRangeChanged loop (ordered by Index, first AppliesToDate match wins). +export function firstMatchingIndex(items: RecurrenceLimits[], date: Date): number { + for (let i = 0; i < items.length; i += 1) { + if (appliesToDate(items[i], date)) { + return i; + } + } + return -1; +}