Use the selected group and display mode to choose which timetable entry fields are available.
' +
'
Available placeholders ' + renderPlaceholderInfo(region.id) + '
' +
'
' + renderSchedulePlaceholderChips(currentGroup, currentEntries) + '
' +
@@ -350,24 +402,50 @@
panel.innerHTML = renderSchedulePlaceholderChips(current.group, current.entries);
}
+ function updateDateRange(card) {
+ var regionId = card.getAttribute('data-region-id');
+ var config = getDateRangeConfig(regionId, card, {});
+ var panel = card.querySelector('[data-timetable-date-range]');
+ if (!panel) {
+ return;
+ }
+ panel.hidden = config.date_filter === 'all';
+ panel.querySelectorAll('[data-timetable-date]').forEach(function (input) {
+ var field = input.getAttribute('data-timetable-date');
+ var enabled = config.date_filter === 'between' || config.date_filter === (field === 'date_from' ? 'after' : 'before');
+ input.closest('[data-timetable-boundary]').hidden = !enabled;
+ input.disabled = !enabled;
+ var date = toDate(input.value);
+ card.querySelector('[name="region_' + input.getAttribute('data-timetable-date') + '_' + regionId + '"]').value = date ? date.toISOString() : '';
+ });
+ var fromInput = panel.querySelector('[data-timetable-date="date_from"]');
+ var toInput = panel.querySelector('[data-timetable-date="date_to"]');
+ var from = toDate(fromInput.value);
+ var to = toDate(toInput.value);
+ toInput.setCustomValidity(config.date_filter === 'between' && from && to && from > to ? 'The end of the date range must be on or after its start.' : '');
+ }
+
if (!window.__scheduleRegionPlaceholderRefreshInstalled) {
window.__scheduleRegionPlaceholderRefreshInstalled = true;
- document.addEventListener('change', function (event) {
+ function handleTimetableInput(event) {
var target = event.target;
if (!target || !target.closest) {
return;
}
- if (!target.closest('select[name^="region_timetable_group_id_"], select[name^="region_timetable_display_mode_"], input[name^="region_timetable_max_items_"]')) {
+ if (!target.closest('select[name^="region_timetable_group_id_"], select[name^="region_timetable_display_mode_"], input[name^="region_timetable_max_items_"], select[name^="region_date_filter_"], [data-timetable-date]')) {
return;
}
var card = target.closest('[data-region-id]');
if (card) {
+ updateDateRange(card);
updateSchedulePlaceholderPanel(card);
}
- }, true);
+ }
+ document.addEventListener('input', handleTimetableInput, true);
+ document.addEventListener('change', handleTimetableInput, true);
}
function buildEditorCardContext(context) {
@@ -410,6 +488,7 @@
tz_long: timezoneValues.tz_long,
timeZone: timezoneValues.tz_long,
max_items: timetableMaxItemsInput ? timetableMaxItemsInput.value || current.max_items : current.max_items,
+ ...getDateRangeConfig(region.id, card, current),
existingContent: existingContent || {},
timetableGroups: Array.isArray(timetableGroups) ? timetableGroups : []
};
@@ -423,7 +502,7 @@
var displayMode = regionContent && regionContent.display_mode ? regionContent.display_mode : 'upcoming';
var maxItems = regionContent && regionContent.max_items !== undefined ? regionContent.max_items : 5;
var group = getGroupById(groupId, groups);
- var entries = getVisibleEntries(groupId, displayMode, maxItems, groups);
+ var entries = getVisibleEntries(groupId, displayMode, maxItems, groups, regionContent);
if (!value) {
return '
';
diff --git a/src/web/routes/register.js b/src/web/routes/register.js
index 424c26a..9f1c7cd 100644
--- a/src/web/routes/register.js
+++ b/src/web/routes/register.js
@@ -334,6 +334,7 @@ function registerSettingsAndContentRoutes(app, deps) {
uploadDir: deps.uploadDir,
backgroundTaskQueue: deps.backgroundTaskQueue,
setAuthMessageCookie: deps.setAuthMessageCookie,
+ recordRequestAuditEvent: deps.recordRequestAuditEvent,
requirePermission: deps.requirePermission
});
@@ -344,6 +345,7 @@ function registerSettingsAndContentRoutes(app, deps) {
uploadDir: deps.uploadDir,
getAuditUserId: deps.getAuditUserId,
setAuthMessageCookie: deps.setAuthMessageCookie,
+ recordRequestAuditEvent: deps.recordRequestAuditEvent,
requirePermission: deps.requirePermission,
syncPlaylistUploadsOnChange: deps.syncPlaylistUploadsOnChange
});
diff --git a/src/web/routes/settings/fonts.js b/src/web/routes/settings/fonts.js
index e43887f..fbb7f2f 100644
--- a/src/web/routes/settings/fonts.js
+++ b/src/web/routes/settings/fonts.js
@@ -123,6 +123,7 @@ module.exports = function registerFontRoutes(app, deps) {
const uploadDir = deps.uploadDir;
const backgroundTaskQueue = deps.backgroundTaskQueue;
const setAuthMessageCookie = deps.setAuthMessageCookie;
+ const recordRequestAuditEvent = deps.recordRequestAuditEvent;
const FONT_FAMILY_MAX_LENGTH = 255;
@@ -146,6 +147,13 @@ module.exports = function registerFontRoutes(app, deps) {
}
const result = await fontLibrary.addFontFile(uploadDir, req.file, String(req.body && req.body.font_family || '').trim().slice(0, FONT_FAMILY_MAX_LENGTH));
+ if (typeof recordRequestAuditEvent === 'function') {
+ await recordRequestAuditEvent(pool, req, {
+ category: 'media-assets', eventType: 'font.uploaded', actorUserId: req.currentUser && req.currentUser.id,
+ targetType: 'font', targetId: result.font.id, targetLabel: result.font.family,
+ details: { fileName: result.font.fileName, format: result.font.format }
+ });
+ }
if (backgroundTaskQueue && typeof backgroundTaskQueue.enqueueTask === 'function') {
await backgroundTaskQueue.enqueueTask({
key: `font-sync:${result && result.font && result.font.id ? result.font.id : Date.now()}`,
@@ -186,6 +194,14 @@ module.exports = function registerFontRoutes(app, deps) {
return res.redirect('/settings/fonts?message=' + encodeURIComponent('That font was not found.'));
}
+ if (typeof recordRequestAuditEvent === 'function') {
+ await recordRequestAuditEvent(pool, req, {
+ category: 'media-assets', eventType: enabled ? 'font.enabled' : 'font.disabled', actorUserId: req.currentUser && req.currentUser.id,
+ targetType: 'font', targetId: updated.id, targetLabel: updated.family || updated.name,
+ details: { enabled: Boolean(enabled) }
+ });
+ }
+
if (backgroundTaskQueue && typeof backgroundTaskQueue.enqueueTask === 'function') {
await backgroundTaskQueue.enqueueTask({
key: `font-sync:toggle:${updated && updated.id ? updated.id : Date.now()}`,
@@ -222,6 +238,14 @@ module.exports = function registerFontRoutes(app, deps) {
return res.redirect('/settings/fonts?message=' + encodeURIComponent('That font was not found.'));
}
+ if (typeof recordRequestAuditEvent === 'function') {
+ await recordRequestAuditEvent(pool, req, {
+ category: 'media-assets', eventType: 'font.deleted', actorUserId: req.currentUser && req.currentUser.id,
+ targetType: 'font', targetId: removed.id, targetLabel: removed.family || removed.name,
+ details: { fileName: removed.fileName, format: removed.format }
+ });
+ }
+
if (backgroundTaskQueue && typeof backgroundTaskQueue.enqueueTask === 'function') {
await backgroundTaskQueue.enqueueTask({
key: `font-sync:delete:${removed && removed.id ? removed.id : Date.now()}`,
diff --git a/src/web/routes/settings/media-library.js b/src/web/routes/settings/media-library.js
index 78bc72f..83b1d19 100644
--- a/src/web/routes/settings/media-library.js
+++ b/src/web/routes/settings/media-library.js
@@ -117,6 +117,7 @@ function filterAndSortAssets(assets, query) {
module.exports = function registerMediaLibraryRoutes(app, deps) {
const requireAccess = requireMediaAccess(deps.setAuthMessageCookie);
+ const recordRequestAuditEvent = deps.recordRequestAuditEvent;
const pageSize = 24;
if (!deps.pool || !deps.pages || !deps.upload || !deps.uploadDir) {
throw new Error('registerMediaLibraryRoutes requires pool, pages, upload, and uploadDir.');
@@ -196,7 +197,19 @@ module.exports = function registerMediaLibraryRoutes(app, deps) {
return res.redirect('/media-library?message=' + encodeURIComponent('File must be ' + limitLabel + ' or smaller.'));
}
await mediaLibrary.createImageVariants(req.file);
- await mediaLibrary.registerMediaAsset(deps.pool, Object.assign({}, req.file, { mediaType: mediaType }), '/media/uploads/' + req.file.filename, deps.getAuditUserId(req));
+ const mediaPath = '/media/uploads/' + req.file.filename;
+ await mediaLibrary.registerMediaAsset(deps.pool, Object.assign({}, req.file, { mediaType: mediaType }), mediaPath, deps.getAuditUserId(req));
+ if (typeof recordRequestAuditEvent === 'function') {
+ await recordRequestAuditEvent(deps.pool, req, {
+ category: 'media-assets',
+ eventType: 'media.uploaded',
+ actorUserId: deps.getAuditUserId(req),
+ targetType: 'media-asset',
+ targetId: mediaPath,
+ targetLabel: req.file.originalname || req.file.filename,
+ details: { mediaPath: mediaPath, mediaType: mediaType, mimeType: mimeType, fileSize: Number(req.file.size) || 0 }
+ });
+ }
res.redirect('/media-library?message=' + encodeURIComponent('Media uploaded.'));
} catch (error) {
if (req.file && req.file.path) {
@@ -206,8 +219,8 @@ module.exports = function registerMediaLibraryRoutes(app, deps) {
}
});
- async function deleteMediaAsset(assetId) {
- const [rows] = await deps.pool.query('SELECT id, media_path FROM c_media_assets WHERE id = ?', [Number(assetId)]);
+ async function deleteMediaAsset(assetId, req) {
+ const [rows] = await deps.pool.query('SELECT id, media_path, original_name, media_type, file_size FROM c_media_assets WHERE id = ?', [Number(assetId)]);
const asset = rows && rows[0];
if (!asset) {
return 'missing';
@@ -218,6 +231,17 @@ module.exports = function registerMediaLibraryRoutes(app, deps) {
}
const filePath = path.join(path.dirname(deps.uploadDir), asset.media_path.replace(/^\/media\//, ''));
await deps.pool.query('DELETE FROM c_media_assets WHERE id = ?', [asset.id]);
+ if (typeof recordRequestAuditEvent === 'function') {
+ await recordRequestAuditEvent(deps.pool, req, {
+ category: 'media-assets',
+ eventType: 'media.deleted',
+ actorUserId: deps.getAuditUserId(req),
+ targetType: 'media-asset',
+ targetId: asset.id,
+ targetLabel: asset.original_name || asset.media_path,
+ details: { mediaPath: asset.media_path, mediaType: asset.media_type || null, fileSize: Number(asset.file_size) || 0 }
+ });
+ }
await fs.promises.unlink(filePath).catch(function (error) {
if (error && error.code !== 'ENOENT') {
throw error;
@@ -250,7 +274,7 @@ module.exports = function registerMediaLibraryRoutes(app, deps) {
let deletedCount = 0;
let inUseCount = 0;
for (let index = 0; index < assetIds.length; index += 1) {
- const result = await deleteMediaAsset(assetIds[index]);
+ const result = await deleteMediaAsset(assetIds[index], req);
if (result === 'deleted') deletedCount += 1;
if (result === 'in-use') inUseCount += 1;
}
@@ -263,7 +287,7 @@ module.exports = function registerMediaLibraryRoutes(app, deps) {
app.post('/media-library/:id/delete', requireAccess, deps.requirePermission('media-library.delete'), async function (req, res, next) {
try {
- const result = await deleteMediaAsset(req.params.id);
+ const result = await deleteMediaAsset(req.params.id, req);
if (result === 'missing') {
return res.redirect('/media-library?message=' + encodeURIComponent('Media asset not found.'));
}
diff --git a/src/web/routes/settings/weather-icons.js b/src/web/routes/settings/weather-icons.js
index 31269a9..f0d5d82 100644
--- a/src/web/routes/settings/weather-icons.js
+++ b/src/web/routes/settings/weather-icons.js
@@ -6,6 +6,7 @@ const { buildPagination } = require('#src/web/lib/pagination');
const { createSearchMatcher, getSearchQuery, getSortDirectionQuery, getSortQuery, parsePageNumber, sortRows } = require('#src/web/lib/list-query');
module.exports = function registerWeatherIconRoutes(app, deps) {
+ const recordRequestAuditEvent = deps.recordRequestAuditEvent;
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 5 * 1024 * 1024, files: library.CONDITIONS.length * 2, fields: 2 } });
const fields = library.CONDITIONS.flatMap(function (condition) {
return [{ name: 'icon_' + condition.key, maxCount: 1 }, { name: 'night_icon_' + condition.key, maxCount: 1 }];
@@ -57,8 +58,17 @@ module.exports = function registerWeatherIconRoutes(app, deps) {
async function save(req, res, next) {
try {
const previousSets = library.readWeatherIconSets(deps.mediaDir);
- await library.saveWeatherIconSet(deps.mediaDir, req.body.name, Object.values(req.files || {}).flat(), req.params.id);
+ const uploadedFiles = Object.values(req.files || {}).flat();
+ const savedSet = await library.saveWeatherIconSet(deps.mediaDir, req.body.name, uploadedFiles, req.params.id);
req.weatherIconRemovalOperations = library.collectWeatherIconRemovalOperations(previousSets, library.readWeatherIconSets(deps.mediaDir));
+ if (typeof recordRequestAuditEvent === 'function') {
+ await recordRequestAuditEvent(deps.pool, req, {
+ category: 'media-assets', eventType: req.params.id ? 'weather-icon-set.updated' : 'weather-icon-set.created',
+ actorUserId: req.currentUser && req.currentUser.id, targetType: 'weather-icon-set',
+ targetId: savedSet.id, targetLabel: savedSet.name,
+ details: { uploadedFileCount: uploadedFiles.length, iconCount: Object.keys(savedSet.icons || {}).length, nightIconCount: Object.keys(savedSet.nightIcons || {}).length }
+ });
+ }
} catch (error) {
try { return await page(req, res, error.message, 400); } catch (pageError) { return next(pageError); }
}
@@ -74,6 +84,13 @@ module.exports = function registerWeatherIconRoutes(app, deps) {
const enabled = String(req.body && req.body.enabled || '').trim() !== '0';
const updated = library.setWeatherIconSetEnabled(deps.mediaDir, req.params.id, enabled);
if (!updated) return res.redirect('/settings/weather-icons?message=' + encodeURIComponent('That weather icon set was not found.'));
+ if (typeof recordRequestAuditEvent === 'function') {
+ await recordRequestAuditEvent(deps.pool, req, {
+ category: 'media-assets', eventType: enabled ? 'weather-icon-set.enabled' : 'weather-icon-set.disabled',
+ actorUserId: req.currentUser && req.currentUser.id, targetType: 'weather-icon-set',
+ targetId: updated.id, targetLabel: updated.name, details: { enabled: Boolean(enabled) }
+ });
+ }
await sync();
res.redirect('/settings/weather-icons?message=' + encodeURIComponent(enabled ? 'Weather icon set enabled.' : 'Weather icon set disabled.'));
} catch (error) { next(error); }
@@ -89,6 +106,13 @@ module.exports = function registerWeatherIconRoutes(app, deps) {
});
if (used) return await page(req, res, 'That icon set is still used by one or more slides.', 400);
const removed = library.deleteWeatherIconSet(deps.mediaDir, req.params.id);
+ if (typeof recordRequestAuditEvent === 'function') {
+ await recordRequestAuditEvent(deps.pool, req, {
+ category: 'media-assets', eventType: 'weather-icon-set.deleted', actorUserId: req.currentUser && req.currentUser.id,
+ targetType: 'weather-icon-set', targetId: removed.id, targetLabel: removed.name,
+ details: { iconCount: Object.keys(removed.icons || {}).length, nightIconCount: Object.keys(removed.nightIcons || {}).length }
+ });
+ }
await sync(library.collectWeatherIconRemovalOperations([removed], []));
res.redirect('/settings/weather-icons?message=' + encodeURIComponent('Weather icon set deleted.'));
} catch (error) { next(error); }
diff --git a/test/audit-log-settings.test.js b/test/audit-log-settings.test.js
index d4e541b..ac0e003 100644
--- a/test/audit-log-settings.test.js
+++ b/test/audit-log-settings.test.js
@@ -37,6 +37,8 @@ const request = {
test('audit categories include weather data sources', () => {
assert.ok(AUDIT_CATEGORY_KEYS.includes('weather'));
assert.equal(AUDIT_CATEGORY_LABELS.weather, 'Weather');
+ assert.ok(AUDIT_CATEGORY_KEYS.includes('media-assets'));
+ assert.equal(AUDIT_CATEGORY_LABELS['media-assets'], 'Media Assets');
});
test('screen control auditing is disabled by default', () => {
diff --git a/test/fonts-routes.test.js b/test/fonts-routes.test.js
index 2b44dc2..0227e31 100644
--- a/test/fonts-routes.test.js
+++ b/test/fonts-routes.test.js
@@ -226,4 +226,52 @@ test('fonts page renders the upload card above the table card', () => {
assert.match(html, /data-table-sort-key="file"/);
assert.match(html, /data-table-sort-key="format"/);
assert.match(html, /data-table-sort-key="status"/);
+});
+
+test('font upload, status changes, and deletion are audited', async () => {
+ const originals = {
+ loadFontLibrary: fontLibrary.loadFontLibrary,
+ addFontFile: fontLibrary.addFontFile,
+ setFontEnabled: fontLibrary.setFontEnabled,
+ deleteFontFile: fontLibrary.deleteFontFile
+ };
+ const { app, handlers } = createHandlers();
+ const events = [];
+ const font = { id: 'alpha.woff2', family: 'Alpha Sans', name: 'Alpha Sans', fileName: 'alpha.woff2', format: 'woff2', enabled: true };
+ fontLibrary.loadFontLibrary = function () { return { fonts: [font], stylesheetHref: '' }; };
+ fontLibrary.addFontFile = async function () { return { font: font, fonts: [font] }; };
+ fontLibrary.setFontEnabled = async function (_dir, _id, enabled) { return Object.assign({}, font, { enabled: enabled }); };
+ fontLibrary.deleteFontFile = async function () { return font; };
+
+ try {
+ registerFontRoutes(app, {
+ pool: { async query() { return [[{ match_count: 0 }]]; } },
+ pages: { renderFontsPage() { return ''; } },
+ upload: { single() { return function (_req, _res, next) { next(); }; } },
+ uploadDir: 'unused',
+ backgroundTaskQueue: null,
+ recordRequestAuditEvent(_pool, _req, event) { events.push(event); }
+ });
+ const response = { redirect() { return this; } };
+ const next = function (error) { if (error) throw error; };
+ const currentUser = { id: 12, permissionKeys: ['fonts.create', 'fonts.update', 'fonts.delete'] };
+
+ await handlers['POST /settings/fonts'].at(-1)(
+ { currentUser: currentUser, body: { font_family: 'Alpha Sans' }, file: { path: 'unused' } }, response, next
+ );
+ await handlers['POST /settings/fonts/:id/toggle'].at(-1)(
+ { currentUser: currentUser, params: { id: font.id }, body: { enabled: '0' } }, response, next
+ );
+ await handlers['POST /settings/fonts/:id/delete'].at(-1)(
+ { currentUser: currentUser, params: { id: font.id } }, response, next
+ );
+
+ assert.deepEqual(events.map(function (event) { return event.eventType; }), ['font.uploaded', 'font.disabled', 'font.deleted']);
+ assert.ok(events.every(function (event) { return event.category === 'media-assets' && event.actorUserId === 12; }));
+ } finally {
+ fontLibrary.loadFontLibrary = originals.loadFontLibrary;
+ fontLibrary.addFontFile = originals.addFontFile;
+ fontLibrary.setFontEnabled = originals.setFontEnabled;
+ fontLibrary.deleteFontFile = originals.deleteFontFile;
+ }
});
\ No newline at end of file
diff --git a/test/media-library.test.js b/test/media-library.test.js
index df840f3..90a0d1a 100644
--- a/test/media-library.test.js
+++ b/test/media-library.test.js
@@ -5,6 +5,9 @@ const os = require('os');
const path = require('path');
const crypto = require('crypto');
const sharp = require('sharp');
+require('../src/common');
+const mediaLibrary = require('../src/web/lib/media/library');
+const registerMediaLibraryRoutes = require('../src/web/routes/settings/media-library');
const {
normalizeMediaPath,
createImageVariants,
@@ -140,4 +143,72 @@ test('media library counts slide and template references', async () => {
assert.equal(queries[0].params[0], '/media/uploads/shared.png');
assert.equal(queries[1].params[0], '/media/uploads/shared.png');
assert.match(queries[0].sql, /LOCATE/);
+ assert.match(queries[0].sql, /CAST\(COALESCE\(content_json, ''\) AS CHAR\)/);
+});
+
+test('media library upload and delete routes audit successful asset changes', async () => {
+ const originalCreateVariants = mediaLibrary.createImageVariants;
+ const originalRegisterAsset = mediaLibrary.registerMediaAsset;
+ const originalCountReferences = mediaLibrary.countMediaAssetReferences;
+ const originalRemoveVariants = mediaLibrary.removeImageVariants;
+ const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-media-audit-'));
+ const uploadDir = path.join(root, 'media', 'uploads');
+ const filePath = path.join(uploadDir, 'photo.png');
+ await fs.promises.mkdir(uploadDir, { recursive: true });
+ await fs.promises.writeFile(filePath, 'image');
+ const routeHandlers = {};
+ const events = [];
+ const app = {
+ get(route, ...handlers) { routeHandlers['GET ' + route] = handlers; },
+ post(route, ...handlers) { routeHandlers['POST ' + route] = handlers; }
+ };
+ const pool = {
+ async query(sql) {
+ if (sql.includes('FROM o_app_settings')) return [[]];
+ if (sql.includes('FROM c_media_assets')) {
+ return [[{ id: 9, media_path: '/media/uploads/photo.png', original_name: 'Photo.png', media_type: 'image', file_size: 5 }]];
+ }
+ return [[]];
+ }
+ };
+
+ mediaLibrary.createImageVariants = async function () {};
+ mediaLibrary.registerMediaAsset = async function () {};
+ mediaLibrary.countMediaAssetReferences = async function () { return 0; };
+ mediaLibrary.removeImageVariants = async function () {};
+
+ try {
+ registerMediaLibraryRoutes(app, {
+ pool: pool,
+ pages: {},
+ upload: { single() { return function (_req, _res, next) { next(); }; } },
+ uploadDir: uploadDir,
+ getAuditUserId(req) { return req.currentUser.id; },
+ recordRequestAuditEvent(_pool, _req, event) { events.push(event); },
+ requirePermission() { return function (_req, _res, next) { next(); }; }
+ });
+ const response = { redirect(value) { this.location = value; return this; } };
+ const next = function (error) { if (error) throw error; };
+ const request = {
+ currentUser: { id: 4 },
+ body: {},
+ file: { path: filePath, filename: 'photo.png', originalname: 'Photo.png', mimetype: 'image/png', size: 5 }
+ };
+
+ await routeHandlers['POST /media-library'].at(-1)(request, response, next);
+ await routeHandlers['POST /media-library/:id/delete'].at(-1)(
+ { currentUser: { id: 4 }, params: { id: '9' } }, response, next
+ );
+
+ assert.deepEqual(events.map(function (event) { return event.eventType; }), ['media.uploaded', 'media.deleted']);
+ assert.equal(events[0].targetLabel, 'Photo.png');
+ assert.equal(events[1].targetId, 9);
+ assert.ok(events.every(function (event) { return event.category === 'media-assets' && event.actorUserId === 4; }));
+ } finally {
+ mediaLibrary.createImageVariants = originalCreateVariants;
+ mediaLibrary.registerMediaAsset = originalRegisterAsset;
+ mediaLibrary.countMediaAssetReferences = originalCountReferences;
+ mediaLibrary.removeImageVariants = originalRemoveVariants;
+ await fs.promises.rm(root, { recursive: true, force: true });
+ }
});
diff --git a/test/timetable-region-timezone.test.js b/test/timetable-region-timezone.test.js
index f88a7c1..455e4d9 100644
--- a/test/timetable-region-timezone.test.js
+++ b/test/timetable-region-timezone.test.js
@@ -83,6 +83,91 @@ function loadScheduleModule(overrides) {
return registry.get('timetable');
}
+test('timetable date ranges agree in preview and player, before limiting items', () => {
+ const previewModule = loadScheduleModule();
+ const groups = [{ id: 1, entries: [
+ { id: 1, title: 'Early', start_datetime: '2099-08-20T09:00:00Z' },
+ { id: 2, title: 'Middle', start_datetime: '2099-08-20T10:00:00Z' },
+ { id: 3, title: 'Late', start_datetime: '2099-08-20T11:00:00Z' }
+ ] }];
+ let playerModule;
+ const sandbox = {
+ window: {
+ pulsePlayerRegionTypes: { register(type, module) { playerModule = module; } },
+ placeholderUtils: {
+ resolvePlaceholderExpression(entry, expression) { return entry[expression]; },
+ formatPlaceholderValue(value) { return value; }
+ }
+ },
+ initialData: { timetableGroups: groups },
+ escapeHtml: String,
+ sanitizeRichText: String
+ };
+ vm.runInNewContext(fs.readFileSync(require.resolve('../src/player/regions/timetable.js'), 'utf8'), sandbox);
+ const cases = [
+ [{}, ['Early', 'Middle', 'Late']],
+ [{ date_filter: 'all', date_from: '2099-08-20T10:00:00Z' }, ['Early', 'Middle', 'Late']],
+ [{ date_filter: 'between', date_from: '2099-08-20T10:00:00Z' }, ['Middle', 'Late']],
+ [{ date_filter: 'between', date_to: '2099-08-20T10:00:00Z' }, ['Early', 'Middle']],
+ [{ date_filter: 'between', date_from: '2099-08-20T12:00:00+02:00', date_to: '2099-08-20T10:00:00Z' }, ['Middle']],
+ [{ date_filter: 'between', date_from: '2099-08-20T10:00:00Z', max_items: 1 }, ['Middle']],
+ [{ date_filter: 'between', date_from: '2099-08-21T00:00:00Z' }, []],
+ [{ date_filter: 'after', date_from: '2099-08-20T10:00:00Z', date_to: '2099-08-20T09:00:00Z' }, ['Middle', 'Late']],
+ [{ date_filter: 'before', date_to: '2099-08-20T10:00:00Z', date_from: '2099-08-20T11:00:00Z' }, ['Early', 'Middle']]
+ ];
+ for (const [range, titles] of cases) {
+ const config = { value: '{{title}}', timetable_group_id: 1, display_mode: 'both', max_items: 5, ...range };
+ const region = { id: 47, baseStyle: '' };
+ const preview = previewModule.renderPreview(region, config, { timetableGroups: groups });
+ const player = playerModule.renderRegion(region, config);
+ for (const title of ['Early', 'Middle', 'Late']) {
+ assert.equal(preview.includes(title), titles.includes(title), 'preview ' + JSON.stringify(range));
+ assert.equal(player.includes(title), titles.includes(title), 'player ' + JSON.stringify(range));
+ }
+ }
+
+ groups[0].entries = [{ id: 4, title: 'Live', start_datetime: new Date(Date.now() - 60000).toISOString(), end_datetime: new Date(Date.now() + 60000).toISOString() }];
+ const config = { value: '{{title}}', timetable_group_id: 1, display_mode: 'upcoming', date_filter: 'between', date_from: '2099-08-20T00:00:00Z' };
+ assert.doesNotMatch(previewModule.renderPreview({}, config, { timetableGroups: groups }), /Live/);
+ assert.doesNotMatch(playerModule.renderRegion({}, config), /Live/);
+});
+
+test('timetable date controls default to all and pass unsaved date ranges to preview', () => {
+ const module = loadScheduleModule();
+ const region = { id: 47, region_key: 'timetable', label: 'Timetable' };
+ const html = module.renderEditorCard({ region, current: {}, timetableGroups: [] });
+ assert.match(html, /