From 459f84ed94c58bed842edfca81dea1864c88879a Mon Sep 17 00:00:00 2001 From: Mark Rapson Date: Sun, 9 Aug 2026 11:57:40 +0100 Subject: [PATCH] Release v2.6.19 --- CHANGELOG.md | 34 ++ build/package.player.json | 2 +- build/package.web.json | 2 +- package.json | 2 +- src/data/index.js | 2 +- src/data/slides.js | 43 +++ src/data/{schedules.js => timetables.js} | 49 ++- src/db/index.js | 9 +- src/db/migrations.js | 231 ++++++++++++-- src/player/regions/schedule.js | 2 + src/player/regions/time-date.js | 2 +- src/web/public/css/theme-custom.css | 32 ++ .../js/data-sources/timetable-group-form.js | 218 ++++++++++--- src/web/public/js/regions/type/api.js | 12 +- src/web/public/js/regions/type/rss.js | 12 +- src/web/public/js/regions/type/schedule.js | 127 +++++++- src/web/public/js/regions/type/text.js | 12 +- src/web/public/js/regions/type/time-date.js | 6 +- src/web/public/js/shared/placeholder-utils.js | 201 ++++++++++-- .../js/shared/time-date-placeholders.js | 94 ++++-- src/web/public/js/slides/slide-form-editor.js | 31 +- src/web/public/js/slides/slide-form.js | 8 + .../data-sources/timetables/duplicate.js | 3 +- .../timetables/form-view-model.js | 74 ++++- .../routes/data-sources/timetables/list.js | 35 ++- .../routes/data-sources/timetables/routes.js | 48 +-- .../views/data-sources/timetables/form.hbs | 30 +- .../views/data-sources/timetables/list.hbs | 6 +- test/clients-routes.test.js | 16 +- test/timetable-group-form.test.js | 64 ++-- test/timetable-region-timezone.test.js | 295 ++++++++++++++++++ test/timetable-region.test.js | 42 +++ test/timetables-duplicate.test.js | 7 +- test/timetables-routes.test.js | 155 +++++++++ 34 files changed, 1650 insertions(+), 256 deletions(-) rename src/data/{schedules.js => timetables.js} (64%) create mode 100644 test/timetable-region-timezone.test.js create mode 100644 test/timetable-region.test.js create mode 100644 test/timetables-routes.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 97c35f9..40e4189 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,40 @@ All notable changes to this project will be documented in this file. +## 2.6.19 - 2026-08-09 + +### Changed + +- Timetable editor helpers, routes, and table layout now use timetable-specific naming and tighter card/table styling. +- Connected clients now sort by client identity fields instead of the old IP-based ordering assumption. + +## 2.6.18 - 2026-08-09 + +### Changed + +- Timetable tables were renamed from the old `schedule` names to `timetable` names, and existing databases now rename those tables during migration. +- The timetable group editor now uses timetable-specific naming in its shared helpers and keeps the entries table aligned with the standard admin card/table layout. + +## 2.6.17 - 2026-08-09 + +### Changed + +- Existing timetable groups and entries are now migrated to Europe/London, and timetable dates are rewritten to UTC using that source timezone so the wall-clock meaning stays intact. + +### Fixed + +- New timetable groups now default to Europe/London so the timetable editor and saved data start from the same timezone assumption as the migrated rows. + +## 2.6.16 - 2026-08-09 + +### Changed + +- Timetable groups now store an IANA time zone and render their entry datetimes in that timetable time zone, so schedules keep the same wall-clock meaning when they are edited from another country. + +### Fixed + +- Timetable entry date inputs now round-trip through the timetable time zone instead of the browser locale, so saving from Florida while targeting Germany keeps the intended local times. + ## 2.6.15 - 2026-08-08 ### Fixed diff --git a/build/package.player.json b/build/package.player.json index d0bdea5..e23722b 100644 --- a/build/package.player.json +++ b/build/package.player.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-player", - "version": "2.6.15", + "version": "2.6.19", "private": false, "description": "Pulse Signage player application bundle", "main": "src/common.js", diff --git a/build/package.web.json b/build/package.web.json index 83df718..0a300f1 100644 --- a/build/package.web.json +++ b/build/package.web.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-web", - "version": "2.6.15", + "version": "2.6.19", "private": false, "description": "Pulse Signage web and bridge application bundle", "main": "src/common.js", diff --git a/package.json b/package.json index ccd8ad2..5ebf5a5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "2.6.15", + "version": "2.6.19", "private": false, "description": "Pulse Signage application with MySQL and media storage", "repository": { diff --git a/src/data/index.js b/src/data/index.js index fa0a0ab..fec85f7 100644 --- a/src/data/index.js +++ b/src/data/index.js @@ -4,7 +4,7 @@ const { fetchAdminData, fetchPlaylistsPage, fetchSlidesPage, fetchTemplatesPage, const { ANNOUNCEMENT_TYPES, ANNOUNCEMENT_COLORS, ANNOUNCEMENT_ICONS, DEFAULT_ANNOUNCEMENT_ICON, normalizeAnnouncementType, normalizeAnnouncementColor, normalizeAnnouncementIcon, fetchAnnouncementsPage, fetchAnnouncementById, fetchActiveAnnouncement, buildAnnouncementPayload } = require('./announcements'); const { ANNOUNCEMENT_ICON_OPTIONS, ANNOUNCEMENT_ICON_LABELS } = require('./announcement-icons'); const { fetchPlaylistById } = require('./playlists'); -const { normalizeDisplayMode, fetchTimetablesData, fetchTimetableGroupsPage, fetchTimetableGroupById, fetchTimetableEntriesByGroupId, buildTimetableGroupPayload } = require('./schedules'); +const { normalizeDisplayMode, fetchTimetablesData, fetchTimetableGroupsPage, fetchTimetableGroupById, fetchTimetableEntriesByGroupId, buildTimetableGroupPayload } = require('./timetables'); const { fetchApiSourcesData, fetchApiSourcesPage, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('./api-sources'); const { fetchRssFeedsData, fetchRssFeedsPage, fetchRssFeedById, fetchRssFeedItemsByFeedId, normalizeRssFeedItem, buildRssFeedPayload, fetchRssFeedItems, replaceRssFeedItems } = require('./rss-feeds'); const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData, fetchScreenPlayerUrls, fetchPlayerPublicBaseUrl, fetchScreenPlayerRecord, fetchPlayerRecordByIdentifier } = require('./screens'); diff --git a/src/data/slides.js b/src/data/slides.js index 5f6cca0..8a687c8 100644 --- a/src/data/slides.js +++ b/src/data/slides.js @@ -308,6 +308,49 @@ async function buildTemplateContent(pool, template, body, filesByField, existing value: submitted === undefined ? String(current.value !== undefined ? current.value : current.text !== undefined ? current.text : '') : String(submitted || ''), timezone: timezoneValue === undefined || timezoneValue === null ? String(current.timezone || current.time_zone || '') : String(timezoneValue || '').trim() }; + } else if (region.region_type === 'timetable') { + const current = existingContent && existingContent[region.region_key] && typeof existingContent[region.region_key] === 'object' ? existingContent[region.region_key] : {}; + const suffix = '_' + region.id; + const generic = {}; + const submittedText = body[`region_text_${region.id}`]; + const normalizedText = submittedText === undefined ? String(current.text !== undefined ? current.text : current.value !== undefined ? current.value : '') : String(submittedText || ''); + + Object.keys(body || {}).forEach((key) => { + if (!key.startsWith('region_') || !key.endsWith(suffix)) { + return; + } + + const field = key.slice('region_'.length, -suffix.length); + if (!field || field === 'type' || field === 'key' || field === 'name' || field === 'label') { + return; + } + + generic[field] = body[key]; + }); + + if (Object.prototype.hasOwnProperty.call(generic, 'timetable_display_mode')) { + generic.display_mode = generic.timetable_display_mode; + delete generic.timetable_display_mode; + } + + if (Object.prototype.hasOwnProperty.call(generic, 'timetable_max_items')) { + generic.max_items = generic.timetable_max_items; + delete generic.timetable_max_items; + } + + Object.keys(current).forEach((key) => { + if (generic[key] === undefined) { + generic[key] = current[key]; + } + }); + + delete generic.timetable_display_mode; + delete generic.timetable_max_items; + + generic.text = normalizedText; + generic.value = normalizedText; + generic.type = region.region_type; + content[region.region_key] = generic; } else if (region.region_type === 'rss') { const submitted = body[`region_text_${region.id}`]; const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {}; diff --git a/src/data/schedules.js b/src/data/timetables.js similarity index 64% rename from src/data/schedules.js rename to src/data/timetables.js index b1091a4..d57534f 100644 --- a/src/data/schedules.js +++ b/src/data/timetables.js @@ -4,6 +4,23 @@ const { fetchPagedRows, validateMaxLength } = require('./utils'); const NAME_MAX_LENGTH = 255; const DESCRIPTION_MAX_LENGTH = 255; +const DEFAULT_TIME_ZONE = 'Europe/London'; + +function normalizeTimeZone(value, fallback) { + const raw = String(value || '').trim(); + if (!raw) { + return String(fallback || DEFAULT_TIME_ZONE).trim() || DEFAULT_TIME_ZONE; + } + + try { + new Intl.DateTimeFormat('en-GB', { timeZone: raw }).format(new Date()); + return raw; + } catch (_error) { + const error = new Error('Timetable time zone is invalid.'); + error.statusCode = 400; + throw error; + } +} function normalizeDisplayMode(value) { const mode = String(value || 'upcoming').trim().toLowerCase(); @@ -15,15 +32,15 @@ function normalizeDisplayMode(value) { async function fetchTimetablesData(pool) { const [timetableGroups] = await pool.query(` - SELECT g.id, g.name, g.short_description, g.created_at, g.modified_at, g.created_by, g.modified_by, - (SELECT COUNT(*) FROM i_schedule_entries e WHERE e.schedule_group_id = g.id) AS entry_count, - (SELECT MIN(e.start_datetime) FROM i_schedule_entries e WHERE e.schedule_group_id = g.id AND e.start_datetime IS NOT NULL) AS next_start_datetime - FROM i_schedule_groups g + SELECT g.id, g.name, g.short_description, g.timezone, g.created_at, g.modified_at, g.created_by, g.modified_by, + (SELECT COUNT(*) FROM i_timetable_entries e WHERE e.schedule_group_id = g.id) AS entry_count, + (SELECT MIN(e.start_datetime) FROM i_timetable_entries e WHERE e.schedule_group_id = g.id AND e.start_datetime IS NOT NULL) AS next_start_datetime + FROM i_timetable_groups g ORDER BY g.modified_at DESC, g.id DESC `); const [timetableEntries] = await pool.query(` SELECT id, schedule_group_id, title, short_description, start_datetime, end_datetime, created_at, modified_at, created_by, modified_by - FROM i_schedule_entries + FROM i_timetable_entries ORDER BY schedule_group_id ASC, start_datetime ASC, id ASC `); @@ -50,17 +67,18 @@ async function fetchTimetablesData(pool) { async function fetchTimetableGroupsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) { const paged = await fetchPagedRows(pool, { - selectSql: `SELECT g.id, g.name, g.short_description, g.created_at, g.modified_at, g.created_by, g.modified_by, - (SELECT COUNT(*) FROM i_schedule_entries e WHERE e.schedule_group_id = g.id) AS entry_count, - (SELECT MIN(e.start_datetime) FROM i_schedule_entries e WHERE e.schedule_group_id = g.id AND e.start_datetime IS NOT NULL) AS next_start_datetime - FROM i_schedule_groups g + selectSql: `SELECT g.id, g.name, g.short_description, g.timezone, g.created_at, g.modified_at, g.created_by, g.modified_by, + (SELECT COUNT(*) FROM i_timetable_entries e WHERE e.schedule_group_id = g.id) AS entry_count, + (SELECT MIN(e.start_datetime) FROM i_timetable_entries e WHERE e.schedule_group_id = g.id AND e.start_datetime IS NOT NULL) AS next_start_datetime + FROM i_timetable_groups g ORDER BY g.modified_at DESC, g.id DESC`, - countSql: 'SELECT COUNT(*) AS count FROM i_schedule_groups', + countSql: 'SELECT COUNT(*) AS count FROM i_timetable_groups', searchColumns: ['g.name', 'g.short_description'], searchTerm: searchTerm, sortColumns: { name: 'g.name', description: 'g.short_description', + timezone: 'g.timezone', entries: 'entry_count', next_start: 'next_start_datetime', created: 'g.created_at', @@ -77,7 +95,7 @@ async function fetchTimetableGroupsPage(pool, page, pageSize, searchTerm, sortKe async function fetchTimetableGroupById(pool, id) { const [rows] = await pool.query( - 'SELECT id, name, short_description, created_at, modified_at, created_by, modified_by FROM i_schedule_groups WHERE id = ?', + 'SELECT id, name, short_description, timezone, created_at, modified_at, created_by, modified_by FROM i_timetable_groups WHERE id = ?', [id] ); @@ -87,7 +105,7 @@ async function fetchTimetableGroupById(pool, id) { async function fetchTimetableEntriesByGroupId(pool, timetableGroupId) { const [rows] = await pool.query( `SELECT id, schedule_group_id, title, short_description, start_datetime, end_datetime, created_at, modified_at, created_by, modified_by - FROM i_schedule_entries + FROM i_timetable_entries WHERE schedule_group_id = ? ORDER BY start_datetime ASC, id ASC`, [timetableGroupId] @@ -100,6 +118,7 @@ function buildTimetableGroupPayload(req, existingTimetableGroup) { const fallback = existingTimetableGroup || {}; const name = validateMaxLength(req.body.name || fallback.name || '', NAME_MAX_LENGTH, 'Timetable group name'); const shortDescription = validateMaxLength(req.body.short_description || req.body.shortDescription || fallback.short_description || '', DESCRIPTION_MAX_LENGTH, 'Timetable group description'); + const timezone = normalizeTimeZone(req.body.timezone || req.body.time_zone || fallback.timezone || DEFAULT_TIME_ZONE, fallback.timezone || DEFAULT_TIME_ZONE); if (!name) { const error = new Error('Timetable group name is required.'); @@ -109,7 +128,8 @@ function buildTimetableGroupPayload(req, existingTimetableGroup) { return { name: name, - shortDescription: shortDescription + shortDescription: shortDescription, + timezone: timezone }; } @@ -119,5 +139,6 @@ module.exports = { fetchTimetableGroupsPage, fetchTimetableGroupById, fetchTimetableEntriesByGroupId, - buildTimetableGroupPayload + buildTimetableGroupPayload, + normalizeTimeZone }; \ No newline at end of file diff --git a/src/db/index.js b/src/db/index.js index 2ff57a4..e923404 100644 --- a/src/db/index.js +++ b/src/db/index.js @@ -238,10 +238,11 @@ async function ensureSchema(pool, options) { `); await pool.query(` - CREATE TABLE IF NOT EXISTS i_schedule_groups ( + CREATE TABLE IF NOT EXISTS i_timetable_groups ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL, short_description VARCHAR(255) NULL, + timezone VARCHAR(64) NOT NULL DEFAULT 'Europe/London', created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by INT NULL, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, @@ -250,7 +251,7 @@ async function ensureSchema(pool, options) { `); await pool.query(` - CREATE TABLE IF NOT EXISTS i_schedule_entries ( + CREATE TABLE IF NOT EXISTS i_timetable_entries ( id INT AUTO_INCREMENT PRIMARY KEY, schedule_group_id INT NOT NULL, title VARCHAR(255) NOT NULL, @@ -261,8 +262,8 @@ async function ensureSchema(pool, options) { created_by INT NULL, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, modified_by INT NULL, - CONSTRAINT fk_schedule_entries_group FOREIGN KEY (schedule_group_id) REFERENCES i_schedule_groups(id) ON DELETE CASCADE, - INDEX idx_schedule_entries_group_start (schedule_group_id, start_datetime) + CONSTRAINT fk_timetable_entries_group FOREIGN KEY (schedule_group_id) REFERENCES i_timetable_groups(id) ON DELETE CASCADE, + INDEX idx_timetable_entries_group_start (schedule_group_id, start_datetime) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); diff --git a/src/db/migrations.js b/src/db/migrations.js index 82b12d4..147b87d 100644 --- a/src/db/migrations.js +++ b/src/db/migrations.js @@ -1,4 +1,5 @@ const { version: appVersion } = require('#root/package.json'); +const TIMETABLE_TIME_ZONE = 'Europe/London'; const VERSIONED_MIGRATIONS = [ { @@ -22,32 +23,32 @@ const VERSIONED_MIGRATIONS = [ // Store the player pointer on screens so we can resolve the player without needing a player-side screen_id. // This is the singleton-player shortcut; a multi-player model should make this relational instead of hardcoded to '1'. if (!(await columnExists(pool, 'd_screens', 'player_id'))) { - await pool.query("ALTER TABLE d_screens ADD COLUMN player_id VARCHAR(128) NOT NULL DEFAULT '1' AFTER playlist_id"); - } else { - await pool.query("UPDATE d_screens SET player_id = '1' WHERE player_id IS NULL OR player_id <> '1'"); + await pool.query("ALTER TABLE d_screens ADD COLUMN player_id VARCHAR(128) NOT NULL DEFAULT '1' AFTER playlist_id"); + } else { + await pool.query("UPDATE d_screens SET player_id = '1' WHERE player_id IS NULL OR player_id <> '1'"); - const [playerColumnNullableRows] = await pool.query( - `SELECT COUNT(*) AS nullable_count - FROM information_schema.COLUMNS - WHERE TABLE_SCHEMA = DATABASE() - AND TABLE_NAME = 'd_screens' - AND COLUMN_NAME = 'player_id' - AND IS_NULLABLE = 'YES'` - ); - if (Number(playerColumnNullableRows && playerColumnNullableRows[0] && playerColumnNullableRows[0].nullable_count) > 0) { - await pool.query("ALTER TABLE d_screens MODIFY COLUMN player_id VARCHAR(128) NOT NULL DEFAULT '1' AFTER playlist_id"); - } - } - - const [screenPlayerUniqueRows] = await pool.query( - `SELECT COUNT(*) AS index_count - FROM information_schema.STATISTICS + const [playerColumnNullableRows] = await pool.query( + `SELECT COUNT(*) AS nullable_count + FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'd_screens' - AND INDEX_NAME = 'uq_screens_player_id'` + AND COLUMN_NAME = 'player_id' + AND IS_NULLABLE = 'YES'` ); - if (Number(screenPlayerUniqueRows && screenPlayerUniqueRows[0] && screenPlayerUniqueRows[0].index_count) > 0) { - await pool.query('ALTER TABLE d_screens DROP INDEX uq_screens_player_id'); + if (Number(playerColumnNullableRows && playerColumnNullableRows[0] && playerColumnNullableRows[0].nullable_count) > 0) { + await pool.query("ALTER TABLE d_screens MODIFY COLUMN player_id VARCHAR(128) NOT NULL DEFAULT '1' AFTER playlist_id"); + } + } + + const [screenPlayerUniqueRows] = await pool.query( + `SELECT COUNT(*) AS index_count + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'd_screens' + AND INDEX_NAME = 'uq_screens_player_id'` + ); + if (Number(screenPlayerUniqueRows && screenPlayerUniqueRows[0] && screenPlayerUniqueRows[0].index_count) > 0) { + await pool.query('ALTER TABLE d_screens DROP INDEX uq_screens_player_id'); } // Recreate the screen-to-player foreign key after the column exists and legacy data is copied over. @@ -56,7 +57,6 @@ const VERSIONED_MIGRATIONS = [ if (await columnExists(pool, 'c_template_regions', 'font_family')) { await pool.query('ALTER TABLE c_template_regions DROP COLUMN font_family'); } - } }, { @@ -276,6 +276,7 @@ const VERSIONED_MIGRATIONS = [ await pool.query('RENAME TABLE d_players_rebuild TO d_players'); await pool.query('ALTER TABLE d_screens MODIFY COLUMN player_id INT NULL AFTER playlist_id'); + return; } }, { @@ -295,6 +296,71 @@ const VERSIONED_MIGRATIONS = [ await dropForeignKeyIfExists(pool, 'd_screens', 'player_id'); await dropColumnIfExists(pool, 'd_screens', 'player_id'); } + }, + { + version: '2.6.16', + label: 'v2.6.16 timetable timezone schema', + run: async function (pool) { + await ensureColumn(pool, 'i_schedule_groups', 'timezone', "VARCHAR(64) NOT NULL DEFAULT 'Europe/London'", 'short_description'); + } + }, + { + version: '2.6.17', + label: 'v2.6.17 timetable europe/london conversion', + run: async function (pool) { + await ensureColumn(pool, 'i_schedule_groups', 'timezone', "VARCHAR(64) NOT NULL DEFAULT 'Europe/London'", 'short_description'); + + await pool.query('UPDATE i_schedule_groups SET timezone = ?', [TIMETABLE_TIME_ZONE]); + + const [rows] = await pool.query('SELECT id, start_datetime, end_datetime FROM i_schedule_entries ORDER BY id ASC'); + for (const row of rows) { + const startDate = convertMigrationDateTimeFromTimeZone(row.start_datetime, TIMETABLE_TIME_ZONE); + const endDate = row.end_datetime ? convertMigrationDateTimeFromTimeZone(row.end_datetime, TIMETABLE_TIME_ZONE) : null; + await pool.query( + 'UPDATE i_schedule_entries SET start_datetime = ?, end_datetime = ? WHERE id = ?', + [formatMigrationDateTimeUtc(startDate), endDate ? formatMigrationDateTimeUtc(endDate) : null, row.id] + ); + } + } + }, + { + version: '2.6.18', + label: 'v2.6.18 timetable table rename', + run: async function (pool) { + const scheduleGroupsExists = await tableExists(pool, 'i_schedule_groups'); + const timetableGroupsExists = await tableExists(pool, 'i_timetable_groups'); + + if (scheduleGroupsExists) { + if (!timetableGroupsExists) { + await pool.query('RENAME TABLE i_schedule_groups TO i_timetable_groups, i_schedule_entries TO i_timetable_entries'); + return; + } + + await pool.query(` + INSERT IGNORE INTO i_timetable_groups (id, name, short_description, timezone, created_at, created_by, modified_at, modified_by) + SELECT id, name, short_description, timezone, created_at, created_by, modified_at, modified_by + FROM i_schedule_groups + ORDER BY id ASC + `); + + await pool.query(` + INSERT IGNORE INTO i_timetable_entries (id, schedule_group_id, title, short_description, start_datetime, end_datetime, created_at, created_by, modified_at, modified_by) + SELECT id, schedule_group_id, title, short_description, start_datetime, end_datetime, created_at, created_by, modified_at, modified_by + FROM i_schedule_entries + ORDER BY schedule_group_id ASC, start_datetime ASC, id ASC + `); + + const [groupRows] = await pool.query('SELECT COALESCE(MAX(id), 0) AS max_id FROM i_timetable_groups'); + const [entryRows] = await pool.query('SELECT COALESCE(MAX(id), 0) AS max_id FROM i_timetable_entries'); + const nextGroupId = Number(groupRows && groupRows[0] && groupRows[0].max_id) + 1; + const nextEntryId = Number(entryRows && entryRows[0] && entryRows[0].max_id) + 1; + await pool.query('ALTER TABLE i_timetable_groups AUTO_INCREMENT = ' + nextGroupId); + await pool.query('ALTER TABLE i_timetable_entries AUTO_INCREMENT = ' + nextEntryId); + + await pool.query('DROP TABLE i_schedule_entries'); + await pool.query('DROP TABLE i_schedule_groups'); + } + } } ]; @@ -311,6 +377,18 @@ async function columnExists(pool, tableName, columnName) { return Number(rows && rows[0] && rows[0].column_count) > 0; } +async function tableExists(pool, tableName) { + const [rows] = await pool.query( + `SELECT COUNT(*) AS table_count + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ?`, + [tableName] + ); + + return Number(rows && rows[0] && rows[0].table_count) > 0; +} + async function columnIsAutoIncrement(pool, tableName, columnName) { const [rows] = await pool.query( `SELECT COUNT(*) AS auto_increment_count @@ -512,6 +590,107 @@ function formatMigrationDateTime(value) { return year + '-' + month + '-' + day + 'T' + hours + ':' + minutes; } +function parseMigrationDateTimeParts(value) { + if (!value) { + return null; + } + + if (value instanceof Date) { + if (Number.isNaN(value.getTime())) { + return null; + } + + return { + year: value.getUTCFullYear(), + month: value.getUTCMonth() + 1, + day: value.getUTCDate(), + hour: value.getUTCHours(), + minute: value.getUTCMinutes(), + second: value.getUTCSeconds() + }; + } + + const raw = String(value || '').trim(); + const match = raw.match(/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?$/); + if (!match) { + return null; + } + + return { + year: Number(match[1]), + month: Number(match[2]), + day: Number(match[3]), + hour: Number(match[4] || 0), + minute: Number(match[5] || 0), + second: Number(match[6] || 0) + }; +} + +function getMigrationTimeZoneOffsetMillis(date, timeZone) { + if (!(date instanceof Date) || Number.isNaN(date.getTime())) { + return 0; + } + + const parts = new Intl.DateTimeFormat('en-GB', { + timeZone: timeZone, + hour12: false, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }).formatToParts(date).reduce(function (acc, part) { + if (part && part.type && part.type !== 'literal') { + acc[part.type] = part.value; + } + return acc; + }, Object.create(null)); + + const localAsUtc = Date.UTC( + Number(parts.year) || 0, + (Number(parts.month) || 1) - 1, + Number(parts.day) || 1, + Number(parts.hour) || 0, + Number(parts.minute) || 0, + Number(parts.second) || 0, + 0 + ); + + return localAsUtc - date.getTime(); +} + +function convertMigrationDateTimeFromTimeZone(value, timeZone) { + const parts = parseMigrationDateTimeParts(value); + if (!parts) { + return null; + } + + const utcMillis = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second, 0); + let adjusted = new Date(utcMillis - getMigrationTimeZoneOffsetMillis(new Date(utcMillis), timeZone)); + const adjustedOffset = getMigrationTimeZoneOffsetMillis(adjusted, timeZone); + + if (adjustedOffset !== getMigrationTimeZoneOffsetMillis(new Date(utcMillis), timeZone)) { + adjusted = new Date(utcMillis - adjustedOffset); + } + + return adjusted; +} + +function formatMigrationDateTimeUtc(value) { + if (!(value instanceof Date) || Number.isNaN(value.getTime())) { + return null; + } + + const year = value.getUTCFullYear(); + const month = String(value.getUTCMonth() + 1).padStart(2, '0'); + const day = String(value.getUTCDate()).padStart(2, '0'); + const hours = String(value.getUTCHours()).padStart(2, '0'); + const minutes = String(value.getUTCMinutes()).padStart(2, '0'); + const seconds = String(value.getUTCSeconds()).padStart(2, '0'); + return year + '-' + month + '-' + day + ' ' + hours + ':' + minutes + ':' + seconds; +} + function formatMigrationTime(value) { if (!value) { return null; @@ -568,6 +747,8 @@ async function runMigrations(pool, options) { const currentVersion = String(options && options.currentVersion || '0.0.0').trim(); const legacyPlayerSchemaPresent = await columnExists(pool, 'd_players', 'device_id'); const screenPlayerColumnPresent = await columnExists(pool, 'd_screens', 'player_id'); + const legacyTimetableGroupsPresent = await tableExists(pool, 'i_schedule_groups'); + const legacyTimetableEntriesPresent = await tableExists(pool, 'i_schedule_entries'); let effectiveCurrentVersion = currentVersion; if (!legacyPlayerSchemaPresent && compareVersions(effectiveCurrentVersion, '2.1.0') < 0) { @@ -578,6 +759,10 @@ async function runMigrations(pool, options) { effectiveCurrentVersion = '2.6.3'; } + if (legacyTimetableGroupsPresent || legacyTimetableEntriesPresent) { + effectiveCurrentVersion = compareVersions(effectiveCurrentVersion, '2.6.18') < 0 ? '2.6.17' : '2.6.17'; + } + for (const migration of VERSIONED_MIGRATIONS) { if (compareVersions(migration.version, effectiveCurrentVersion) > 0 && compareVersions(migration.version, targetVersion) <= 0) { await migration.run(pool); diff --git a/src/player/regions/schedule.js b/src/player/regions/schedule.js index bec3b99..69d97cf 100644 --- a/src/player/regions/schedule.js +++ b/src/player/regions/schedule.js @@ -228,6 +228,7 @@ function renderRegion(region, regionContent) { var maxItems = regionContent && regionContent.max_items !== undefined ? regionContent.max_items : 5; var group = getGroupById(groupId, groups); var entries = getVisibleEntries(groupId, displayMode, maxItems, groups); + var timeZone = group && (group.timezone || group.time_zone) ? String(group.timezone || group.time_zone) : ''; var width = Math.max(1, Math.round(Number(region && region.pixelWidth ? region.pixelWidth : 0) || 1)); var height = Math.max(1, Math.round(Number(region && region.pixelHeight ? region.pixelHeight : 0) || 1)); var canvasScale = Number(region && region.canvasScale ? region.canvasScale : 1) || 1; @@ -241,6 +242,7 @@ function renderRegion(region, regionContent) { return '
' + sanitizeRichText(substituteTimetableVariables(value, Object.assign({}, entry || {}, { start: entry && entry.start_datetime !== undefined ? entry.start_datetime : '', end: entry && entry.end_datetime !== undefined ? entry.end_datetime : '', + timeZone: timeZone, group: group || {}, entries: entries, index: index + 1 diff --git a/src/player/regions/time-date.js b/src/player/regions/time-date.js index cf0222f..62df679 100644 --- a/src/player/regions/time-date.js +++ b/src/player/regions/time-date.js @@ -233,7 +233,7 @@ function renderTemplate(format, timeZone, date) { var template = String(format || '').trim() || DEFAULT_FORMAT; var values = getFormattedParts(timeZone, date); return template.replace(/\{\{\s*([a-zA-Z0-9_.()\-]+)\s*\}\}/g, function (_match, key) { - return String(resolveTimeDatePlaceholder(values, key) || ''); + return String(resolveTimeDatePlaceholder(values, key, { timeZone: timeZone }) || ''); }); } diff --git a/src/web/public/css/theme-custom.css b/src/web/public/css/theme-custom.css index 5ad675c..692aad5 100644 --- a/src/web/public/css/theme-custom.css +++ b/src/web/public/css/theme-custom.css @@ -756,6 +756,38 @@ background: var(--bs-tertiary-bg); } +.timetable-entries-table-shell { + overflow: hidden; + border-bottom-left-radius: calc(var(--bs-border-radius) - 1px); + border-bottom-right-radius: calc(var(--bs-border-radius) - 1px); +} + +.timetable-entries-table-shell > .table-responsive { + margin-bottom: 0; +} + +.timetable-entries-table { + border-collapse: collapse; +} + +.timetable-entries-table > thead > tr:first-child > * { + border-top-width: 0; +} + +.timetable-entries-table > :not(caption) > * > :first-child { + border-left-width: 0; + padding-left: 0; +} + +.timetable-entries-table > :not(caption) > * > :last-child { + border-right-width: 0; + padding-right: 0; +} + +.timetable-entries-table > tbody > tr:last-child > * { + border-bottom-width: 0; +} + .api-source-section-heading { display: flex; align-items: center; diff --git a/src/web/public/js/data-sources/timetable-group-form.js b/src/web/public/js/data-sources/timetable-group-form.js index 69b9778..b23d700 100644 --- a/src/web/public/js/data-sources/timetable-group-form.js +++ b/src/web/public/js/data-sources/timetable-group-form.js @@ -2,14 +2,17 @@ (function () { var form = document.getElementById('timetable-group-form'); + var timezoneInput = document.getElementById('timetable-group-timezone'); var body = document.querySelector('[data-timetable-entries-body]'); var addButton = document.querySelector('[data-add-timetable-entry]'); var template = document.getElementById('timetable-entry-row-template'); - if (!form || !body || !addButton || !template) { + if (!form || !timezoneInput || !body || !addButton || !template) { return; } + var DEFAULT_TIME_ZONE = 'Europe/London'; + function markDirty() { form.dataset.dirty = 'true'; } @@ -18,43 +21,150 @@ return String(value).padStart(2, '0'); } - function formatDateTimeLocalValue(date) { + function getDefaultTimeZone() { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || DEFAULT_TIME_ZONE; + } catch (_error) { + return DEFAULT_TIME_ZONE; + } + } + + function resolveTimeZone(value) { + var raw = String(value || '').trim(); + if (!raw) { + return getDefaultTimeZone(); + } + + try { + new Intl.DateTimeFormat('en-GB', { timeZone: raw }).format(new Date()); + return raw; + } catch (_error) { + return getDefaultTimeZone(); + } + } + + function parseDateTimeLocalParts(value) { + var raw = String(value || '').trim(); + var match = raw.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/); + if (!match) { + return null; + } + + return { + year: Number(match[1]), + month: Number(match[2]), + day: Number(match[3]), + hour: Number(match[4]), + minute: Number(match[5]), + second: Number(match[6] || '0') + }; + } + + function getDateTimeParts(date, timeZone) { if (!(date instanceof Date) || Number.isNaN(date.getTime())) { + return null; + } + + var resolvedTimeZone = resolveTimeZone(timeZone); + var parts = new Intl.DateTimeFormat('en-GB', { + timeZone: resolvedTimeZone, + hour12: false, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }).formatToParts(date); + var mapped = Object.create(null); + + parts.forEach(function (part) { + if (part && part.type && part.type !== 'literal') { + mapped[part.type] = part.value; + } + }); + + return mapped; + } + + function formatDateTimeLocalValue(date, timeZone) { + var parts = getDateTimeParts(date, timeZone); + if (!parts) { return ''; } return [ - String(date.getFullYear()).padStart(4, '0'), + String(parts.year || '').padStart(4, '0'), '-', - pad(date.getMonth() + 1), + pad(parts.month), '-', - pad(date.getDate()), + pad(parts.day), 'T', - pad(date.getHours()), + pad(parts.hour), ':', - pad(date.getMinutes()) + pad(parts.minute) ].join(''); } - function parseUtcDateTimeLocalValue(value) { - var raw = String(value || '').trim(); - if (!raw) { - return null; + function getTimeZoneOffsetMillis(date, timeZone) { + var parts = getDateTimeParts(date, timeZone); + if (!parts) { + return 0; } - var normalized = /(?:[zZ]|[+-]\d\d(?::?\d\d)?)$/.test(raw) ? raw : raw + 'Z'; - var date = new Date(normalized); - return Number.isNaN(date.getTime()) ? null : date; + var localAsUtc = Date.UTC( + Number(parts.year) || 0, + (Number(parts.month) || 1) - 1, + Number(parts.day) || 1, + Number(parts.hour) || 0, + Number(parts.minute) || 0, + Number(parts.second) || 0, + 0 + ); + + return localAsUtc - date.getTime(); } - function parseDateTimeLocalValue(value) { + function parseDateTimeLocalAsUtc(value, timeZone) { var raw = String(value || '').trim(); - if (!raw) { - return null; + var isoDate; + var parts = parseDateTimeLocalParts(value); + if (!parts) { + isoDate = /(?:[zZ]|[+-]\d\d(?::?\d\d)?)$/.test(raw) ? new Date(raw) : null; + return isoDate && !Number.isNaN(isoDate.getTime()) ? isoDate : null; } - var date = new Date(raw); - return Number.isNaN(date.getTime()) ? null : date; + var resolvedTimeZone = resolveTimeZone(timeZone); + var utcMillis = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second, 0); + var date = new Date(utcMillis); + var offset = getTimeZoneOffsetMillis(date, resolvedTimeZone); + var adjusted = new Date(utcMillis - offset); + var adjustedOffset = getTimeZoneOffsetMillis(adjusted, resolvedTimeZone); + + if (adjustedOffset !== offset) { + adjusted = new Date(utcMillis - adjustedOffset); + } + + return Number.isNaN(adjusted.getTime()) ? null : adjusted; + } + + function convertDateTimeLocalValue(value, sourceTimeZone, targetTimeZone) { + var raw = String(value || '').trim(); + if (!raw) { + return ''; + } + + var utcDate = parseDateTimeLocalAsUtc(raw, sourceTimeZone); + if (!utcDate) { + return ''; + } + + var resolvedTargetTimeZone = resolveTimeZone(targetTimeZone); + if (resolvedTargetTimeZone === 'UTC') { + return utcDate.toISOString(); + } + + return formatDateTimeLocalValue(utcDate, resolvedTargetTimeZone); } function clearFieldValidity(input) { @@ -82,17 +192,21 @@ } function validateEntryRow(row) { - if (!row) { - return; - } - - var startInput = row.querySelector('[name="entry_start_datetime[]"]'); - var endInput = row.querySelector('[name="entry_end_datetime[]"]'); + var timezone = resolveTimeZone(timezoneInput.value); + var startInput; + var endInput; var startValue; var endValue; var startDate; var endDate; + if (!row) { + return; + } + + startInput = row.querySelector('[name="entry_start_datetime[]"]'); + endInput = row.querySelector('[name="entry_end_datetime[]"]'); + clearFieldValidity(endInput); if (!startInput || !endInput) { @@ -106,8 +220,8 @@ return; } - startDate = parseDateTimeLocalValue(startValue); - endDate = parseDateTimeLocalValue(endValue); + startDate = parseDateTimeLocalAsUtc(startValue, timezone); + endDate = parseDateTimeLocalAsUtc(endValue, timezone); if (!startDate || !endDate) { return; @@ -119,12 +233,15 @@ } function bindRowValidation(row) { + var startInput; + var endInput; + if (!row) { return; } - var startInput = row.querySelector('[name="entry_start_datetime[]"]'); - var endInput = row.querySelector('[name="entry_end_datetime[]"]'); + startInput = row.querySelector('[name="entry_start_datetime[]"]'); + endInput = row.querySelector('[name="entry_end_datetime[]"]'); if (!startInput || !endInput) { return; @@ -142,32 +259,37 @@ validateEntryRow(row); } - function toUtcDateTimeLocalValue(value) { - var localDate = new Date(String(value || '').trim()); - return Number.isNaN(localDate.getTime()) ? '' : localDate.toISOString(); - } - - function syncRowValuesToLocal(row) { + function syncRowValuesToTimeZone(row, sourceTimeZone, targetTimeZone) { if (!row) { return; } ['entry_start_datetime[]', 'entry_end_datetime[]'].forEach(function (name) { var input = row.querySelector('[name="' + name + '"]'); - if (!input || input.dataset.timetableTimezoneSynced === 'true') { + var convertedValue; + + if (!input) { return; } - var localValue = formatDateTimeLocalValue(parseUtcDateTimeLocalValue(input.value)); - if (localValue) { - input.value = localValue; + convertedValue = convertDateTimeLocalValue(input.value, sourceTimeZone, targetTimeZone); + if (convertedValue) { + input.value = convertedValue; } - input.dataset.timetableTimezoneSynced = 'true'; + }); + } + + function syncAllRowsToTimeZone(sourceTimeZone, targetTimeZone) { + body.querySelectorAll('[data-timetable-entry-row]').forEach(function (row) { + syncRowValuesToTimeZone(row, sourceTimeZone, targetTimeZone); + validateEntryRow(row); }); } function syncFormDataToUtc(formData) { var rows = body.querySelectorAll('[data-timetable-entry-row]'); + var currentTimeZone = resolveTimeZone(timezoneInput.value); + ['entry_id[]', 'entry_title[]', 'entry_short_description[]', 'entry_start_datetime[]', 'entry_end_datetime[]'].forEach(function (name) { formData.delete(name); }); @@ -182,8 +304,8 @@ formData.append('entry_id[]', idInput ? idInput.value : ''); formData.append('entry_title[]', titleInput ? titleInput.value : ''); formData.append('entry_short_description[]', descriptionInput ? descriptionInput.value : ''); - formData.append('entry_start_datetime[]', startInput ? toUtcDateTimeLocalValue(startInput.value) : ''); - formData.append('entry_end_datetime[]', endInput ? toUtcDateTimeLocalValue(endInput.value) : ''); + formData.append('entry_start_datetime[]', startInput ? convertDateTimeLocalValue(startInput.value, currentTimeZone, 'UTC') : ''); + formData.append('entry_end_datetime[]', endInput ? convertDateTimeLocalValue(endInput.value, currentTimeZone, 'UTC') : ''); }); } @@ -210,9 +332,16 @@ markDirty(); } + function handleTimezoneChange() { + var nextTimeZone = resolveTimeZone(timezoneInput.value || DEFAULT_TIME_ZONE); + form.dataset.timetableTimezone = nextTimeZone; + markDirty(); + } + + form.dataset.timetableTimezone = resolveTimeZone(timezoneInput.value || DEFAULT_TIME_ZONE); + body.querySelectorAll('[data-timetable-entry-row]').forEach(function (row) { bindRemove(row); - syncRowValuesToLocal(row); bindRowValidation(row); }); @@ -220,5 +349,8 @@ syncFormDataToUtc(event.formData); }); + timezoneInput.addEventListener('change', handleTimezoneChange); + timezoneInput.addEventListener('input', handleTimezoneChange); + addButton.addEventListener('click', addRow); }()); \ No newline at end of file diff --git a/src/web/public/js/regions/type/api.js b/src/web/public/js/regions/type/api.js index a0b04ac..c5451fa 100644 --- a/src/web/public/js/regions/type/api.js +++ b/src/web/public/js/regions/type/api.js @@ -272,8 +272,18 @@ function buildPreviewRenderContext(region, card, existingContent, sources) { var current = getCurrentConfig(region, existingContent || {}, sources || []); var editor = window.tinymce && typeof window.tinymce.get === 'function' ? window.tinymce.get('slide-editor-region-' + region.id) : null; + var fallbackValue = card && card.querySelector ? ((card.querySelector('textarea[name="region_text_' + region.id + '"]') || {}).value || current.value || '') : current.value; var content = card && card.querySelector ? { - value: String(editor ? editor.getContent({ format: 'html' }) : ((card.querySelector('textarea[name="region_text_' + region.id + '"]') || {}).value || current.value || '')), + value: String((function () { + if (!editor || typeof editor.getContent !== 'function') { + return fallbackValue; + } + try { + return editor.getContent({ format: 'html' }); + } catch (_error) { + return fallbackValue; + } + }())), source_id: (card.querySelector('select[name="region_api_source_id_' + region.id + '"]') || {}).value || current.source_id, item_number: (card.querySelector('input[name="region_api_item_number_' + region.id + '"]') || {}).value || current.item_number, items_path: card.querySelector('input[name="region_api_items_path_' + region.id + '"]') ? (card.querySelector('input[name="region_api_items_path_' + region.id + '"]') || {}).value : current.items_path diff --git a/src/web/public/js/regions/type/rss.js b/src/web/public/js/regions/type/rss.js index af5e281..62dddc2 100644 --- a/src/web/public/js/regions/type/rss.js +++ b/src/web/public/js/regions/type/rss.js @@ -277,8 +277,18 @@ function buildPreviewRenderContext(region, card, existingContent, feeds) { var current = getCurrentConfig(region, existingContent || {}); var editor = window.tinymce && typeof window.tinymce.get === 'function' ? window.tinymce.get('slide-editor-region-' + region.id) : null; + var fallbackValue = card && card.querySelector ? ((card.querySelector('textarea[name="region_text_' + region.id + '"]') || {}).value || current.value || '') : current.value; var content = card && card.querySelector ? { - value: String(editor ? editor.getContent({ format: 'html' }) : ((card.querySelector('textarea[name="region_text_' + region.id + '"]') || {}).value || current.value || '')), + value: String((function () { + if (!editor || typeof editor.getContent !== 'function') { + return fallbackValue; + } + try { + return editor.getContent({ format: 'html' }); + } catch (_error) { + return fallbackValue; + } + }())), feed_id: (card.querySelector('select[name="region_rss_feed_id_' + region.id + '"]') || {}).value || current.feed_id, item_number: (card.querySelector('input[name="region_rss_item_number_' + region.id + '"]') || {}).value || current.item_number } : current; diff --git a/src/web/public/js/regions/type/schedule.js b/src/web/public/js/regions/type/schedule.js index 8e992fd..d460ecf 100644 --- a/src/web/public/js/regions/type/schedule.js +++ b/src/web/public/js/regions/type/schedule.js @@ -43,6 +43,52 @@ return group && Array.isArray(group.entries) ? group.entries : []; } + function getDefaultTimeZone() { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; + } catch (_error) { + return 'UTC'; + } + } + + function resolveTimeZone(value) { + var raw = String(value || '').trim(); + if (!raw) { + return getDefaultTimeZone(); + } + + try { + new Intl.DateTimeFormat('en-GB', { timeZone: raw }).format(new Date()); + return raw; + } catch (_error) { + return getDefaultTimeZone(); + } + } + + function getTimezoneValues(group, dateValue) { + var timeZone = resolveTimeZone(group && (group.timezone || group.time_zone || '')); + var shortName = timeZone; + var targetDate = dateValue instanceof Date ? dateValue : new Date(dateValue || Date.now()); + + try { + var parts = new Intl.DateTimeFormat('en-GB', { + timeZone: timeZone, + timeZoneName: 'short' + }).formatToParts(Number.isNaN(targetDate.getTime()) ? new Date() : targetDate); + var timeZonePart = parts.find(function (part) { + return part && part.type === 'timeZoneName'; + }); + shortName = timeZonePart && timeZonePart.value ? String(timeZonePart.value) : timeZone; + } catch (_error) { + shortName = timeZone; + } + + return { + tz: timeZone, + tz_short: shortName + }; + } + function toDate(value) { if (!value) { return null; @@ -119,9 +165,9 @@ var current = existingContent[region.region_key] || {}; return { timetable_group_id: current.timetable_group_id === undefined || current.timetable_group_id === null || current.timetable_group_id === '' ? '' : Number(current.timetable_group_id), - display_mode: String(current.display_mode || 'upcoming').trim().toLowerCase() || 'upcoming', - value: String(current.value !== undefined ? current.value : current.text !== undefined ? current.text : ''), - max_items: Math.max(1, Number(current.max_items || 5)), + display_mode: String(current.display_mode || current.timetable_display_mode || 'upcoming').trim().toLowerCase() || 'upcoming', + value: String(current.text !== undefined ? current.text : current.value !== undefined ? current.value : ''), + max_items: Math.max(1, Number(current.max_items || current.timetable_max_items || 5)), font_family: current.font_family || region.font_family || getDefaultStyle().font_family, font_size: current.font_size || region.font_size || getDefaultStyle().font_size, font_color: current.font_color || region.font_color || getDefaultStyle().font_color @@ -143,14 +189,47 @@ }).join(''); } + function renderScheduleFormatTokenTable() { + if (window.timeDatePlaceholders && typeof window.timeDatePlaceholders.renderTable === 'function') { + return window.timeDatePlaceholders.renderTable(); + } + + return '' + + '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
FormatOutputDescription
h1-12The hour, 12-hour clock
hh01-12The hour, 12-hour clock, 2-digits
m0-59The minute
mm00-59The minute, 2-digits
s0-59The second
ss00-59The second, 2-digits
AAM/PMUppercase day period
aam/pmLowercase day period
D1-31The day of the month
DD01-31The day of the month, 2-digits
dddMonThe abbreviated weekday name
ddddMondayThe full weekday name
M1-12The month, beginning at 1
MM01-12The month, 2-digits
MMMJan-DecThe abbreviated month name
MMMMJanuary-DecemberThe full month name
YY18The two-digit year
YYYY2018The four-digit year
' + + '
'; + } + function getCurrentSelection(regionId, card, existingContent, timetableGroups) { var current = existingContent[regionId] || {}; var timetableGroupInput = card && card.querySelector ? card.querySelector('select[name="region_timetable_group_id_' + regionId + '"]') : null; var timetableDisplayModeInput = card && card.querySelector ? card.querySelector('select[name="region_timetable_display_mode_' + regionId + '"]') : null; var timetableMaxItemsInput = card && card.querySelector ? card.querySelector('input[name="region_timetable_max_items_' + regionId + '"]') : null; var groupId = timetableGroupInput ? timetableGroupInput.value : (current.timetable_group_id === undefined || current.timetable_group_id === null || current.timetable_group_id === '' ? '' : Number(current.timetable_group_id)); - var displayMode = timetableDisplayModeInput ? timetableDisplayModeInput.value : String(current.display_mode || 'upcoming').trim().toLowerCase() || 'upcoming'; - var maxItems = Math.max(1, Number(timetableMaxItemsInput ? timetableMaxItemsInput.value : current.max_items || 5)); + var displayMode = timetableDisplayModeInput ? timetableDisplayModeInput.value : String(current.display_mode || current.timetable_display_mode || 'upcoming').trim().toLowerCase() || 'upcoming'; + var maxItems = Math.max(1, Number(timetableMaxItemsInput ? timetableMaxItemsInput.value : current.max_items || current.timetable_max_items || 5)); var group = getGroupById(groupId, timetableGroups); var entries = getVisibleEntries(groupId, displayMode, maxItems, timetableGroups); if (!entries.length) { @@ -169,10 +248,10 @@ var region = context.region; var current = context.current || {}; var timetableGroups = Array.isArray(context.timetableGroups) ? context.timetableGroups : []; - var currentValue = String(current.value !== undefined ? current.value : current.text !== undefined ? current.text : ''); + var currentValue = String(current.text !== undefined ? current.text : current.value !== undefined ? current.value : ''); var currentGroupId = current.timetable_group_id === undefined || current.timetable_group_id === null || current.timetable_group_id === '' ? '' : Number(current.timetable_group_id); - var currentDisplayMode = String(current.display_mode || 'upcoming').trim().toLowerCase() || 'upcoming'; - var currentMaxItems = Math.max(1, Number(current.max_items || 5)); + var currentDisplayMode = String(current.display_mode || current.timetable_display_mode || 'upcoming').trim().toLowerCase() || 'upcoming'; + var currentMaxItems = Math.max(1, Number(current.max_items || current.timetable_max_items || 5)); var currentGroup = getGroupById(currentGroupId, timetableGroups); var currentEntries = getVisibleEntries(currentGroupId, currentDisplayMode, currentMaxItems, timetableGroups); if (!currentEntries.length) { @@ -219,7 +298,12 @@ '
' + '
Available placeholders
' + '
' + renderSchedulePlaceholderChips(currentGroup, currentEntries) + '
' + - '
Placeholder values support transforms, for example {{title.upper()}}, {{title.title()}}, {{title.lower()}} or {{start.format("MMM D, YYYY h:mm A")}}.
' + + '
Placeholder values support transforms, for example {{title.upper()}}, {{title.title()}}, {{title.lower()}}, {{start.format("MMM D, YYYY h:mm A")}}, {{start.tz()}} or {{start.tz_short()}}.
' + + '
' + + 'Supported date format tokens' + + '
' + renderScheduleFormatTokenTable() + '
' + + '
Use these tokens inside .format(...); for example {{start.format("MMM D, YYYY h:mm A")}}.
' + + '
' + '
' + '
' + '' + @@ -287,13 +371,29 @@ var textAreaInput = card && card.querySelector ? card.querySelector('textarea.editor-source') : null; var hiddenInput = card && card.querySelector ? card.querySelector('input[type="hidden"][name="region_text_' + region.id + '"]') : null; var editor = window.tinymce && typeof window.tinymce.get === 'function' ? window.tinymce.get('slide-editor-region-' + region.id) : null; - var value = String(editor ? editor.getContent({ format: 'html' }) : (hiddenInput && hiddenInput.value !== undefined ? hiddenInput.value : (textAreaInput && textAreaInput.value !== undefined ? textAreaInput.value : current.value))); + var fallbackValue = hiddenInput && hiddenInput.value !== undefined ? hiddenInput.value : (textAreaInput && textAreaInput.value !== undefined ? textAreaInput.value : current.value); + var currentGroup = getGroupById(timetableGroupInput ? timetableGroupInput.value : current.timetable_group_id, timetableGroups); + var timezoneValues = getTimezoneValues(currentGroup); + var value = String((function () { + if (!editor || typeof editor.getContent !== 'function') { + return fallbackValue; + } + + try { + return editor.getContent({ format: 'html' }); + } catch (_error) { + return fallbackValue; + } + }())); return { value: value, style: getTextStyle(region, current), timetable_group_id: timetableGroupInput ? timetableGroupInput.value || current.timetable_group_id : current.timetable_group_id, display_mode: timetableDisplayModeInput ? timetableDisplayModeInput.value || current.display_mode : current.display_mode, + tz: timezoneValues.tz, + tz_short: timezoneValues.tz_short, + timeZone: timezoneValues.tz, max_items: timetableMaxItemsInput ? timetableMaxItemsInput.value || current.max_items : current.max_items, existingContent: existingContent || {}, timetableGroups: Array.isArray(timetableGroups) ? timetableGroups : [] @@ -303,7 +403,7 @@ function renderPreview(region, regionContent, context) { var groups = context && context.timetableGroups ? context.timetableGroups : []; var style = regionContent && regionContent.style ? regionContent.style : getTextStyle(region, regionContent || {}); - var value = String(regionContent && (regionContent.value !== undefined ? regionContent.value : regionContent.text !== undefined ? regionContent.text : '') || '').trim(); + var value = String(regionContent && (regionContent.text !== undefined ? regionContent.text : regionContent.value !== undefined ? regionContent.value : '') || '').trim(); var groupId = regionContent && regionContent.timetable_group_id !== undefined ? regionContent.timetable_group_id : ''; var displayMode = regionContent && regionContent.display_mode ? regionContent.display_mode : 'upcoming'; var maxItems = regionContent && regionContent.max_items !== undefined ? regionContent.max_items : 5; @@ -315,9 +415,14 @@ } return '
' + entries.map(function (entry, index) { + var entryDate = entry && (entry.start_datetime || entry.end_datetime || entry.date || entry.time || ''); + var timezoneValues = getTimezoneValues(group, entryDate); return '
' + sanitizeRichText(renderTemplate(value, Object.assign({}, entry || {}, { start: entry && entry.start_datetime !== undefined ? entry.start_datetime : '', end: entry && entry.end_datetime !== undefined ? entry.end_datetime : '', + tz: timezoneValues.tz, + tz_short: timezoneValues.tz_short, + timeZone: timezoneValues.tz, group: group || {}, entries: entries, index: index + 1 diff --git a/src/web/public/js/regions/type/text.js b/src/web/public/js/regions/type/text.js index 320da26..245e5b2 100644 --- a/src/web/public/js/regions/type/text.js +++ b/src/web/public/js/regions/type/text.js @@ -84,7 +84,17 @@ } : {}; return { - value: String(editor ? editor.getContent({ format: 'html' }) : (hidden && hidden.value !== undefined ? hidden.value : (textarea && textarea.value !== undefined ? textarea.value : (current && current.value !== undefined ? current.value : '')))), + value: String((function () { + var fallback = hidden && hidden.value !== undefined ? hidden.value : (textarea && textarea.value !== undefined ? textarea.value : (current && current.value !== undefined ? current.value : '')); + if (!editor || typeof editor.getContent !== 'function') { + return fallback; + } + try { + return editor.getContent({ format: 'html' }); + } catch (_error) { + return fallback; + } + }())), style: style, existingContent: existingContent || {} }; diff --git a/src/web/public/js/regions/type/time-date.js b/src/web/public/js/regions/type/time-date.js index dd936b0..e48cc5b 100644 --- a/src/web/public/js/regions/type/time-date.js +++ b/src/web/public/js/regions/type/time-date.js @@ -166,9 +166,9 @@ }; } - function resolveTimeDatePlaceholder(values, expression) { + function resolveTimeDatePlaceholder(values, expression, options) { if (typeof placeholderUtils.resolvePlaceholderExpression === 'function' && typeof placeholderUtils.formatPlaceholderValue === 'function') { - return placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression(values, expression)); + return placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression(values, expression, options || {})); } var parsed = String(expression || '').trim(); @@ -220,7 +220,7 @@ var template = String(format || '').trim(); var values = getFormattedParts(timeZone, date); return template.replace(/\{\{\s*([a-zA-Z0-9_.()\-]+)\s*\}\}/g, function (_match, key) { - return String(resolveTimeDatePlaceholder(values, key) || ''); + return String(resolveTimeDatePlaceholder(values, key, { timeZone: timeZone }) || ''); }); } diff --git a/src/web/public/js/shared/placeholder-utils.js b/src/web/public/js/shared/placeholder-utils.js index 7e15d94..e3f7581 100644 --- a/src/web/public/js/shared/placeholder-utils.js +++ b/src/web/public/js/shared/placeholder-utils.js @@ -75,36 +75,148 @@ return text; } - function formatDateValue(value, pattern) { - var date = value instanceof Date ? value : new Date(value); + function getDefaultTimeZone() { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; + } catch (_error) { + return 'UTC'; + } + } + + function getDateValue(value) { + return value instanceof Date ? value : new Date(value); + } + + function resolveTimeZone(value) { + var raw = String(value || '').trim(); + if (!raw) { + return getDefaultTimeZone(); + } + + try { + new Intl.DateTimeFormat('en-GB', { timeZone: raw }).format(new Date()); + return raw; + } catch (_error) { + return getDefaultTimeZone(); + } + } + + function getDatePartsFromLocalTime(date) { + var dayPeriod = date.getHours() >= 12 ? 'PM' : 'AM'; + return { + year: String(date.getFullYear()), + month: padNumber(date.getMonth() + 1, 2), + day: padNumber(date.getDate(), 2), + hour24: padNumber(date.getHours(), 2), + hour12: padNumber(date.getHours() % 12 || 12, 2), + minute: padNumber(date.getMinutes(), 2), + second: padNumber(date.getSeconds(), 2), + weekdayLong: dayNamesLong[date.getDay()], + weekdayShort: dayNamesShort[date.getDay()], + monthLong: monthNamesLong[date.getMonth()], + monthShort: monthNamesShort[date.getMonth()], + dayPeriod: dayPeriod + }; + } + + function getDatePartsFromTimeZone(date, timeZone) { + var resolvedTimeZone = resolveTimeZone(timeZone); + var baseFormatter = new Intl.DateTimeFormat('en-GB', { + timeZone: resolvedTimeZone, + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + day: '2-digit', + month: '2-digit', + year: 'numeric' + }); + var weekdayLongFormatter = new Intl.DateTimeFormat('en-GB', { + timeZone: resolvedTimeZone, + weekday: 'long' + }); + var weekdayShortFormatter = new Intl.DateTimeFormat('en-GB', { + timeZone: resolvedTimeZone, + weekday: 'short' + }); + var monthLongFormatter = new Intl.DateTimeFormat('en-GB', { + timeZone: resolvedTimeZone, + month: 'long' + }); + var monthShortFormatter = new Intl.DateTimeFormat('en-GB', { + timeZone: resolvedTimeZone, + month: 'short' + }); + var ampmFormatter = new Intl.DateTimeFormat('en-GB', { + timeZone: resolvedTimeZone, + hour12: true, + hour: '2-digit', + minute: '2-digit' + }); + var numericParts = baseFormatter.formatToParts(date); + var weekdayLong = weekdayLongFormatter.formatToParts(date); + var weekdayShort = weekdayShortFormatter.formatToParts(date); + var monthLong = monthLongFormatter.formatToParts(date); + var monthShort = monthShortFormatter.formatToParts(date); + var ampm = ampmFormatter.formatToParts(date); + + function getPart(parts, type) { + var match = parts.find(function (part) { + return part && part.type === type; + }); + return match ? String(match.value || '') : ''; + } + + var dayPeriod = getPart(ampm, 'dayPeriod'); + + return { + year: getPart(numericParts, 'year'), + month: getPart(numericParts, 'month'), + day: getPart(numericParts, 'day'), + hour24: getPart(numericParts, 'hour'), + hour12: getPart(ampm, 'hour'), + minute: getPart(numericParts, 'minute'), + second: getPart(numericParts, 'second'), + weekdayLong: getPart(weekdayLong, 'weekday'), + weekdayShort: getPart(weekdayShort, 'weekday'), + monthLong: getPart(monthLong, 'month'), + monthShort: getPart(monthShort, 'month'), + dayPeriod: String(dayPeriod || '').toUpperCase(), + timeZone: resolvedTimeZone + }; + } + + function formatDateValue(value, pattern, timeZone) { + var date = getDateValue(value); if (Number.isNaN(date.getTime())) { return ''; } var format = String(pattern || 'YYYY-MM-DD HH:mm').trim() || 'YYYY-MM-DD HH:mm'; - var hours24 = date.getHours(); - var hours12 = hours24 % 12 || 12; + var parts = timeZone ? getDatePartsFromTimeZone(date, timeZone) : getDatePartsFromLocalTime(date); + var hours24 = parts.hour24; + var hours12 = parts.hour12; var tokenMap = { - YYYY: String(date.getFullYear()), - YY: String(date.getFullYear()).slice(-2), - MMMM: monthNamesLong[date.getMonth()], - MMM: monthNamesShort[date.getMonth()], - MM: padNumber(date.getMonth() + 1, 2), - M: String(date.getMonth() + 1), - DD: padNumber(date.getDate(), 2), - D: String(date.getDate()), - dddd: dayNamesLong[date.getDay()], - ddd: dayNamesShort[date.getDay()], - HH: padNumber(hours24, 2), - H: String(hours24), - hh: padNumber(hours12, 2), - h: String(hours12), - mm: padNumber(date.getMinutes(), 2), - m: String(date.getMinutes()), - ss: padNumber(date.getSeconds(), 2), - s: String(date.getSeconds()), - A: hours24 >= 12 ? 'PM' : 'AM', - a: hours24 >= 12 ? 'pm' : 'am' + YYYY: parts.year, + YY: parts.year.slice(-2), + MMMM: parts.monthLong, + MMM: parts.monthShort, + MM: parts.month, + M: String(Number(parts.month) || 0), + DD: parts.day, + D: String(Number(parts.day) || 0), + dddd: parts.weekdayLong, + ddd: parts.weekdayShort, + HH: hours24, + H: String(Number(hours24) || 0), + hh: hours12, + h: String(Number(hours12) || 0), + mm: parts.minute, + m: String(Number(parts.minute) || 0), + ss: parts.second, + s: String(Number(parts.second) || 0), + A: parts.dayPeriod, + a: String(parts.dayPeriod || '').toLowerCase() }; return format.replace(/\[([^\]]+)\]|YYYY|YY|MMMM|MMM|MM|M|DD|D|dddd|ddd|HH|H|hh|h|mm|m|ss|s|A|a/g, function (match, literal) { @@ -112,7 +224,32 @@ }); } - function applyTransform(value, transform) { + function getTimeZoneShortName(value, timeZone) { + var date = getDateValue(value); + if (Number.isNaN(date.getTime())) { + return ''; + } + + var resolvedTimeZone = resolveTimeZone(timeZone); + try { + var parts = new Intl.DateTimeFormat('en-GB', { + timeZone: resolvedTimeZone, + timeZoneName: 'short' + }).formatToParts(date); + var match = parts.find(function (part) { + return part && part.type === 'timeZoneName'; + }); + return match ? String(match.value || '') : resolvedTimeZone; + } catch (_error) { + return resolvedTimeZone; + } + } + + function getTransformTimeZone(args, options) { + return resolveTimeZone((args && args[0]) || (options && options.timeZone) || ''); + } + + function applyTransform(value, transform, options) { var text = String(value === undefined || value === null ? '' : value); var name = String(transform && transform.name || '').trim().toLowerCase(); var args = Array.isArray(transform && transform.args) ? transform.args : []; @@ -132,18 +269,26 @@ } if (name === 'format' || name === 'date' || name === 'datetime' || name === 'time') { - return formatDateValue(value, args[0] || (name === 'time' ? 'h:mm A' : name === 'date' ? 'MMM D, YYYY' : 'MMM D, YYYY h:mm A')); + return formatDateValue(value, args[0] || (name === 'time' ? 'h:mm A' : name === 'date' ? 'MMM D, YYYY' : 'MMM D, YYYY h:mm A'), getTransformTimeZone(args.slice(1), options)); + } + + if (name === 'tz') { + return getTransformTimeZone(args, options); + } + + if (name === 'tz_short') { + return getTimeZoneShortName(value, getTransformTimeZone(args, options)); } return text; } - function resolvePlaceholderExpression(value, expression) { + function resolvePlaceholderExpression(value, expression, options) { var parsed = parsePlaceholderExpression(expression); var resolved = resolvePath(value, parsed.path); parsed.transforms.forEach(function (transform) { - resolved = applyTransform(resolved, transform); + resolved = applyTransform(resolved, transform, options || {}); }); return resolved; diff --git a/src/web/public/js/shared/time-date-placeholders.js b/src/web/public/js/shared/time-date-placeholders.js index e7a758e..e98f268 100644 --- a/src/web/public/js/shared/time-date-placeholders.js +++ b/src/web/public/js/shared/time-date-placeholders.js @@ -2,47 +2,79 @@ (function () { var root = window; - var PLACEHOLDERS = [ - { token: 'h', label: 'h' }, - { token: 'hh', label: 'hh' }, - { token: 'm', label: 'm' }, - { token: 'mm', label: 'mm' }, - { token: 's', label: 's' }, - { token: 'ss', label: 'ss' }, - { token: 'd', label: 'd' }, - { token: 'dd', label: 'dd' }, - { token: 'M', label: 'M' }, - { token: 'MM', label: 'MM' }, - { token: 'y', label: 'y' }, - { token: 'yyyy', label: 'yyyy' }, - { token: 'yy', label: 'yy' }, - { token: 'ddd', label: 'ddd' }, - { token: 'dddd', label: 'dddd' }, - { token: 'MMM', label: 'MMM' }, - { token: 'MMMM', label: 'MMMM' }, - { token: 'a', label: 'a' }, - { token: 'tz', label: 'tz' }, - { token: 'tz_short', label: 'tz_short' } + var FORMAT_PLACEHOLDERS = [ + { token: 'h', output: '1-12', description: 'The hour, 12-hour clock' }, + { token: 'hh', output: '01-12', description: 'The hour, 12-hour clock, 2-digits' }, + { token: 'm', output: '0-59', description: 'The minute' }, + { token: 'mm', output: '00-59', description: 'The minute, 2-digits' }, + { token: 's', output: '0-59', description: 'The second' }, + { token: 'ss', output: '00-59', description: 'The second, 2-digits' }, + { token: 'A', output: 'AM/PM', description: 'Uppercase day period' }, + { token: 'a', output: 'am/pm', description: 'Lowercase day period' }, + { token: 'D', output: '1-31', description: 'The day of the month' }, + { token: 'DD', output: '01-31', description: 'The day of the month, 2-digits' }, + { token: 'ddd', output: 'Mon', description: 'The abbreviated weekday name' }, + { token: 'dddd', output: 'Monday', description: 'The full weekday name' }, + { token: 'M', output: '1-12', description: 'The month, beginning at 1' }, + { token: 'MM', output: '01-12', description: 'The month, 2-digits' }, + { token: 'MMM', output: 'Jan-Dec', description: 'The abbreviated month name' }, + { token: 'MMMM', output: 'January-December', description: 'The full month name' }, + { token: 'YY', output: '18', description: 'The two-digit year' }, + { token: 'YYYY', output: '2018', description: 'The four-digit year' } ]; + var HELPER_PLACEHOLDERS = [ + { token: 'tz', output: 'Europe/London', description: 'The resolved time zone name' }, + { token: 'tz_short', output: 'BST/GMT', description: 'The time zone abbreviation for the selected date' } + ]; + + var PLACEHOLDERS = FORMAT_PLACEHOLDERS.concat(HELPER_PLACEHOLDERS); + function getTokens() { return PLACEHOLDERS.slice(); } - function renderChips() { - if (root.placeholderChips && typeof root.placeholderChips.renderChips === 'function') { - return root.placeholderChips.renderChips(PLACEHOLDERS.map(function (item) { - return item.token; - })); - } + function getFormatTokens() { + return FORMAT_PLACEHOLDERS.slice(); + } - return PLACEHOLDERS.map(function (item) { - return '{{' + item.token + '}}'; - }).join(''); + function renderTable() { + return '' + + '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + FORMAT_PLACEHOLDERS.map(function (item) { + return '' + + '' + + '' + + '' + + '' + + ''; + }).join('') + + '' + + '
FormatOutputDescription
' + item.token + '' + escapeHtml(item.output || '') + '' + escapeHtml(item.description || '') + '
' + + '
'; + } + + function escapeHtml(value) { + return String(value === undefined || value === null ? '' : value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); } root.timeDatePlaceholders = { getTokens: getTokens, - renderChips: renderChips + getFormatTokens: getFormatTokens, + renderTable: renderTable }; }()); \ No newline at end of file diff --git a/src/web/public/js/slides/slide-form-editor.js b/src/web/public/js/slides/slide-form-editor.js index 3c97a34..2a5b436 100644 --- a/src/web/public/js/slides/slide-form-editor.js +++ b/src/web/public/js/slides/slide-form-editor.js @@ -72,6 +72,18 @@ export function createSlideFormEditorController(options) { return window.tinymce || null; } + function getEditorContentSafely(editor, fallbackValue) { + if (!editor || typeof editor.getContent !== 'function') { + return String(fallbackValue || ''); + } + + try { + return String(editor.getContent({ format: 'html' })); + } catch (_error) { + return String(fallbackValue || ''); + } + } + function getThemeName() { var theme = String(document.documentElement && document.documentElement.dataset && document.documentElement.dataset.bsTheme || 'light').trim().toLowerCase(); if (theme === 'auto') { @@ -95,14 +107,19 @@ export function createSlideFormEditorController(options) { } function syncEditorState(regionId, editor, shouldLock, hydrated) { + var hidden = getEditorHiddenInput(regionId); + var sourceElm = editor && editor.targetElm ? editor.targetElm : null; + var fallbackContent = hidden && hidden.value !== undefined ? hidden.value : (sourceElm && sourceElm.value !== undefined ? sourceElm.value : ''); + if (editor && typeof editor.save === 'function') { + if (typeof editor.blur === 'function') { + editor.blur(); + } editor.save(); } - var content = editor.getContent({ format: 'html' }); + var content = sourceElm && sourceElm.value !== undefined ? String(sourceElm.value || '') : getEditorContentSafely(editor, fallbackContent); content = isEmptyRichTextValue(content) ? '' : content; - var hidden = getEditorHiddenInput(regionId); - var sourceElm = editor && editor.targetElm ? editor.targetElm : null; if (hidden) { hidden.value = content; @@ -211,7 +228,7 @@ export function createSlideFormEditorController(options) { } editor.on('init', function () { - var content = editor.getContent({ format: 'html' }); + var content = getEditorContentSafely(editor, hidden && hidden.value !== undefined ? hidden.value : (source && source.value !== undefined ? source.value : '')); content = isEmptyRichTextValue(content) ? '' : content; if (hidden) { hidden.value = content; @@ -405,14 +422,12 @@ export function createSlideFormEditorController(options) { var saves = Array.prototype.map.call(templateFields.querySelectorAll('.editor-holder'), function (holder) { var regionId = holder.getAttribute('data-region-id'); var editor = editorInstances.get(regionId); - var hidden = getEditorHiddenInput(regionId); - if (!editor || !hidden) { + if (!editor) { return Promise.resolve(); } - hidden.value = editor.getContent({ format: 'html' }); - syncEditorFontSizeHidden(regionId); + syncEditorState(regionId, editor, false, true); return Promise.resolve(); }); diff --git a/src/web/public/js/slides/slide-form.js b/src/web/public/js/slides/slide-form.js index abe053a..2f0e4eb 100644 --- a/src/web/public/js/slides/slide-form.js +++ b/src/web/public/js/slides/slide-form.js @@ -840,6 +840,14 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev throw new Error('Wait for the image upload to finish before saving.'); } await slideFormEditorController.syncEditors(); + await new Promise(function (resolve) { + window.requestAnimationFrame(function () { + resolve(); + }); + }); + await new Promise(function (resolve) { + window.setTimeout(resolve, 0); + }); await syncRegionCards(); }, fallbackSuccessMessage: 'Saved slide.', diff --git a/src/web/routes/data-sources/timetables/duplicate.js b/src/web/routes/data-sources/timetables/duplicate.js index dedf879..936a85d 100644 --- a/src/web/routes/data-sources/timetables/duplicate.js +++ b/src/web/routes/data-sources/timetables/duplicate.js @@ -9,7 +9,8 @@ function buildDuplicateTimetableGroup(timetableGroup, duplicateName) { return Object.assign({}, timetableGroup, { id: null, name: duplicateName, - shortDescription: timetableGroup.short_description || '' + shortDescription: timetableGroup.short_description || '', + timezone: timetableGroup.timezone || 'UTC' }); } diff --git a/src/web/routes/data-sources/timetables/form-view-model.js b/src/web/routes/data-sources/timetables/form-view-model.js index 947f4c2..8edb5f6 100644 --- a/src/web/routes/data-sources/timetables/form-view-model.js +++ b/src/web/routes/data-sources/timetables/form-view-model.js @@ -1,14 +1,40 @@ // Shared timetable group form view-model builder. +const { normalizeTimeZone } = require('../../../../data/timetables'); + +const COMMON_TIME_ZONES = [ + 'UTC', + 'Europe/London', + 'Europe/Dublin', + 'Europe/Paris', + 'Europe/Berlin', + 'Europe/Madrid', + 'America/New_York', + 'America/Chicago', + 'America/Denver', + 'America/Los_Angeles', + 'America/Toronto', + 'America/Vancouver', + 'America/Sao_Paulo', + 'Asia/Dubai', + 'Asia/Kolkata', + 'Asia/Singapore', + 'Asia/Tokyo', + 'Asia/Seoul', + 'Australia/Sydney', + 'Pacific/Auckland' +]; + function buildDefaultTimetableGroup() { return { id: null, name: '', - shortDescription: '' + shortDescription: '', + timezone: 'Europe/London' }; } -function formatDateTimeLocalValue(value) { +function formatDateTimeLocalValue(value, timeZone) { if (!value) { return ''; } @@ -18,22 +44,49 @@ function formatDateTimeLocalValue(value) { return ''; } - const year = String(date.getFullYear()).padStart(4, '0'); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - const hours = String(date.getHours()).padStart(2, '0'); - const minutes = String(date.getMinutes()).padStart(2, '0'); - return `${year}-${month}-${day}T${hours}:${minutes}`; + const resolvedTimeZone = normalizeTimeZone(timeZone, 'Europe/London'); + const parts = new Intl.DateTimeFormat('en-GB', { + timeZone: resolvedTimeZone, + hour12: false, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }).formatToParts(date).reduce(function (acc, part) { + if (part && part.type && part.type !== 'literal') { + acc[part.type] = part.value; + } + return acc; + }, Object.create(null)); + + if (!parts.year || !parts.month || !parts.day || !parts.hour || !parts.minute) { + return ''; + } + + return [ + String(parts.year).padStart(4, '0'), + '-', + String(parts.month).padStart(2, '0'), + '-', + String(parts.day).padStart(2, '0'), + 'T', + String(parts.hour).padStart(2, '0'), + ':', + String(parts.minute).padStart(2, '0') + ].join(''); } function buildTimetableGroupFormViewModel(timetableGroup, timetableEntries, message, currentUser, isEdit, options) { const viewTimetableGroup = Object.assign(buildDefaultTimetableGroup(), timetableGroup || {}); + viewTimetableGroup.timezone = normalizeTimeZone(viewTimetableGroup.timezone, 'Europe/London'); const viewOptions = options || {}; const viewTimetableEntries = Array.isArray(timetableEntries) && timetableEntries.length ? timetableEntries.map(function (entry) { return Object.assign({}, entry, { - startValue: formatDateTimeLocalValue(entry.start_datetime), - endValue: formatDateTimeLocalValue(entry.end_datetime) + startValue: formatDateTimeLocalValue(entry.start_datetime, viewTimetableGroup.timezone), + endValue: formatDateTimeLocalValue(entry.end_datetime, viewTimetableGroup.timezone) }); }) : [{ id: null, title: '', short_description: '', startValue: '', endValue: '', sort_order: 0 }]; @@ -46,6 +99,7 @@ function buildTimetableGroupFormViewModel(timetableGroup, timetableEntries, mess messageVariant: viewOptions.messageVariant || '', isEdit: Boolean(isEdit), timetableGroup: viewTimetableGroup, + timeZoneOptions: COMMON_TIME_ZONES, timetableEntries: viewTimetableEntries, inUse: Boolean(viewTimetableGroup.inUse), showSaveSecondaryActions: true, diff --git a/src/web/routes/data-sources/timetables/list.js b/src/web/routes/data-sources/timetables/list.js index 9d60a57..de6af52 100644 --- a/src/web/routes/data-sources/timetables/list.js +++ b/src/web/routes/data-sources/timetables/list.js @@ -1,22 +1,45 @@ // Timetable group list page renderer. const { renderView } = require('../../../view'); +const { normalizeTimeZone } = require('../../../../data/timetables'); -function formatNextStartLabel(value, formatDashboardDate) { +function formatDateInTimeZone(value, timeZone) { + if (!value) { + return ''; + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return ''; + } + + const resolvedTimeZone = normalizeTimeZone(timeZone, 'Europe/London'); + return new Intl.DateTimeFormat('en-US', { + timeZone: resolvedTimeZone, + month: 'short', + day: '2-digit', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + second: '2-digit' + }).format(date); +} + +function formatNextStartLabel(value, timeZone) { if (!value) { return 'No entries'; } - const label = typeof formatDashboardDate === 'function' - ? formatDashboardDate(value) - : String(value); + const label = formatDateInTimeZone(value, timeZone) || String(value); return label || 'No entries'; } -module.exports = function renderTimetableGroupsPage(data, message, currentUser, formatDashboardDate) { +module.exports = function renderTimetableGroupsPage(data, message, currentUser) { const timetableGroups = (data.timetableGroups || []).map(function (timetableGroup) { + const timeZone = normalizeTimeZone(timetableGroup.timezone, 'Europe/London'); return Object.assign({}, timetableGroup, { - nextStartLabel: formatNextStartLabel(timetableGroup.next_start_datetime, formatDashboardDate), + timeZone: timeZone, + nextStartLabel: formatNextStartLabel(timetableGroup.next_start_datetime, timeZone), nextStartValue: timetableGroup.next_start_datetime ? new Date(timetableGroup.next_start_datetime).toISOString() : '' }); }); diff --git a/src/web/routes/data-sources/timetables/routes.js b/src/web/routes/data-sources/timetables/routes.js index 72164ca..8ed812c 100644 --- a/src/web/routes/data-sources/timetables/routes.js +++ b/src/web/routes/data-sources/timetables/routes.js @@ -42,7 +42,7 @@ async function getDataSourceUsageMaps(pool, common) { return timetableGroupIds; } -function readScheduleArrayFieldValue(body, keys) { +function readTimetableArrayFieldValue(body, keys) { const searchKeys = Array.isArray(keys) ? keys : [keys]; for (let index = 0; index < searchKeys.length; index += 1) { const key = searchKeys[index]; @@ -57,17 +57,17 @@ function readScheduleArrayFieldValue(body, keys) { return []; } -function buildScheduleEntryRows(req, parseDateTimeLocal) { +function buildTimetableEntryRows(req, parseDateTimeLocal) { const body = req.body || {}; - const ids = readScheduleArrayFieldValue(body, ['entry_id[]', 'entry_id']); - const titles = readScheduleArrayFieldValue(body, ['entry_title[]', 'entry_title']); - const descriptions = readScheduleArrayFieldValue(body, ['entry_short_description[]', 'entry_short_description']); - const starts = readScheduleArrayFieldValue(body, ['entry_start_datetime[]', 'entry_start_datetime']); - const ends = readScheduleArrayFieldValue(body, ['entry_end_datetime[]', 'entry_end_datetime']); + const ids = readTimetableArrayFieldValue(body, ['entry_id[]', 'entry_id']); + const titles = readTimetableArrayFieldValue(body, ['entry_title[]', 'entry_title']); + const descriptions = readTimetableArrayFieldValue(body, ['entry_short_description[]', 'entry_short_description']); + const starts = readTimetableArrayFieldValue(body, ['entry_start_datetime[]', 'entry_start_datetime']); + const ends = readTimetableArrayFieldValue(body, ['entry_end_datetime[]', 'entry_end_datetime']); const lengths = [ids.length, titles.length, descriptions.length, starts.length, ends.length].filter(Boolean); if (lengths.length && lengths.some(function (value) { return value !== lengths[0]; })) { - const error = new Error('Schedule entry data is invalid.'); + const error = new Error('Timetable entry data is invalid.'); error.statusCode = 400; throw error; } @@ -86,19 +86,19 @@ function buildScheduleEntryRows(req, parseDateTimeLocal) { } if (!title) { - const error = new Error('Each schedule entry requires a title.'); + const error = new Error('Each timetable entry requires a title.'); error.statusCode = 400; throw error; } if (!startDatetime) { - const error = new Error('Each schedule entry requires a start datetime.'); + const error = new Error('Each timetable entry requires a start datetime.'); error.statusCode = 400; throw error; } if (endDatetime && endDatetime < startDatetime) { - const error = new Error('Schedule entry end datetime must be after the start datetime.'); + const error = new Error('Timetable entry end datetime must be after the start datetime.'); error.statusCode = 400; throw error; } @@ -191,7 +191,7 @@ module.exports = function registerTimetableRoutes(app, deps) { let duplicateName = buildDuplicateTimetableGroupName(timetableGroup.name); let duplicateIndex = 2; - while (await common.fetchDuplicateName(pool, 'i_schedule_groups', duplicateName)) { + while (await common.fetchDuplicateName(pool, 'i_timetable_groups', duplicateName)) { duplicateName = buildDuplicateTimetableGroupName(timetableGroup.name) + ' (' + duplicateIndex + ')'; duplicateIndex += 1; } @@ -207,16 +207,16 @@ module.exports = function registerTimetableRoutes(app, deps) { const connection = await pool.getConnection(); try { const payload = common.buildTimetableGroupPayload(req, null); - if (await common.fetchDuplicateName(pool, 'i_schedule_groups', payload.name)) { + if (await common.fetchDuplicateName(pool, 'i_timetable_groups', payload.name)) { return res.redirect('/data-sources/timetables/new?message=' + encodeURIComponent('A timetable group with that name already exists.')); } - const entryRows = buildScheduleEntryRows(req, parseDateTimeLocal); + const entryRows = buildTimetableEntryRows(req, parseDateTimeLocal); const actorId = getAuditUserId(req); await connection.beginTransaction(); const [result] = await connection.query( - 'INSERT INTO i_schedule_groups (name, short_description, created_by, modified_by) VALUES (?, ?, ?, ?)', - [payload.name, payload.shortDescription || null, actorId, actorId] + 'INSERT INTO i_timetable_groups (name, short_description, timezone, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', + [payload.name, payload.shortDescription || null, payload.timezone, actorId, actorId] ); if (entryRows.length) { @@ -233,7 +233,7 @@ module.exports = function registerTimetableRoutes(app, deps) { }); await connection.query( - 'INSERT INTO i_schedule_entries (schedule_group_id, title, short_description, start_datetime, end_datetime, created_by, modified_by) VALUES ?', + 'INSERT INTO i_timetable_entries (schedule_group_id, title, short_description, start_datetime, end_datetime, created_by, modified_by) VALUES ?', [insertRows] ); } @@ -265,18 +265,18 @@ module.exports = function registerTimetableRoutes(app, deps) { } const payload = common.buildTimetableGroupPayload(req, timetableGroup); - if (await common.fetchDuplicateName(pool, 'i_schedule_groups', payload.name, timetableGroup.id)) { + if (await common.fetchDuplicateName(pool, 'i_timetable_groups', payload.name, timetableGroup.id)) { return res.redirect('/data-sources/timetables/' + timetableGroup.id + '/edit?message=' + encodeURIComponent('A timetable group with that name already exists.')); } - const entryRows = buildScheduleEntryRows(req, parseDateTimeLocal); + const entryRows = buildTimetableEntryRows(req, parseDateTimeLocal); const actorId = getAuditUserId(req); await connection.beginTransaction(); await connection.query( - 'UPDATE i_schedule_groups SET name = ?, short_description = ?, modified_by = ? WHERE id = ?', - [payload.name, payload.shortDescription || null, actorId, timetableGroup.id] + 'UPDATE i_timetable_groups SET name = ?, short_description = ?, timezone = ?, modified_by = ? WHERE id = ?', + [payload.name, payload.shortDescription || null, payload.timezone, actorId, timetableGroup.id] ); - await connection.query('DELETE FROM i_schedule_entries WHERE schedule_group_id = ?', [timetableGroup.id]); + await connection.query('DELETE FROM i_timetable_entries WHERE schedule_group_id = ?', [timetableGroup.id]); if (entryRows.length) { const insertRows = entryRows.map(function (entry) { @@ -292,7 +292,7 @@ module.exports = function registerTimetableRoutes(app, deps) { }); await connection.query( - 'INSERT INTO i_schedule_entries (schedule_group_id, title, short_description, start_datetime, end_datetime, created_by, modified_by) VALUES ?', + 'INSERT INTO i_timetable_entries (schedule_group_id, title, short_description, start_datetime, end_datetime, created_by, modified_by) VALUES ?', [insertRows] ); } @@ -330,7 +330,7 @@ module.exports = function registerTimetableRoutes(app, deps) { const connection = await pool.getConnection(); try { await connection.beginTransaction(); - await connection.query('DELETE FROM i_schedule_groups WHERE id = ?', [timetableGroup.id]); + await connection.query('DELETE FROM i_timetable_groups WHERE id = ?', [timetableGroup.id]); await connection.commit(); } catch (error) { await connection.rollback(); diff --git a/src/web/views/data-sources/timetables/form.hbs b/src/web/views/data-sources/timetables/form.hbs index 63081e9..c92422d 100644 --- a/src/web/views/data-sources/timetables/form.hbs +++ b/src/web/views/data-sources/timetables/form.hbs @@ -10,15 +10,29 @@

Timetable group details

-
+
- - +
+
+ + +
+
+ + + + {{#each timeZoneOptions}} + + {{/each}} + +
Entries are stored and displayed using this IANA time zone.
+
+
@@ -32,9 +46,9 @@

Entries

-
+
- +
@@ -59,10 +73,9 @@ {{/each}} @@ -87,10 +100,9 @@ diff --git a/src/web/views/data-sources/timetables/list.hbs b/src/web/views/data-sources/timetables/list.hbs index 88d39f8..5666d17 100644 --- a/src/web/views/data-sources/timetables/list.hbs +++ b/src/web/views/data-sources/timetables/list.hbs @@ -24,6 +24,7 @@ + @@ -35,10 +36,11 @@ + {{/each}} {{else}} - + {{/if}}
Title -
End time must be at least 1 minute after the start time.
- +
-
End time must be at least 1 minute after the start time.
- +
Name DescriptionTime zone Entries Next start Actions
{{name}} {{short_description}}{{timeZone}} {{entry_count}} {{#if nextStartValue}} - + {{else}} {{nextStartLabel}} {{/if}} @@ -69,7 +71,7 @@
No timetable groups yet.
No timetable groups yet.
diff --git a/test/clients-routes.test.js b/test/clients-routes.test.js index fe4fea8..a05d76c 100644 --- a/test/clients-routes.test.js +++ b/test/clients-routes.test.js @@ -17,7 +17,7 @@ function createHandlers() { return { app, handlers }; } -test('clients list defaults to client then ip ordering', async () => { +test('clients list defaults to client ordering', async () => { const { app, handlers } = createHandlers(); registerClientsRoutes(app, { @@ -38,7 +38,7 @@ test('clients list defaults to client then ip ordering', async () => { return JSON.stringify(data.clients.map(function (client) { return { client_name: client.client_name, - clientIp: client.clientIp + clientId: client.clientId }; })); } @@ -46,9 +46,9 @@ test('clients list defaults to client then ip ordering', async () => { buildDashboardState: async () => ({ screens: [], clients: [ - { client_name: 'Beta', clientIp: '10.0.0.9' }, - { client_name: 'Alpha', clientIp: '10.0.0.20' }, - { client_name: 'Alpha', clientIp: '10.0.0.2' } + { client_name: 'Beta', clientId: 'beta-1' }, + { client_name: 'Alpha', clientId: 'alpha-1' }, + { client_name: 'Alpha', clientId: 'alpha-2' } ] }), requirePermission() { @@ -76,9 +76,9 @@ test('clients list defaults to client then ip ordering', async () => { }, response, () => {}); assert.deepEqual(JSON.parse(response.body), [ - { client_name: 'Alpha', clientIp: '10.0.0.2' }, - { client_name: 'Alpha', clientIp: '10.0.0.20' }, - { client_name: 'Beta', clientIp: '10.0.0.9' } + { client_name: 'Alpha', clientId: 'alpha-1' }, + { client_name: 'Alpha', clientId: 'alpha-2' }, + { client_name: 'Beta', clientId: 'beta-1' } ]); }); diff --git a/test/timetable-group-form.test.js b/test/timetable-group-form.test.js index ec59947..3e6d994 100644 --- a/test/timetable-group-form.test.js +++ b/test/timetable-group-form.test.js @@ -97,26 +97,9 @@ function createRow(startValue, endValue) { return row; } -function formatDateTimeLocalValue(date) { - function pad(value) { - return String(value).padStart(2, '0'); - } - - return [ - String(date.getFullYear()).padStart(4, '0'), - '-', - pad(date.getMonth() + 1), - '-', - pad(date.getDate()), - 'T', - pad(date.getHours()), - ':', - pad(date.getMinutes()) - ].join(''); -} - test('timetable group form highlights an end time that is too early', () => { - const row = createRow('2026-08-07T10:00', '2026-08-07T10:00'); + const row = createRow('2026-08-07T12:00', '2026-08-07T12:00'); + const timezoneInput = createInput('timezone', 'Europe/Berlin'); const body = { rows: [row], querySelectorAll(selector) { @@ -140,7 +123,10 @@ test('timetable group form highlights an end time that is too early', () => { }; const form = { dataset: {}, - addEventListener() {} + listeners: Object.create(null), + addEventListener(type, handler) { + this.listeners[type] = handler; + } }; const templateRow = createRow('', ''); const template = { @@ -161,6 +147,9 @@ test('timetable group form highlights an end time that is too early', () => { if (id === 'timetable-group-form') { return form; } + if (id === 'timetable-group-timezone') { + return timezoneInput; + } if (id === 'timetable-entry-row-template') { return template; } @@ -194,14 +183,47 @@ test('timetable group form highlights an end time that is too early', () => { const script = fs.readFileSync(scriptPath, 'utf8'); vm.runInNewContext(script, sandbox, { filename: scriptPath }); + assert.equal(row.startInput.value, '2026-08-07T12:00'); + assert.equal(row.endInput.value, '2026-08-07T12:00'); assert.equal(row.endInput.validityMessage, 'End time must be at least 1 minute after the start time.'); assert.equal(row.endInput.classList.contains('is-invalid'), true); assert.equal(row.endInput.attributes['aria-invalid'], 'true'); - row.endInput.value = formatDateTimeLocalValue(new Date(new Date(row.startInput.value).getTime() + 60000)); + row.endInput.value = '2026-08-07T12:01'; row.endInput.dispatchEvent({ type: 'input' }); assert.equal(row.endInput.validityMessage, ''); assert.equal(row.endInput.classList.contains('is-invalid'), false); assert.equal(Object.prototype.hasOwnProperty.call(row.endInput.attributes, 'aria-invalid'), false); + + timezoneInput.value = 'Europe/London'; + timezoneInput.dispatchEvent({ type: 'change' }); + + assert.equal(row.startInput.value, '2026-08-07T12:00'); + assert.equal(row.endInput.value, '2026-08-07T12:01'); + assert.equal(form.dataset.timetableTimezone, 'Europe/London'); + assert.equal(form.dataset.dirty, 'true'); + + const formData = { + deleted: [], + appended: [], + delete(name) { + this.deleted.push(name); + }, + append(name, value) { + this.appended.push([name, value]); + } + }; + + form.listeners.formdata({ formData: formData }); + + const startEntry = formData.appended.find(function (item) { + return item[0] === 'entry_start_datetime[]'; + }); + const endEntry = formData.appended.find(function (item) { + return item[0] === 'entry_end_datetime[]'; + }); + + assert.equal(startEntry[1], '2026-08-07T11:00:00.000Z'); + assert.equal(endEntry[1], '2026-08-07T11:01:00.000Z'); }); \ No newline at end of file diff --git a/test/timetable-region-timezone.test.js b/test/timetable-region-timezone.test.js new file mode 100644 index 0000000..d754d09 --- /dev/null +++ b/test/timetable-region-timezone.test.js @@ -0,0 +1,295 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); + +function loadScheduleModule(overrides) { + const timeDatePlaceholdersScript = fs.readFileSync(require.resolve('../src/web/public/js/shared/time-date-placeholders.js'), 'utf8'); + const placeholderScript = fs.readFileSync(require.resolve('../src/web/public/js/shared/placeholder-utils.js'), 'utf8'); + const scriptPath = require.resolve('../src/web/public/js/regions/type/schedule.js'); + const script = fs.readFileSync(scriptPath, 'utf8'); + const registry = new Map(); + const customWindow = overrides && overrides.window ? overrides.window : {}; + const sandbox = { + document: { + addEventListener() {} + }, + window: { + pulseRegionTypes: { + register(type, module) { + registry.set(type, module); + } + }, + pulseRegionUtils: { + escapeHtml(value) { + return String(value === undefined || value === null ? '' : value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + }, + sanitizeRichText(value) { + return String(value === undefined || value === null ? '' : value); + } + }, + placeholderChips: { + renderChips(tokens) { + return tokens.map((token) => '{{' + token + '}}').join(' '); + } + }, + initialData: { + timetableGroups: [ + { + id: 1, + name: 'Main board', + timezone: 'UTC', + entries: [ + { + id: 101, + title: 'Launch', + short_description: 'Doors open', + start_datetime: '2026-08-09T10:00:00.000Z', + end_datetime: '2026-08-09T11:00:00.000Z' + } + ] + } + ] + }, + Intl: Intl, + Date: Date, + Object: Object, + Array: Array, + Number: Number, + String: String, + Boolean: Boolean, + Math: Math, + JSON: JSON, + RegExp: RegExp, + console: console + }, + ...customWindow + }; + sandbox.window = Object.assign({}, sandbox.window, customWindow); + + vm.runInNewContext(timeDatePlaceholdersScript, sandbox, { filename: 'time-date-placeholders.js' }); + vm.runInNewContext(placeholderScript, sandbox, { filename: 'placeholder-utils.js' }); + if (customWindow.placeholderUtils) { + sandbox.window.placeholderUtils = customWindow.placeholderUtils; + } + vm.runInNewContext(script, sandbox, { filename: scriptPath }); + return registry.get('timetable'); +} + +test('timetable region editor lists timezone transforms in helper text', () => { + const module = loadScheduleModule(); + const html = module.renderEditorCard({ + region: { id: 47, label: 'Timetable' }, + current: { + value: '

{{title}} {{start.tz()}} {{start.tz_short()}}

', + timetable_group_id: 1, + display_mode: 'current', + max_items: 3 + }, + timetableGroups: [ + { + id: 1, + name: 'Main board', + timezone: 'UTC', + entries: [] + } + ] + }); + + assert.match(html, /Placeholder values support transforms/); + assert.match(html, /\{\{start\.tz\(\)\}\}<\/code>/); + assert.match(html, /\{\{start\.tz_short\(\)\}\}<\/code>/); + assert.doesNotMatch(html, /class="chip">\{\{start\.tz\(\)\}\}<\/span>/); +}); + +test('timetable region editor shows supported date format tokens', () => { + const module = loadScheduleModule(); + const html = module.renderEditorCard({ + region: { id: 47, label: 'Timetable' }, + current: { + value: '

{{start.format("MMM D, YYYY h:mm A")}}

', + timetable_group_id: 1, + display_mode: 'current', + max_items: 3 + }, + timetableGroups: [ + { + id: 1, + name: 'Main board', + timezone: 'UTC', + entries: [] + } + ] + }); + + assert.match(html, /Supported date format tokens/); + assert.match(html, /Format<\/th>/); + assert.ok(html.indexOf('D1-31The day of the month') < html.indexOf('dddMonThe abbreviated weekday name')); + assert.ok(html.indexOf('dddMonThe abbreviated weekday name') < html.indexOf('M1-12The month, beginning at 1')); + assert.ok(html.indexOf('YY18The two-digit year') < html.indexOf('YYYY2018The four-digit year')); + assert.doesNotMatch(html, /y<\/code><\/td>/); + assert.doesNotMatch(html, /yyyy<\/code><\/td>/); + assert.doesNotMatch(html, /yy<\/code><\/td>/); + assert.doesNotMatch(html, /tz<\/code><\/td>/); + assert.doesNotMatch(html, /tz_short<\/code><\/td>/); + assert.match(html, /Use these tokens inside \.format\(\.\.\.\)<\/code>/); +}); + +test('timetable region preview resolves timezone placeholders from explicit timezone transforms', () => { + const module = loadScheduleModule(); + const preview = module.renderPreview( + { id: 47, width: 500, height: 280 }, + { + value: '

{{start.tz("UTC")}} {{start.tz_short("UTC")}}

', + timetable_group_id: 1, + display_mode: 'both', + max_items: 3 + }, + { + timetableGroups: [ + { + id: 1, + name: 'Main board', + timezone: 'UTC', + entries: [ + { + id: 101, + title: 'Launch', + short_description: 'Doors open', + start_datetime: '2026-08-10T10:00:00.000Z', + end_datetime: '2026-08-10T11:00:00.000Z' + } + ] + } + ] + } + ); + + assert.match(preview, /UTC/); +}); + +test('timetable placeholder format transform respects uppercase and lowercase day period tokens', () => { + const placeholderScript = fs.readFileSync(require.resolve('../src/web/public/js/shared/placeholder-utils.js'), 'utf8'); + const sandbox = { + window: {}, + Intl: Intl, + Date: Date, + Object: Object, + Array: Array, + Number: Number, + String: String, + Boolean: Boolean, + Math: Math, + JSON: JSON, + RegExp: RegExp, + console: console + }; + + vm.runInNewContext(placeholderScript, sandbox, { filename: 'placeholder-utils.js' }); + + const upper = sandbox.window.placeholderUtils.resolvePlaceholderExpression( + { start: '2026-08-09T13:05:00.000Z' }, + 'start.format("MMM D, YYYY h:mm A")' + ); + const lower = sandbox.window.placeholderUtils.resolvePlaceholderExpression( + { start: '2026-08-09T13:05:00.000Z' }, + 'start.format("MMM D, YYYY h:mm a")' + ); + + assert.match(String(upper), /PM$/); + assert.match(String(lower), /pm$/); +}); + +test('timetable timezone abbreviation changes across daylight saving boundaries', () => { + const module = loadScheduleModule(); + const preview = module.renderPreview( + { id: 47, width: 500, height: 280 }, + { + value: '

{{start.tz_short("Europe/London")}}

', + timetable_group_id: 1, + display_mode: 'both', + max_items: 10 + }, + { + timetableGroups: [ + { + id: 1, + name: 'London board', + timezone: 'Europe/London', + entries: [ + { + id: 201, + title: 'Before DST ends', + short_description: '', + start_datetime: '2026-10-24T12:00:00.000Z', + end_datetime: '2026-10-24T13:00:00.000Z' + }, + { + id: 202, + title: 'After DST ends', + short_description: '', + start_datetime: '2026-10-26T12:00:00.000Z', + end_datetime: '2026-10-26T13:00:00.000Z' + } + ] + } + ] + } + ); + + assert.match(preview, /BST/); + assert.match(preview, /(GMT|UTC)/); +}); + +test('timetable preview does not force the group timezone into placeholder transforms', () => { + const calls = []; + const module = loadScheduleModule({ + window: { + placeholderUtils: { + resolvePlaceholderExpression(value, expression, options) { + calls.push({ expression, options: options || {} }); + return options && options.timeZone ? options.timeZone : 'local'; + }, + formatPlaceholderValue(value) { + return String(value === undefined || value === null ? '' : value); + } + } + } + }); + + const preview = module.renderPreview( + { id: 47, width: 500, height: 280 }, + { + value: '

{{start.tz_short()}}

', + timetable_group_id: 1, + display_mode: 'both', + max_items: 3 + }, + { + timetableGroups: [ + { + id: 1, + name: 'Madrid board', + timezone: 'Europe/Madrid', + entries: [ + { + id: 101, + title: 'Launch', + short_description: 'Doors open', + start_datetime: '2026-08-10T10:00:00.000Z', + end_datetime: '2026-08-10T11:00:00.000Z' + } + ] + } + ] + } + ); + + assert.match(preview, /local/); + assert.equal(calls.length > 0 ? Boolean(calls[0].options && calls[0].options.timeZone) : false, false); +}); diff --git a/test/timetable-region.test.js b/test/timetable-region.test.js new file mode 100644 index 0000000..af63f23 --- /dev/null +++ b/test/timetable-region.test.js @@ -0,0 +1,42 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { buildSlidePayload } = require('../src/data/slides'); + +test('buildSlidePayload normalizes timetable region fields', async () => { + const pool = { + async query(sql) { + if (sql.includes('FROM c_templates st')) { + return [[{ id: 9, name: 'Template 9', canvas_size_id: 1, canvas_size_width: 1920, canvas_size_height: 1080 }]]; + } + + if (sql.includes('FROM c_template_regions')) { + return [[{ id: 47, template_id: 9, region_key: 'timetable', region_type: 'timetable', label: 'Timetable' }]]; + } + + return [[]]; + } + }; + + const payload = await buildSlidePayload(pool, { + body: { + title: 'Timetable slide', + template_id: '9', + region_text_47: '

{{title}}

', + region_timetable_group_id_47: '1', + region_timetable_display_mode_47: 'current', + region_timetable_max_items_47: '7' + }, + files: [] + }, null); + + const content = JSON.parse(payload.contentJson); + assert.equal(content.timetable.type, 'timetable'); + assert.equal(content.timetable.text, '

{{title}}

'); + assert.equal(content.timetable.value, '

{{title}}

'); + assert.equal(content.timetable.timetable_group_id, '1'); + assert.equal(content.timetable.display_mode, 'current'); + assert.equal(content.timetable.max_items, '7'); + assert.equal(Object.prototype.hasOwnProperty.call(content.timetable, 'timetable_display_mode'), false); + assert.equal(Object.prototype.hasOwnProperty.call(content.timetable, 'timetable_max_items'), false); +}); \ No newline at end of file diff --git a/test/timetables-duplicate.test.js b/test/timetables-duplicate.test.js index c6e53d0..3d9ffe5 100644 --- a/test/timetables-duplicate.test.js +++ b/test/timetables-duplicate.test.js @@ -14,7 +14,8 @@ test('timetable duplicate helper copies group and entry fields', () => { const group = { id: 5, name: 'Morning Shift', - short_description: 'Weekday events' + short_description: 'Weekday events', + timezone: 'Europe/Berlin' }; const entry = { id: 12, @@ -32,6 +33,7 @@ test('timetable duplicate helper copies group and entry fields', () => { assert.equal(duplicateGroup.name, 'Copy of Morning Shift'); assert.equal(duplicateGroup.short_description, 'Weekday events'); assert.equal(duplicateGroup.shortDescription, 'Weekday events'); + assert.equal(duplicateGroup.timezone, 'Europe/Berlin'); assert.equal(duplicateEntry.id, null); assert.equal(duplicateEntry.schedule_group_id, null); assert.equal(duplicateEntry.title, 'Opening'); @@ -42,7 +44,7 @@ test('timetable duplicate helper copies group and entry fields', () => { test('timetable duplicate form model keeps info toast variant and secondary actions', () => { const model = buildTimetableGroupFormViewModel( - { id: null, name: 'Morning Shift', shortDescription: 'Weekday events' }, + { id: null, name: 'Morning Shift', shortDescription: 'Weekday events', timezone: 'Europe/Berlin' }, [], 'Review the copied values and save when ready.', { permissionKeys: ['timetables.create'] }, @@ -52,6 +54,7 @@ test('timetable duplicate form model keeps info toast variant and secondary acti assert.equal(model.messageVariant, 'info'); assert.equal(model.showSaveSecondaryActions, true); + assert.equal(model.timetableGroup.timezone, 'Europe/Berlin'); }); test('timetable list template includes duplicate action', () => { diff --git a/test/timetables-routes.test.js b/test/timetables-routes.test.js new file mode 100644 index 0000000..fada65c --- /dev/null +++ b/test/timetables-routes.test.js @@ -0,0 +1,155 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +require('../src/common'); + +const registerTimetableRoutes = require('../src/web/routes/data-sources/timetables/routes'); + +function createApp() { + const handlers = {}; + return { + handlers: handlers, + app: { + get(path, ...routeHandlers) { + handlers['GET ' + path] = routeHandlers; + }, + post(path, ...routeHandlers) { + handlers['POST ' + path] = routeHandlers; + } + } + }; +} + +function createConnection() { + const queries = []; + return { + queries: queries, + async beginTransaction() { + queries.push(['beginTransaction']); + }, + async query(sql, params) { + queries.push([sql, params]); + if (String(sql).indexOf('INSERT INTO i_timetable_groups') !== -1) { + return [{ insertId: 42 }]; + } + return [{}]; + }, + async commit() { + queries.push(['commit']); + }, + async rollback() { + queries.push(['rollback']); + }, + release() { + queries.push(['release']); + } + }; +} + +test('timetable create and update persist the selected timezone', async () => { + const connection = createConnection(); + const { app, handlers } = createApp(); + + registerTimetableRoutes(app, { + pool: { + getConnection() { + return connection; + } + }, + common: { + fetchTimetableGroupById: async function () { + return { + id: 7, + name: 'Existing', + short_description: 'Old description', + timezone: 'Europe/London' + }; + }, + fetchDuplicateName: async function () { + return false; + }, + buildTimetableGroupPayload: function (req) { + return { + name: req.body.name, + shortDescription: req.body.short_description, + timezone: req.body.timezone + }; + }, + fetchTimetableEntriesByGroupId: async function () { + return []; + } + }, + getAuditUserId: function () { + return 9; + }, + redirectAfterSave: function (_req, res, url) { + res.redirect(url); + }, + parseDateTimeLocal: function (value) { + return value ? new Date(value) : null; + }, + requirePermission: function () { + return function (_req, _res, next) { + next(); + }; + } + }); + + const createRoute = handlers['POST /data-sources/timetables'][1]; + const updateRoute = handlers['POST /data-sources/timetables/:id'][1]; + + const createResponse = { + redirects: [], + redirect(url) { + this.redirects.push(url); + }, + status() { + return this; + } + }; + + await createRoute({ + body: { + name: 'Morning', + short_description: 'Breakfast block', + timezone: 'Europe/Berlin' + }, + currentUser: { id: 1 }, + query: {} + }, createResponse, function () {}); + + assert.deepEqual(connection.queries[1], [ + 'INSERT INTO i_timetable_groups (name, short_description, timezone, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', + ['Morning', 'Breakfast block', 'Europe/Berlin', 9, 9] + ]); + assert.equal(createResponse.redirects[0], '/data-sources/timetables/42/edit'); + + connection.queries.length = 0; + + const updateResponse = { + redirects: [], + redirect(url) { + this.redirects.push(url); + }, + status() { + return this; + } + }; + + await updateRoute({ + params: { id: '7' }, + body: { + name: 'Morning', + short_description: 'Breakfast block', + timezone: 'Europe/Berlin' + }, + currentUser: { id: 1 }, + query: {} + }, updateResponse, function () {}); + + assert.deepEqual(connection.queries[1], [ + 'UPDATE i_timetable_groups SET name = ?, short_description = ?, timezone = ?, modified_by = ? WHERE id = ?', + ['Morning', 'Breakfast block', 'Europe/Berlin', 9, 7] + ]); + assert.equal(updateResponse.redirects[0], '/data-sources/timetables/7/edit'); +}); \ No newline at end of file