Release v2.17.3: roll weather forecasts forward at hour and day boundaries
This commit is contained in:
1 parent
5e23d16483
commit
bc8075379a
13 files changed
+263
-10
No files matched your search
@@ -2,6 +2,13 @@
|
|||||||
|
|
||||||
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.17.3 - 2026-10-23
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Weather forecasts drop expired hours at the hour boundary and past days at local midnight, using the weather location's timezone and cached data without waiting for a provider refresh. Editor previews, held player slides, and thumbnails keep forecast values and icons aligned as the remaining entries shift forward immediately.
|
||||||
|
- Exhausted or missing forecast entries no longer display a default weather icon.
|
||||||
|
|
||||||
## 2.17.2 - 2026-10-23
|
## 2.17.2 - 2026-10-23
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage-player",
|
"name": "pulse-signage-player",
|
||||||
"version": "2.17.2",
|
"version": "2.17.3",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage player application bundle",
|
"description": "Pulse Signage player application bundle",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage-web",
|
"name": "pulse-signage-web",
|
||||||
"version": "2.17.2",
|
"version": "2.17.3",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage web and bridge application bundle",
|
"description": "Pulse Signage web and bridge application bundle",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.17.2",
|
"version": "2.17.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.17.2",
|
"version": "2.17.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"animate.css": "^4.1.1",
|
"animate.css": "^4.1.1",
|
||||||
"bootstrap-icons": "1.13.1",
|
"bootstrap-icons": "1.13.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.17.2",
|
"version": "2.17.3",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage application with MySQL and media storage",
|
"description": "Pulse Signage application with MySQL and media storage",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ function normalizeWeatherExpression(expression) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function withWeatherUnits(snapshot, location) {
|
function withWeatherUnits(snapshot, location) {
|
||||||
|
if (window.pulseWeatherData) snapshot = window.pulseWeatherData.filterForecastSnapshot(snapshot, location.timezone);
|
||||||
var data = Object.assign({}, snapshot, { location_label: location.location_label || '', name: location.name || '', timezone: location.timezone || '', temp_unit: location.temperature_unit === 'fahrenheit' ? '°F' : '°C', wind_unit: location.wind_unit === 'mph' ? 'mph' : location.wind_unit === 'ms' ? 'm/s' : 'km/h', precip_unit: location.precipitation_unit === 'inch' ? 'in' : 'mm' });
|
var data = Object.assign({}, snapshot, { location_label: location.location_label || '', name: location.name || '', timezone: location.timezone || '', temp_unit: location.temperature_unit === 'fahrenheit' ? '°F' : '°C', wind_unit: location.wind_unit === 'mph' ? 'mph' : location.wind_unit === 'ms' ? 'm/s' : 'km/h', precip_unit: location.precipitation_unit === 'inch' ? 'in' : 'mm' });
|
||||||
var temperatureUnit = location.temperature_unit === 'fahrenheit' ? '°F' : '°C';
|
var temperatureUnit = location.temperature_unit === 'fahrenheit' ? '°F' : '°C';
|
||||||
var windUnit = location.wind_unit === 'mph' ? 'mph' : location.wind_unit === 'ms' ? 'm/s' : 'km/h';
|
var windUnit = location.wind_unit === 'mph' ? 'mph' : location.wind_unit === 'ms' ? 'm/s' : 'km/h';
|
||||||
@@ -62,6 +63,7 @@ function substituteWeatherVariables(html, value, iconSetId) {
|
|||||||
var alignment = iconMatch[3] || 'middle';
|
var alignment = iconMatch[3] || 'middle';
|
||||||
var style = width ? ' style="display:block;font-size:' + width + 'px;line-height:1;width:' + width + 'px;height:' + height + 'px;"' : '';
|
var style = width ? ' style="display:block;font-size:' + width + 'px;line-height:1;width:' + width + 'px;height:' + height + 'px;"' : '';
|
||||||
var weatherCode = weatherPlaceholderUtils.resolvePlaceholderExpression(value, codeExpression, { timeZone: value.timezone });
|
var weatherCode = weatherPlaceholderUtils.resolvePlaceholderExpression(value, codeExpression, { timeZone: value.timezone });
|
||||||
|
if (weatherCode === null || weatherCode === undefined || weatherCode === '') return '';
|
||||||
var source = window.pulseWeatherIcons && window.pulseWeatherIcons.imageForCode(initialData && initialData.weatherIconSets, iconSetId, weatherCode, window.pulseWeatherIcons.isNightForWeather(value));
|
var source = window.pulseWeatherIcons && window.pulseWeatherIcons.imageForCode(initialData && initialData.weatherIconSets, iconSetId, weatherCode, window.pulseWeatherIcons.isNightForWeather(value));
|
||||||
if (source) return window.pulseWeatherIcons.renderImage(source, width, height, alignment);
|
if (source) return window.pulseWeatherIcons.renderImage(source, width, height, alignment);
|
||||||
var icon = '<i class="bi ' + weatherIconForCode(weatherCode) + '"' + style + ' aria-hidden="true"></i>';
|
var icon = '<i class="bi ' + weatherIconForCode(weatherCode) + '"' + style + ' aria-hidden="true"></i>';
|
||||||
@@ -86,7 +88,8 @@ function renderWeatherRegion(region, regionContent) {
|
|||||||
if (value) {
|
if (value) {
|
||||||
var weatherData = withWeatherUnits(snapshot, location);
|
var weatherData = withWeatherUnits(snapshot, location);
|
||||||
var fontSize = Math.max(8, Number(regionContent && regionContent.font_size || region && (region.font_size || region.fontSize) || 32) || 32);
|
var fontSize = Math.max(8, Number(regionContent && regionContent.font_size || region && (region.font_size || region.fontSize) || 32) || 32);
|
||||||
return '<div class="template-region weather" style="' + region.baseStyle + '"><div style="' + style + 'font-family:Arial,sans-serif;font-size:' + fontSize + 'px;line-height:1.5;">' + renderEditorJsContent(substituteWeatherVariables(value, weatherData, regionContent.weather_icon_set_id)) + '</div></div>';
|
var liveContent = /\{\{\s*(?:hourly|daily)\./.test(value) ? ' data-weather-content="' + escapeHtml(JSON.stringify(regionContent)) + '"' : '';
|
||||||
|
return '<div class="template-region weather"' + liveContent + ' style="' + region.baseStyle + '"><div data-weather-output style="' + style + 'font-family:Arial,sans-serif;font-size:' + fontSize + 'px;line-height:1.5;">' + renderEditorJsContent(substituteWeatherVariables(value, weatherData, regionContent.weather_icon_set_id)) + '</div></div>';
|
||||||
}
|
}
|
||||||
var daily = snapshot.daily || {};
|
var daily = snapshot.daily || {};
|
||||||
var days = (daily.time || []).slice(0, 7).map(function (day, index) {
|
var days = (daily.time || []).slice(0, 7).map(function (day, index) {
|
||||||
@@ -95,4 +98,36 @@ function renderWeatherRegion(region, regionContent) {
|
|||||||
return '<div class="template-region weather" style="' + region.baseStyle + '"><div style="' + style + 'padding:3%;font-family:Arial,sans-serif;"><div style="display:flex;align-items:center;justify-content:space-between;"><div><div style="font-size:.8em;opacity:.72;">' + escapeHtml(location.location_label || location.name) + '</div><strong style="font-size:2em;">' + escapeHtml(current.temperature_2m === undefined ? '-' : current.temperature_2m) + '°</strong></div><i class="bi ' + weatherIconForCode(current.weather_code) + '" style="font-size:3em;"></i></div><div style="display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:.35em;margin-top:1em;">' + days + '</div></div></div>';
|
return '<div class="template-region weather" style="' + region.baseStyle + '"><div style="' + style + 'padding:3%;font-family:Arial,sans-serif;"><div style="display:flex;align-items:center;justify-content:space-between;"><div><div style="font-size:.8em;opacity:.72;">' + escapeHtml(location.location_label || location.name) + '</div><strong style="font-size:2em;">' + escapeHtml(current.temperature_2m === undefined ? '-' : current.temperature_2m) + '°</strong></div><i class="bi ' + weatherIconForCode(current.weather_code) + '" style="font-size:3em;"></i></div><div style="display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:.35em;margin-top:1em;">' + days + '</div></div></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
weatherRegistry.register('weather', { renderRegion: renderWeatherRegion });
|
function initializeWeatherRegions(root) {
|
||||||
|
Array.prototype.forEach.call(root.querySelectorAll('.template-region.weather[data-weather-content]'), function (element) {
|
||||||
|
if (element.__weatherTimer) window.clearTimeout(element.__weatherTimer);
|
||||||
|
var content;
|
||||||
|
try { content = JSON.parse(element.getAttribute('data-weather-content')); } catch (_error) { return; }
|
||||||
|
var previousTimes = null;
|
||||||
|
function tick() {
|
||||||
|
element.__weatherTimer = null;
|
||||||
|
if (!element.isConnected) return;
|
||||||
|
var location = getWeatherLocation(content.weather_location_id);
|
||||||
|
var output = element.querySelector('[data-weather-output]');
|
||||||
|
if (location && location.responseJson && output) {
|
||||||
|
var data = withWeatherUnits(location.responseJson, location);
|
||||||
|
var times = JSON.stringify([data.hourly && data.hourly.time || [], data.daily && data.daily.time || []]);
|
||||||
|
if (times !== previousTimes) {
|
||||||
|
output.innerHTML = renderEditorJsContent(substituteWeatherVariables(content.value, data, content.weather_icon_set_id));
|
||||||
|
previousTimes = times;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
element.__weatherTimer = window.setTimeout(tick, Math.max(1, 60000 - Date.now() % 60000));
|
||||||
|
}
|
||||||
|
tick();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroyWeatherRegions(root) {
|
||||||
|
Array.prototype.forEach.call(root.querySelectorAll('.template-region.weather[data-weather-content]'), function (element) {
|
||||||
|
if (element.__weatherTimer) window.clearTimeout(element.__weatherTimer);
|
||||||
|
element.__weatherTimer = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
weatherRegistry.register('weather', { renderRegion: renderWeatherRegion, initRegion: initializeWeatherRegions, destroyRegion: destroyWeatherRegions });
|
||||||
@@ -103,7 +103,9 @@ function renderOnboardingFormBody(deviceId) {
|
|||||||
|
|
||||||
|
|
||||||
function renderPlayerPage(slug, initialData) {
|
function renderPlayerPage(slug, initialData) {
|
||||||
const serviceWorkerScript = '<script>' + fs.readFileSync(path.join(__dirname, '../web/public/js/shared/weather-icons.js'), 'utf8') + '</script>' + getPlayerServiceWorkerRegistrationScript();
|
const serviceWorkerScript = ['weather-icons.js', 'weather-data.js'].map(function (filename) {
|
||||||
|
return '<script>' + fs.readFileSync(path.join(__dirname, '../web/public/js/shared', filename), 'utf8') + '</script>';
|
||||||
|
}).join('') + getPlayerServiceWorkerRegistrationScript();
|
||||||
const template = getPlayerPageTemplate();
|
const template = getPlayerPageTemplate();
|
||||||
const hlsScriptTag = '<script src="/assets/vendor/hls.min.js"></script>';
|
const hlsScriptTag = '<script src="/assets/vendor/hls.min.js"></script>';
|
||||||
const chartScriptTag = '<script src="/assets/vendor/chart.js/chart.umd.js"></script>';
|
const chartScriptTag = '<script src="/assets/vendor/chart.js/chart.umd.js"></script>';
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const { createRequestAuthHeaders, createPageAuthToken } = require('#src/request-
|
|||||||
const { convertWeatherSnapshot } = require('#src/data/weather-units');
|
const { convertWeatherSnapshot } = require('#src/data/weather-units');
|
||||||
|
|
||||||
const placeholderUtils = require('#src/web/public/js/shared/placeholder-utils');
|
const placeholderUtils = require('#src/web/public/js/shared/placeholder-utils');
|
||||||
|
const weatherData = require('#src/web/public/js/shared/weather-data');
|
||||||
|
|
||||||
const timetableScript = new vm.Script(fs.readFileSync(require.resolve('#src/player/regions/timetable'), 'utf8'));
|
const timetableScript = new vm.Script(fs.readFileSync(require.resolve('#src/player/regions/timetable'), 'utf8'));
|
||||||
|
|
||||||
@@ -195,8 +196,9 @@ function weatherIconForCode(code) {
|
|||||||
function substitutePlaceholders(html, regionContent, options) {
|
function substitutePlaceholders(html, regionContent, options) {
|
||||||
const source = String(html || '');
|
const source = String(html || '');
|
||||||
const type = String(regionContent && regionContent.type || '').trim().toLowerCase();
|
const type = String(regionContent && regionContent.type || '').trim().toLowerCase();
|
||||||
const item = options && typeof options.getItem === 'function' ? options.getItem(type, regionContent) : null;
|
let item = options && typeof options.getItem === 'function' ? options.getItem(type, regionContent) : null;
|
||||||
if (!item) return source;
|
if (!item) return source;
|
||||||
|
if (type === 'weather') item = weatherData.filterForecastSnapshot(item, item.timezone);
|
||||||
return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/gi, function (_match, expression) {
|
return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/gi, function (_match, expression) {
|
||||||
const weatherExpression = type === 'weather' ? normalizeWeatherExpression(expression) : expression;
|
const weatherExpression = type === 'weather' ? normalizeWeatherExpression(expression) : expression;
|
||||||
const resolved = typeof placeholderUtils.resolvePlaceholderExpression === 'function'
|
const resolved = typeof placeholderUtils.resolvePlaceholderExpression === 'function'
|
||||||
@@ -207,6 +209,8 @@ function substitutePlaceholders(html, regionContent, options) {
|
|||||||
}
|
}
|
||||||
if (type === 'weather' && /(?:^|\.)weather_code\.\d+\.icon(?:\(|$)|^current\.weather_code\.icon/.test(weatherExpression)) {
|
if (type === 'weather' && /(?:^|\.)weather_code\.\d+\.icon(?:\(|$)|^current\.weather_code\.icon/.test(weatherExpression)) {
|
||||||
const codeExpression = weatherExpression.replace(/\.icon(?:\(.*\))?$/, '');
|
const codeExpression = weatherExpression.replace(/\.icon(?:\(.*\))?$/, '');
|
||||||
|
const weatherCode = placeholderUtils.resolvePlaceholderExpression(item, codeExpression);
|
||||||
|
if (weatherCode === null || weatherCode === undefined || weatherCode === '') return '';
|
||||||
const iconSize = String(expression).match(/\.icon\(\s*(\d+)(?:\s*,\s*(\d+))?(?:\s*,\s*(top|middle|bottom))?\s*\)$/);
|
const iconSize = String(expression).match(/\.icon\(\s*(\d+)(?:\s*,\s*(\d+))?(?:\s*,\s*(top|middle|bottom))?\s*\)$/);
|
||||||
const width = iconSize ? Number(iconSize[1]) : 0;
|
const width = iconSize ? Number(iconSize[1]) : 0;
|
||||||
const height = iconSize && iconSize[2] ? Number(iconSize[2]) : width;
|
const height = iconSize && iconSize[2] ? Number(iconSize[2]) : width;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const path = require('path');
|
|||||||
const PUBLIC_JS_ROOT = path.join(__dirname, '..', 'public', 'js');
|
const PUBLIC_JS_ROOT = path.join(__dirname, '..', 'public', 'js');
|
||||||
const REGION_ROOT_DIR = path.join(PUBLIC_JS_ROOT, 'regions');
|
const REGION_ROOT_DIR = path.join(PUBLIC_JS_ROOT, 'regions');
|
||||||
const REGION_TYPE_DIR = path.join(REGION_ROOT_DIR, 'type');
|
const REGION_TYPE_DIR = path.join(REGION_ROOT_DIR, 'type');
|
||||||
const REGION_CORE_SCRIPTS = ['js/shared/placeholder-utils.js', 'js/shared/placeholder-chips.js', 'js/shared/placeholder-info.js', 'js/shared/time-date-placeholders.js', 'js/regions/region-utils.js', 'js/regions/region-types.js', 'js/regions/chart-data.js', 'vendor/chart.js/chart.umd.js'];
|
const REGION_CORE_SCRIPTS = ['js/shared/placeholder-utils.js', 'js/shared/weather-data.js', 'js/shared/placeholder-chips.js', 'js/shared/placeholder-info.js', 'js/shared/time-date-placeholders.js', 'js/regions/region-utils.js', 'js/regions/region-types.js', 'js/regions/chart-data.js', 'vendor/chart.js/chart.umd.js'];
|
||||||
|
|
||||||
function withAssetVersion(scriptPath, assetVersion) {
|
function withAssetVersion(scriptPath, assetVersion) {
|
||||||
if (!assetVersion) {
|
if (!assetVersion) {
|
||||||
|
|||||||
@@ -99,6 +99,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function withWeatherUnits(snapshot, location) {
|
function withWeatherUnits(snapshot, location) {
|
||||||
|
if (window.pulseWeatherData) snapshot = window.pulseWeatherData.filterForecastSnapshot(snapshot, location.timezone);
|
||||||
var data = Object.assign({}, snapshot, { location_label: location.location_label || '', name: location.name || '', timezone: location.timezone || '', temp_unit: location.temperature_unit === 'fahrenheit' ? '°F' : '°C', wind_unit: location.wind_unit === 'mph' ? 'mph' : location.wind_unit === 'ms' ? 'm/s' : 'km/h', precip_unit: location.precipitation_unit === 'inch' ? 'in' : 'mm' });
|
var data = Object.assign({}, snapshot, { location_label: location.location_label || '', name: location.name || '', timezone: location.timezone || '', temp_unit: location.temperature_unit === 'fahrenheit' ? '°F' : '°C', wind_unit: location.wind_unit === 'mph' ? 'mph' : location.wind_unit === 'ms' ? 'm/s' : 'km/h', precip_unit: location.precipitation_unit === 'inch' ? 'in' : 'mm' });
|
||||||
var temperatureUnit = location.temperature_unit === 'fahrenheit' ? '°F' : '°C';
|
var temperatureUnit = location.temperature_unit === 'fahrenheit' ? '°F' : '°C';
|
||||||
var windUnit = location.wind_unit === 'mph' ? 'mph' : location.wind_unit === 'ms' ? 'm/s' : 'km/h';
|
var windUnit = location.wind_unit === 'mph' ? 'mph' : location.wind_unit === 'ms' ? 'm/s' : 'km/h';
|
||||||
@@ -122,6 +123,7 @@
|
|||||||
var codeExpression = normalizedExpression.replace(/\.icon(?:\(.*\))?$/, '');
|
var codeExpression = normalizedExpression.replace(/\.icon(?:\(.*\))?$/, '');
|
||||||
var sizeExpression = rawExpression.match(/\.icon\((\d+)(?:\s*,\s*(\d+))?(?:\s*,\s*(top|middle|bottom))?\)$/);
|
var sizeExpression = rawExpression.match(/\.icon\((\d+)(?:\s*,\s*(\d+))?(?:\s*,\s*(top|middle|bottom))?\)$/);
|
||||||
var weatherCode = placeholderUtils.resolvePlaceholderExpression(value, codeExpression, { timeZone: value.timezone });
|
var weatherCode = placeholderUtils.resolvePlaceholderExpression(value, codeExpression, { timeZone: value.timezone });
|
||||||
|
if (weatherCode === null || weatherCode === undefined || weatherCode === '') return '';
|
||||||
var width = sizeExpression ? Math.max(1, Math.min(1000, Number(sizeExpression[1]))) : 0;
|
var width = sizeExpression ? Math.max(1, Math.min(1000, Number(sizeExpression[1]))) : 0;
|
||||||
var height = sizeExpression && sizeExpression[2] ? Math.max(1, Math.min(1000, Number(sizeExpression[2]))) : width;
|
var height = sizeExpression && sizeExpression[2] ? Math.max(1, Math.min(1000, Number(sizeExpression[2]))) : width;
|
||||||
var alignment = sizeExpression && sizeExpression[3] || 'middle';
|
var alignment = sizeExpression && sizeExpression[3] || 'middle';
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
(function (root, factory) {
|
||||||
|
if (typeof module === 'object' && module.exports) module.exports = factory();
|
||||||
|
else root.pulseWeatherData = factory();
|
||||||
|
}(typeof window !== 'undefined' ? window : globalThis, function () {
|
||||||
|
function currentHour(timeZone, now) {
|
||||||
|
var parts = new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: timeZone || 'UTC', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', hourCycle: 'h23'
|
||||||
|
}).formatToParts(new Date(now));
|
||||||
|
var values = {};
|
||||||
|
parts.forEach(function (part) { values[part.type] = part.value; });
|
||||||
|
return values.year + '-' + values.month + '-' + values.day + 'T' + values.hour;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterHourlySnapshot(snapshot, timeZone, now) {
|
||||||
|
var source = snapshot || {};
|
||||||
|
var hourly = source.hourly;
|
||||||
|
if (!hourly || !Array.isArray(hourly.time)) return source;
|
||||||
|
var timestamp = now === undefined ? Date.now() : Number(now);
|
||||||
|
var hour = currentHour(timeZone || source.timezone, timestamp);
|
||||||
|
var indices = [];
|
||||||
|
hourly.time.forEach(function (value, index) {
|
||||||
|
var time = String(value || '');
|
||||||
|
var keep;
|
||||||
|
if (/(?:Z|[+-]\d{2}:?\d{2})$/i.test(time)) {
|
||||||
|
var start = Date.parse(time);
|
||||||
|
keep = Number.isFinite(start) && start + 3600000 > timestamp;
|
||||||
|
} else {
|
||||||
|
keep = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(time) && time.slice(0, 13) >= hour;
|
||||||
|
}
|
||||||
|
if (keep) indices.push(index);
|
||||||
|
});
|
||||||
|
var filtered = {};
|
||||||
|
Object.keys(hourly).forEach(function (field) {
|
||||||
|
filtered[field] = Array.isArray(hourly[field]) ? indices.map(function (index) {
|
||||||
|
return hourly[field][index] === undefined ? null : hourly[field][index];
|
||||||
|
}) : hourly[field];
|
||||||
|
});
|
||||||
|
return Object.assign({}, source, { hourly: filtered });
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterDailySnapshot(snapshot, timeZone, now) {
|
||||||
|
var source = snapshot || {};
|
||||||
|
var daily = source.daily;
|
||||||
|
if (!daily || !Array.isArray(daily.time)) return source;
|
||||||
|
var timestamp = now === undefined ? Date.now() : Number(now);
|
||||||
|
var zone = timeZone || source.timezone;
|
||||||
|
var today = currentHour(zone, timestamp).slice(0, 10);
|
||||||
|
var indices = [];
|
||||||
|
daily.time.forEach(function (value, index) {
|
||||||
|
var time = String(value || '');
|
||||||
|
var day = '';
|
||||||
|
if (/(?:Z|[+-]\d{2}:?\d{2})$/i.test(time)) {
|
||||||
|
var start = Date.parse(time);
|
||||||
|
if (Number.isFinite(start)) day = currentHour(zone, start).slice(0, 10);
|
||||||
|
} else if (/^\d{4}-\d{2}-\d{2}(?:$|T\d{2}:\d{2})/.test(time)) {
|
||||||
|
day = time.slice(0, 10);
|
||||||
|
}
|
||||||
|
if (day && day >= today) indices.push(index);
|
||||||
|
});
|
||||||
|
var filtered = {};
|
||||||
|
Object.keys(daily).forEach(function (field) {
|
||||||
|
filtered[field] = Array.isArray(daily[field]) ? indices.map(function (index) {
|
||||||
|
return daily[field][index] === undefined ? null : daily[field][index];
|
||||||
|
}) : daily[field];
|
||||||
|
});
|
||||||
|
return Object.assign({}, source, { daily: filtered });
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterForecastSnapshot(snapshot, timeZone, now) {
|
||||||
|
var timestamp = now === undefined ? Date.now() : Number(now);
|
||||||
|
return filterDailySnapshot(filterHourlySnapshot(snapshot, timeZone, timestamp), timeZone, timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { currentHour: currentHour, filterHourlySnapshot: filterHourlySnapshot, filterDailySnapshot: filterDailySnapshot, filterForecastSnapshot: filterForecastSnapshot };
|
||||||
|
}));
|
||||||
@@ -1030,10 +1030,20 @@ import { createSlideAnimationEditor } from '/assets/js/slides/slide-animation-ed
|
|||||||
previewResizeObserver.observe(slidePreviewStage);
|
previewResizeObserver.observe(slidePreviewStage);
|
||||||
}
|
}
|
||||||
requestSidebarSync();
|
requestSidebarSync();
|
||||||
|
var weatherPreviewTimer;
|
||||||
|
function scheduleWeatherPreviewUpdate() {
|
||||||
|
weatherPreviewTimer = window.setTimeout(function () {
|
||||||
|
var template = getTemplateById(templateSelect.value);
|
||||||
|
if (template && template.regions.some(function (region) { return region.region_type === 'weather'; })) requestPreviewRender();
|
||||||
|
scheduleWeatherPreviewUpdate();
|
||||||
|
}, Math.max(1, 60000 - Date.now() % 60000));
|
||||||
|
}
|
||||||
|
scheduleWeatherPreviewUpdate();
|
||||||
window.addEventListener('scroll', requestSidebarSync, { passive: true });
|
window.addEventListener('scroll', requestSidebarSync, { passive: true });
|
||||||
window.addEventListener('resize', requestPreviewRender);
|
window.addEventListener('resize', requestPreviewRender);
|
||||||
window.addEventListener('resize', requestSidebarSync);
|
window.addEventListener('resize', requestSidebarSync);
|
||||||
window.addEventListener('beforeunload', function () {
|
window.addEventListener('beforeunload', function () {
|
||||||
|
window.clearTimeout(weatherPreviewTimer);
|
||||||
if (regionMediaController) {
|
if (regionMediaController) {
|
||||||
regionMediaController.queueUploadCleanup(regionMediaController.getPendingUploadCleanupPaths());
|
regionMediaController.queueUploadCleanup(regionMediaController.getPendingUploadCleanupPaths());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,124 @@ const vm = require('node:vm');
|
|||||||
|
|
||||||
const { convertWeatherSnapshot, normalizeOpenMeteoSnapshot, normalizePirateWeatherSnapshot } = require('../src/data/weather-units');
|
const { convertWeatherSnapshot, normalizeOpenMeteoSnapshot, normalizePirateWeatherSnapshot } = require('../src/data/weather-units');
|
||||||
const placeholderUtils = require('../src/web/public/js/shared/placeholder-utils');
|
const placeholderUtils = require('../src/web/public/js/shared/placeholder-utils');
|
||||||
|
const weatherData = require('../src/web/public/js/shared/weather-data');
|
||||||
|
|
||||||
|
test('hourly weather advances at the local hour boundary without refetching', () => {
|
||||||
|
const snapshot = { hourly: { time: ['2026-10-23T02:00', '2026-10-23T03:00', '2026-10-23T04:00'], temperature_2m: [12, 13, 14], weather_code: [61, 3, 0], temperature_unit: '\u00b0C' } };
|
||||||
|
const before = weatherData.filterHourlySnapshot(snapshot, 'Europe/London', Date.parse('2026-10-23T01:59:59.999Z'));
|
||||||
|
assert.deepEqual(before.hourly.temperature_2m, [12, 13, 14]);
|
||||||
|
const after = weatherData.filterHourlySnapshot(snapshot, 'Europe/London', Date.parse('2026-10-23T02:00:00Z'));
|
||||||
|
assert.deepEqual(after.hourly.time, ['2026-10-23T03:00', '2026-10-23T04:00']);
|
||||||
|
assert.deepEqual(after.hourly.temperature_2m, [13, 14]);
|
||||||
|
assert.deepEqual(after.hourly.weather_code, [3, 0]);
|
||||||
|
assert.equal(after.hourly.temperature_unit, '\u00b0C');
|
||||||
|
assert.equal(snapshot.hourly.time.length, 3);
|
||||||
|
assert.deepEqual(weatherData.filterHourlySnapshot(snapshot, 'Europe/London', Date.parse('2026-10-24T00:00Z')).hourly.time, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hourly weather handles UTC provider timestamps and fractional-offset locations', () => {
|
||||||
|
const snapshot = { hourly: { time: ['2026-10-23T02:00:00.000Z', '2026-10-23T03:00:00.000Z'], temperature_2m: [12, 13] } };
|
||||||
|
assert.deepEqual(weatherData.filterHourlySnapshot(snapshot, 'Asia/Kathmandu', Date.parse('2026-10-23T03:00Z')).hourly.temperature_2m, [13]);
|
||||||
|
const local = { hourly: { time: ['2026-10-23T02:00', '2026-10-23T03:00'], temperature_2m: [12, 13] } };
|
||||||
|
assert.deepEqual(weatherData.filterHourlySnapshot(local, 'Asia/Kathmandu', Date.parse('2026-10-22T21:15Z')).hourly.temperature_2m, [13]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('daily weather advances at local midnight and keeps forecast columns aligned', () => {
|
||||||
|
const snapshot = { daily: { time: ['2026-10-23', '2026-10-24', '2026-10-25'], temperature_2m_max: [12, 13, 14], weather_code: [61, 3, 0], temperature_unit: '\u00b0C' } };
|
||||||
|
const before = weatherData.filterDailySnapshot(snapshot, 'Europe/London', Date.parse('2026-10-23T22:59:59.999Z'));
|
||||||
|
assert.deepEqual(before.daily.temperature_2m_max, [12, 13, 14]);
|
||||||
|
const after = weatherData.filterDailySnapshot(snapshot, 'Europe/London', Date.parse('2026-10-23T23:00:00Z'));
|
||||||
|
assert.deepEqual(after.daily.time, ['2026-10-24', '2026-10-25']);
|
||||||
|
assert.deepEqual(after.daily.temperature_2m_max, [13, 14]);
|
||||||
|
assert.deepEqual(after.daily.weather_code, [3, 0]);
|
||||||
|
assert.equal(after.daily.temperature_unit, '\u00b0C');
|
||||||
|
assert.equal(snapshot.daily.time.length, 3);
|
||||||
|
assert.deepEqual(weatherData.filterDailySnapshot(snapshot, 'Europe/London', Date.parse('2026-10-26T00:00Z')).daily.time, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('daily weather interprets offset timestamps in the location timezone', () => {
|
||||||
|
const snapshot = { daily: { time: ['2026-10-23T07:00:00Z', '2026-10-24T07:00:00Z'], temperature_2m_max: [12, 13] } };
|
||||||
|
assert.deepEqual(weatherData.filterDailySnapshot(snapshot, 'America/Los_Angeles', Date.parse('2026-10-24T06:59:59Z')).daily.temperature_2m_max, [12, 13]);
|
||||||
|
assert.deepEqual(weatherData.filterDailySnapshot(snapshot, 'America/Los_Angeles', Date.parse('2026-10-24T07:00Z')).daily.temperature_2m_max, [13]);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const mode of ['hourly', 'daily']) test('weather previews and held player regions roll ' + mode + ' placeholders forward and clean up timers', () => {
|
||||||
|
let now = Date.parse(mode === 'hourly' ? '2026-10-23T01:59:59.999Z' : '2026-10-23T22:59:59.999Z');
|
||||||
|
const times = mode === 'hourly' ? ['2026-10-23T02:00', '2026-10-23T03:00'] : ['2026-10-23', '2026-10-24'];
|
||||||
|
const field = mode === 'hourly' ? 'temperature_2m' : 'temperature_2m_max';
|
||||||
|
const alias = mode === 'hourly' ? 'temp' : 'temp_max';
|
||||||
|
const location = { id: 1, timezone: 'Europe/London', responseJson: { [mode]: { time: times, [field]: [12, 13], weather_code: [61, 3] } } };
|
||||||
|
const content = { weather_location_id: 1, value: '<p>{{' + mode + '.0.time}}:{{' + mode + '.0.' + alias + '}}</p>{{' + mode + '.0.icon}}' };
|
||||||
|
const region = { pixelWidth: 600, pixelHeight: 300, baseStyle: '' };
|
||||||
|
const timers = new Map();
|
||||||
|
let timerId = 0;
|
||||||
|
class Clock extends Date { static now() { return now; } }
|
||||||
|
const renderers = {};
|
||||||
|
for (const target of ['editor', 'player']) {
|
||||||
|
const sandbox = {
|
||||||
|
Date: Clock,
|
||||||
|
document: { addEventListener() {} },
|
||||||
|
window: {
|
||||||
|
placeholderUtils,
|
||||||
|
pulseWeatherData: { filterForecastSnapshot(snapshot, timeZone) { return weatherData.filterForecastSnapshot(snapshot, timeZone, now); } },
|
||||||
|
pulseRegionTypes: { register(_type, module) { renderers.editor = module; } },
|
||||||
|
pulsePlayerRegionTypes: { register(_type, module) { renderers.player = module; } },
|
||||||
|
setTimeout(callback, delay) { timers.set(++timerId, { callback, delay }); return timerId; },
|
||||||
|
clearTimeout(id) { timers.delete(id); }
|
||||||
|
},
|
||||||
|
initialData: { weatherLocations: [location] },
|
||||||
|
escapeHtml: value => String(value), renderEditorJsContent: value => value
|
||||||
|
};
|
||||||
|
const script = target === 'editor' ? '../src/web/public/js/regions/type/weather.js' : '../src/player/regions/weather.js';
|
||||||
|
vm.runInNewContext(fs.readFileSync(require.resolve(script), 'utf8'), sandbox);
|
||||||
|
}
|
||||||
|
const before = '<p>' + times[0] + ':12</p>';
|
||||||
|
const after = '<p>' + times[1] + ':13</p>';
|
||||||
|
assert.ok(renderers.editor.renderPreview(region, content, { weatherLocations: [location] }).includes(before));
|
||||||
|
const playerHtml = renderers.player.renderRegion(region, content);
|
||||||
|
assert.ok(playerHtml.includes(before));
|
||||||
|
assert.match(playerHtml, /data-weather-content=/);
|
||||||
|
const output = { innerHTML: '' };
|
||||||
|
const element = { isConnected: true, getAttribute() { return JSON.stringify(content); }, querySelector() { return output; } };
|
||||||
|
const root = { querySelectorAll() { return [element]; } };
|
||||||
|
renderers.player.initRegion(root);
|
||||||
|
assert.ok(output.innerHTML.includes(before));
|
||||||
|
assert.equal(timers.get(element.__weatherTimer).delay, 1);
|
||||||
|
const timer = timers.get(element.__weatherTimer);
|
||||||
|
timers.delete(element.__weatherTimer);
|
||||||
|
now += 1;
|
||||||
|
timer.callback();
|
||||||
|
assert.ok(output.innerHTML.includes(after));
|
||||||
|
assert.ok(!output.innerHTML.includes(before));
|
||||||
|
assert.ok(renderers.editor.renderPreview(region, content, { weatherLocations: [location] }).includes(after));
|
||||||
|
renderers.player.destroyRegion(root);
|
||||||
|
assert.equal(timers.size, 0);
|
||||||
|
now = Date.parse('2026-10-26T00:00Z');
|
||||||
|
const expired = renderers.editor.renderPreview(region, content, { weatherLocations: [location] });
|
||||||
|
assert.doesNotMatch(expired, /bi-cloud|bi-sun|bi-lightning/);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const mode of ['hourly', 'daily']) test('weather thumbnails filter cached ' + mode + ' fields at capture time', () => {
|
||||||
|
const { buildThumbnailPreviewPayload } = require('../src/web/lib/media/slide-thumbnails');
|
||||||
|
const originalDateNow = Date.now;
|
||||||
|
Date.now = () => Date.parse(mode === 'hourly' ? '2026-10-23T02:00Z' : '2026-10-23T23:00Z');
|
||||||
|
try {
|
||||||
|
const times = mode === 'hourly' ? ['2026-10-23T02:00', '2026-10-23T03:00'] : ['2026-10-23', '2026-10-24'];
|
||||||
|
const field = mode === 'hourly' ? 'temperature_2m' : 'temperature_2m_max';
|
||||||
|
const alias = mode === 'hourly' ? 'temp' : 'temp_max';
|
||||||
|
const slide = {
|
||||||
|
template: { regions: [{ region_key: 'weather', region_type: 'weather', width: 600, height: 300 }] },
|
||||||
|
content: { weather: { type: 'weather', value: '<p>{{' + mode + '.0.time}}:{{' + mode + '.0.' + alias + '}}</p>' } }
|
||||||
|
};
|
||||||
|
const payload = buildThumbnailPreviewPayload(slide, { getItem() {
|
||||||
|
return { timezone: 'Europe/London', [mode]: { time: times, [field]: [12, 13] } };
|
||||||
|
} });
|
||||||
|
assert.ok(payload.html.includes(times[1] + ':13'));
|
||||||
|
assert.ok(!payload.html.includes(times[0] + ':12'));
|
||||||
|
} finally {
|
||||||
|
Date.now = originalDateNow;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('Pirate Weather snapshots normalize into shared weather fields and selected units', () => {
|
test('Pirate Weather snapshots normalize into shared weather fields and selected units', () => {
|
||||||
const time = Date.parse('2026-10-23T12:00:00Z') / 1000;
|
const time = Date.parse('2026-10-23T12:00:00Z') / 1000;
|
||||||
|
|||||||
Reference in new issue
Block a user