Release v2.6.21

This commit is contained in:
2026-08-09 12:30:22 +01:00
parent 39f2098ffe
commit c36b9122c9
11 changed files with 58 additions and 36 deletions
+8
View File
@@ -2,6 +2,14 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## 2.6.21 - 2026-08-09
### Changed
- Startup now logs the previously detected schema version, the current app version, and whether migrations are required.
- The schema documentation now reflects the timetable table rename, the new `o_app_state` table, and startup version tracking.
- Timetable timezone placeholders now use `tz` for the short zone name and `tz_long` for the full IANA zone.
## 2.6.20 - 2026-08-09 ## 2.6.20 - 2026-08-09
### Changed ### Changed
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage-player", "name": "pulse-signage-player",
"version": "2.6.20", "version": "2.6.21",
"private": false, "private": false,
"description": "Pulse Signage player application bundle", "description": "Pulse Signage player application bundle",
"main": "src/common.js", "main": "src/common.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage-web", "name": "pulse-signage-web",
"version": "2.6.20", "version": "2.6.21",
"private": false, "private": false,
"description": "Pulse Signage web and bridge application bundle", "description": "Pulse Signage web and bridge application bundle",
"main": "src/common.js", "main": "src/common.js",
+20 -10
View File
@@ -165,8 +165,8 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
- `i_rss_feeds` - RSS feed definitions and refresh cadence. - `i_rss_feeds` - RSS feed definitions and refresh cadence.
- `i_rss_feed_items` - cached RSS feed items. - `i_rss_feed_items` - cached RSS feed items.
- `i_api_sources` - API source definitions and last response snapshot. - `i_api_sources` - API source definitions and last response snapshot.
- `i_schedule_groups` - grouped schedule definitions used by the schedule region. - `i_timetable_groups` - grouped timetable definitions used by the timetable region.
- `i_schedule_entries` - dated entries that belong to a schedule group. - `i_timetable_entries` - dated entries that belong to a timetable group.
### `i_rss_feeds` ### `i_rss_feeds`
@@ -184,32 +184,40 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
- `id`, `name`, `api_url`, `auth_method`, `auth_username`, `auth_password`, `auth_bearer_token`, `auth_header_name`, `auth_header_value`, `items_path`, `update_interval_value`, `update_interval_unit`, `last_pulled_at`, `last_pull_error`, `last_response_status`, `last_response_content_type`, `last_response_json`, `created_at`, `created_by`, `modified_at`, `modified_by` - `id`, `name`, `api_url`, `auth_method`, `auth_username`, `auth_password`, `auth_bearer_token`, `auth_header_name`, `auth_header_value`, `items_path`, `update_interval_value`, `update_interval_unit`, `last_pulled_at`, `last_pull_error`, `last_response_status`, `last_response_content_type`, `last_response_json`, `created_at`, `created_by`, `modified_at`, `modified_by`
### `i_schedule_groups` ### `i_timetable_groups`
- `id`, `name`, `short_description`, `created_at`, `created_by`, `modified_at`, `modified_by` - `id`, `name`, `short_description`, `timezone`, `created_at`, `created_by`, `modified_at`, `modified_by`
- `timezone` defaults to `Europe/London`.
### `i_schedule_entries` ### `i_timetable_entries`
- `id`, `schedule_group_id`, `title`, `short_description`, `start_datetime`, `end_datetime`, `created_at`, `created_by`, `modified_at`, `modified_by` - `id`, `schedule_group_id`, `title`, `short_description`, `start_datetime`, `end_datetime`, `created_at`, `created_by`, `modified_at`, `modified_by`
- Foreign key: - Foreign key:
- `schedule_group_id` -> `i_schedule_groups.id` with `ON DELETE CASCADE` - `schedule_group_id` -> `i_timetable_groups.id` with `ON DELETE CASCADE`
- Index: - Index:
- `(schedule_group_id, start_datetime)` - `(schedule_group_id, start_datetime)`
## Operations ## Operations
- `o_background_tasks` - queue and history for background jobs. - `o_background_tasks` - queue and history for background jobs.
- `o_app_state` - generic app state and version markers stored as key/value pairs.
### `o_background_tasks` ### `o_background_tasks`
- `id`, `task_key`, `task_type`, `title`, `category`, `status`, `payload_json`, `metadata_json`, `attempts`, `created_at`, `created_by`, `started_at`, `finished_at`, `error_message` - `id`, `task_key`, `task_type`, `title`, `category`, `status`, `payload_json`, `metadata_json`, `attempts`, `created_at`, `created_by`, `started_at`, `finished_at`, `error_message`
- Indexed by `status`, `task_key`, and `task_type`. - Indexed by `status`, `task_key`, and `task_type`.
### `o_app_state`
- `state_key`, `state_value`, `created_at`, `modified_at`
- `state_key` is the primary key.
- `schema_version` is stored here so startup can detect the previously recorded schema version before deciding whether migrations need to run.
## Notes ## Notes
- The schema is initialized with `CREATE TABLE IF NOT EXISTS`, so new installs can start from an empty database. - The schema is initialized with `CREATE TABLE IF NOT EXISTS`, so new installs can start from an empty database.
- `src/db/bootstrap.js` seeds the canvas size defaults, default permissions, and the default administrator role. - `src/db/bootstrap.js` seeds the canvas size defaults, default permissions, and the default administrator role.
- The migration module stays in place for future releases, but this version treats the current schema as the install baseline. - `src/db/migrations.js` records the current schema version in `o_app_state` during startup so later launches can tell whether an update is happening.
```mermaid ```mermaid
erDiagram erDiagram
@@ -255,9 +263,11 @@ erDiagram
} }
I_API_SOURCES { I_API_SOURCES {
} }
I_SCHEDULE_GROUPS { I_TIMETABLE_GROUPS {
} }
I_SCHEDULE_ENTRIES { I_TIMETABLE_ENTRIES {
}
O_APP_STATE {
} }
O_BACKGROUND_TASKS { O_BACKGROUND_TASKS {
} }
@@ -282,5 +292,5 @@ erDiagram
D_SCREENS ||--o{ D_ANNOUNCEMENT_SCREENS : receives D_SCREENS ||--o{ D_ANNOUNCEMENT_SCREENS : receives
I_RSS_FEEDS ||--o{ I_RSS_FEED_ITEMS : caches I_RSS_FEEDS ||--o{ I_RSS_FEED_ITEMS : caches
I_SCHEDULE_GROUPS ||--o{ I_SCHEDULE_ENTRIES : contains I_TIMETABLE_GROUPS ||--o{ I_TIMETABLE_ENTRIES : contains
``` ```
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.6.20", "version": "2.6.21",
"private": false, "private": false,
"description": "Pulse Signage application with MySQL and media storage", "description": "Pulse Signage application with MySQL and media storage",
"repository": { "repository": {
+2 -2
View File
@@ -213,8 +213,8 @@ function getFormattedParts(timeZone, date) {
MMM: toTitleCase(getPart(monthShort, 'month')), MMM: toTitleCase(getPart(monthShort, 'month')),
MMMM: toTitleCase(getPart(monthLong, 'month')), MMMM: toTitleCase(getPart(monthLong, 'month')),
a: toTitleCase(getPart(ampm, 'dayPeriod')), a: toTitleCase(getPart(ampm, 'dayPeriod')),
tz: resolvedTimeZone, tz: getPart(timezoneShort, 'timeZoneName'),
tz_short: getPart(timezoneShort, 'timeZoneName'), tz_long: resolvedTimeZone,
date: getPart(numericParts, 'year') + '-' + getPart(numericParts, 'month') + '-' + getPart(numericParts, 'day'), date: getPart(numericParts, 'year') + '-' + getPart(numericParts, 'month') + '-' + getPart(numericParts, 'day'),
time: getPart(numericParts, 'hour') + ':' + getPart(numericParts, 'minute') + ':' + getPart(numericParts, 'second') time: getPart(numericParts, 'hour') + ':' + getPart(numericParts, 'minute') + ':' + getPart(numericParts, 'second')
}; };
+7 -7
View File
@@ -84,8 +84,8 @@
} }
return { return {
tz: timeZone, tz: shortName,
tz_short: shortName tz_long: timeZone
}; };
} }
@@ -298,7 +298,7 @@
'<div class="api-region-placeholder-section schedule-placeholder-section" data-schedule-placeholder-chips>' + '<div class="api-region-placeholder-section schedule-placeholder-section" data-schedule-placeholder-chips>' +
'<div class="api-region-placeholder-title">Available placeholders</div>' + '<div class="api-region-placeholder-title">Available placeholders</div>' +
'<div class="d-flex flex-wrap gap-2">' + renderSchedulePlaceholderChips(currentGroup, currentEntries) + '</div>' + '<div class="d-flex flex-wrap gap-2">' + renderSchedulePlaceholderChips(currentGroup, currentEntries) + '</div>' +
'<div class="text-body-secondary small mt-1">Placeholder values support transforms, for example <code>{{title.upper()}}</code>, <code>{{title.title()}}</code>, <code>{{title.lower()}}</code>, <code>{{start.format("MMM D, YYYY h:mm A")}}</code>, <code>{{start.tz()}}</code> or <code>{{start.tz_short()}}</code>.</div>' + '<div class="text-body-secondary small mt-1">Placeholder values support transforms, for example <code>{{title.upper()}}</code>, <code>{{title.title()}}</code>, <code>{{title.lower()}}</code>, <code>{{start.format("MMM D, YYYY h:mm A")}}</code>, <code>{{start.tz()}}</code> for the short zone name, or <code>{{start.tz_long()}}</code> for the full IANA zone name.</div>' +
'<details class="mt-2">' + '<details class="mt-2">' +
'<summary class="small text-body-secondary">Supported date format tokens</summary>' + '<summary class="small text-body-secondary">Supported date format tokens</summary>' +
'<div class="mt-2">' + renderScheduleFormatTokenTable() + '</div>' + '<div class="mt-2">' + renderScheduleFormatTokenTable() + '</div>' +
@@ -392,8 +392,8 @@
timetable_group_id: timetableGroupInput ? timetableGroupInput.value || current.timetable_group_id : current.timetable_group_id, 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, display_mode: timetableDisplayModeInput ? timetableDisplayModeInput.value || current.display_mode : current.display_mode,
tz: timezoneValues.tz, tz: timezoneValues.tz,
tz_short: timezoneValues.tz_short, tz_long: timezoneValues.tz_long,
timeZone: timezoneValues.tz, timeZone: timezoneValues.tz_long,
max_items: timetableMaxItemsInput ? timetableMaxItemsInput.value || current.max_items : current.max_items, max_items: timetableMaxItemsInput ? timetableMaxItemsInput.value || current.max_items : current.max_items,
existingContent: existingContent || {}, existingContent: existingContent || {},
timetableGroups: Array.isArray(timetableGroups) ? timetableGroups : [] timetableGroups: Array.isArray(timetableGroups) ? timetableGroups : []
@@ -421,8 +421,8 @@
start: entry && entry.start_datetime !== undefined ? entry.start_datetime : '', start: entry && entry.start_datetime !== undefined ? entry.start_datetime : '',
end: entry && entry.end_datetime !== undefined ? entry.end_datetime : '', end: entry && entry.end_datetime !== undefined ? entry.end_datetime : '',
tz: timezoneValues.tz, tz: timezoneValues.tz,
tz_short: timezoneValues.tz_short, tz_long: timezoneValues.tz_long,
timeZone: timezoneValues.tz, timeZone: timezoneValues.tz_long,
group: group || {}, group: group || {},
entries: entries, entries: entries,
index: index + 1 index: index + 1
+3 -3
View File
@@ -156,11 +156,11 @@
MMM: toTitleCase(getPart(monthShort, 'month')), MMM: toTitleCase(getPart(monthShort, 'month')),
MMMM: toTitleCase(getPart(monthLong, 'month')), MMMM: toTitleCase(getPart(monthLong, 'month')),
a: toTitleCase(getPart(ampm, 'dayPeriod')), a: toTitleCase(getPart(ampm, 'dayPeriod')),
tz: resolvedTimeZone, tz: getPart(getFormatter('tz-short:' + resolvedTimeZone, {
tz_short: getPart(getFormatter('tz-short:' + resolvedTimeZone, {
timeZone: resolvedTimeZone, timeZone: resolvedTimeZone,
timeZoneName: 'short' timeZoneName: 'short'
}).formatToParts(targetDate), 'timeZoneName'), }).formatToParts(targetDate), 'timeZoneName'),
tz_long: resolvedTimeZone,
date: getPart(numericParts, 'year') + '-' + getPart(numericParts, 'month') + '-' + getPart(numericParts, 'day'), date: getPart(numericParts, 'year') + '-' + getPart(numericParts, 'month') + '-' + getPart(numericParts, 'day'),
time: getPart(numericParts, 'hour') + ':' + getPart(numericParts, 'minute') + ':' + getPart(numericParts, 'second') time: getPart(numericParts, 'hour') + ':' + getPart(numericParts, 'minute') + ':' + getPart(numericParts, 'second')
}; };
@@ -200,7 +200,7 @@
'MMMM', 'MMMM',
'a', 'a',
'tz', 'tz',
'tz_short' 'tz_long'
]; ];
if (typeof placeholderChips.renderChips === 'function') { if (typeof placeholderChips.renderChips === 'function') {
@@ -245,6 +245,10 @@
} }
} }
function getTimeZoneLongName(timeZone) {
return getTransformTimeZone([], { timeZone: timeZone });
}
function getTransformTimeZone(args, options) { function getTransformTimeZone(args, options) {
return resolveTimeZone((args && args[0]) || (options && options.timeZone) || ''); return resolveTimeZone((args && args[0]) || (options && options.timeZone) || '');
} }
@@ -273,11 +277,11 @@
} }
if (name === 'tz') { if (name === 'tz') {
return getTransformTimeZone(args, options); return getTimeZoneShortName(value, getTransformTimeZone(args, options));
} }
if (name === 'tz_short') { if (name === 'tz_long') {
return getTimeZoneShortName(value, getTransformTimeZone(args, options)); return getTimeZoneLongName(getTransformTimeZone(args, options));
} }
return text; return text;
@@ -24,8 +24,8 @@
]; ];
var HELPER_PLACEHOLDERS = [ var HELPER_PLACEHOLDERS = [
{ token: 'tz', output: 'Europe/London', description: 'The resolved time zone name' }, { token: 'tz', output: 'BST/GMT', description: 'The time zone abbreviation for the selected date' },
{ token: 'tz_short', output: 'BST/GMT', description: 'The time zone abbreviation for the selected date' } { token: 'tz_long', output: 'Europe/London', description: 'The resolved time zone name' }
]; ];
var PLACEHOLDERS = FORMAT_PLACEHOLDERS.concat(HELPER_PLACEHOLDERS); var PLACEHOLDERS = FORMAT_PLACEHOLDERS.concat(HELPER_PLACEHOLDERS);
+6 -6
View File
@@ -86,7 +86,7 @@ test('timetable region editor lists timezone transforms in helper text', () => {
const html = module.renderEditorCard({ const html = module.renderEditorCard({
region: { id: 47, label: 'Timetable' }, region: { id: 47, label: 'Timetable' },
current: { current: {
value: '<p>{{title}} {{start.tz()}} {{start.tz_short()}}</p>', value: '<p>{{title}} {{start.tz()}} {{start.tz_long()}}</p>',
timetable_group_id: 1, timetable_group_id: 1,
display_mode: 'current', display_mode: 'current',
max_items: 3 max_items: 3
@@ -103,7 +103,7 @@ test('timetable region editor lists timezone transforms in helper text', () => {
assert.match(html, /Placeholder values support transforms/); assert.match(html, /Placeholder values support transforms/);
assert.match(html, /<code>\{\{start\.tz\(\)\}\}<\/code>/); assert.match(html, /<code>\{\{start\.tz\(\)\}\}<\/code>/);
assert.match(html, /<code>\{\{start\.tz_short\(\)\}\}<\/code>/); assert.match(html, /<code>\{\{start\.tz_long\(\)\}\}<\/code>/);
assert.doesNotMatch(html, /class="chip">\{\{start\.tz\(\)\}\}<\/span>/); assert.doesNotMatch(html, /class="chip">\{\{start\.tz\(\)\}\}<\/span>/);
}); });
@@ -136,7 +136,7 @@ test('timetable region editor shows supported date format tokens', () => {
assert.doesNotMatch(html, /<td><code>yyyy<\/code><\/td>/); assert.doesNotMatch(html, /<td><code>yyyy<\/code><\/td>/);
assert.doesNotMatch(html, /<td><code>yy<\/code><\/td>/); assert.doesNotMatch(html, /<td><code>yy<\/code><\/td>/);
assert.doesNotMatch(html, /<td><code>tz<\/code><\/td>/); assert.doesNotMatch(html, /<td><code>tz<\/code><\/td>/);
assert.doesNotMatch(html, /<td><code>tz_short<\/code><\/td>/); assert.match(html, /start\.tz_long\(\)/);
assert.match(html, /Use these tokens inside <code>\.format\(\.\.\.\)<\/code>/); assert.match(html, /Use these tokens inside <code>\.format\(\.\.\.\)<\/code>/);
}); });
@@ -145,7 +145,7 @@ test('timetable region preview resolves timezone placeholders from explicit time
const preview = module.renderPreview( const preview = module.renderPreview(
{ id: 47, width: 500, height: 280 }, { id: 47, width: 500, height: 280 },
{ {
value: '<p>{{start.tz("UTC")}} {{start.tz_short("UTC")}}</p>', value: '<p>{{start.tz("UTC")}} {{start.tz_long("UTC")}}</p>',
timetable_group_id: 1, timetable_group_id: 1,
display_mode: 'both', display_mode: 'both',
max_items: 3 max_items: 3
@@ -210,7 +210,7 @@ test('timetable timezone abbreviation changes across daylight saving boundaries'
const preview = module.renderPreview( const preview = module.renderPreview(
{ id: 47, width: 500, height: 280 }, { id: 47, width: 500, height: 280 },
{ {
value: '<p>{{start.tz_short("Europe/London")}}</p>', value: '<p>{{start.tz("Europe/London")}}</p>',
timetable_group_id: 1, timetable_group_id: 1,
display_mode: 'both', display_mode: 'both',
max_items: 10 max_items: 10
@@ -265,7 +265,7 @@ test('timetable preview does not force the group timezone into placeholder trans
const preview = module.renderPreview( const preview = module.renderPreview(
{ id: 47, width: 500, height: 280 }, { id: 47, width: 500, height: 280 },
{ {
value: '<p>{{start.tz_short()}}</p>', value: '<p>{{start.tz_long()}}</p>',
timetable_group_id: 1, timetable_group_id: 1,
display_mode: 'both', display_mode: 'both',
max_items: 3 max_items: 3