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 '
' + '' + @@ -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 '