@@ -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;
+}