From bc8075379ab875726055d42e3876a1d97a45cb1b Mon Sep 17 00:00:00 2001 From: Mark Rapson Date: Fri, 23 Oct 2026 02:53:02 +0100 Subject: [PATCH] Release v2.17.3: roll weather forecasts forward at hour and day boundaries --- CHANGELOG.md | 7 ++ build/package.player.json | 2 +- build/package.web.json | 2 +- package-lock.json | 4 +- package.json | 2 +- src/player/regions/weather.js | 39 ++++++- src/player/render.js | 4 +- src/web/lib/media/slide-thumbnails.js | 6 +- src/web/lib/region-scripts.js | 2 +- src/web/public/js/regions/type/weather.js | 2 + src/web/public/js/shared/weather-data.js | 75 ++++++++++++++ src/web/public/js/slides/slide-form.js | 10 ++ test/weather-units.test.js | 118 ++++++++++++++++++++++ 13 files changed, 263 insertions(+), 10 deletions(-) create mode 100644 src/web/public/js/shared/weather-data.js diff --git a/CHANGELOG.md b/CHANGELOG.md index ae0e402..5951ee6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ 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 ### Added diff --git a/build/package.player.json b/build/package.player.json index 51e35b6..60f836e 100644 --- a/build/package.player.json +++ b/build/package.player.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-player", - "version": "2.17.2", + "version": "2.17.3", "private": false, "description": "Pulse Signage player application bundle", "engines": { diff --git a/build/package.web.json b/build/package.web.json index 41d896f..0270d03 100644 --- a/build/package.web.json +++ b/build/package.web.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-web", - "version": "2.17.2", + "version": "2.17.3", "private": false, "description": "Pulse Signage web and bridge application bundle", "engines": { diff --git a/package-lock.json b/package-lock.json index 6388299..180c914 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pulse-signage", - "version": "2.17.2", + "version": "2.17.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pulse-signage", - "version": "2.17.2", + "version": "2.17.3", "dependencies": { "animate.css": "^4.1.1", "bootstrap-icons": "1.13.1", diff --git a/package.json b/package.json index e76c661..8cf7455 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "2.17.2", + "version": "2.17.3", "private": false, "description": "Pulse Signage application with MySQL and media storage", "engines": { diff --git a/src/player/regions/weather.js b/src/player/regions/weather.js index 2e5012a..9cf2dbf 100644 --- a/src/player/regions/weather.js +++ b/src/player/regions/weather.js @@ -36,6 +36,7 @@ function normalizeWeatherExpression(expression) { } 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 temperatureUnit = location.temperature_unit === 'fahrenheit' ? '°F' : '°C'; 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 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 }); + if (weatherCode === null || weatherCode === undefined || weatherCode === '') return ''; 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); var icon = ''; @@ -86,7 +88,8 @@ function renderWeatherRegion(region, regionContent) { if (value) { var weatherData = withWeatherUnits(snapshot, location); var fontSize = Math.max(8, Number(regionContent && regionContent.font_size || region && (region.font_size || region.fontSize) || 32) || 32); - return '
' + renderEditorJsContent(substituteWeatherVariables(value, weatherData, regionContent.weather_icon_set_id)) + '
'; + var liveContent = /\{\{\s*(?:hourly|daily)\./.test(value) ? ' data-weather-content="' + escapeHtml(JSON.stringify(regionContent)) + '"' : ''; + return '
' + renderEditorJsContent(substituteWeatherVariables(value, weatherData, regionContent.weather_icon_set_id)) + '
'; } var daily = snapshot.daily || {}; var days = (daily.time || []).slice(0, 7).map(function (day, index) { @@ -95,4 +98,36 @@ function renderWeatherRegion(region, regionContent) { return '
' + escapeHtml(location.location_label || location.name) + '
' + escapeHtml(current.temperature_2m === undefined ? '-' : current.temperature_2m) + '°
' + days + '
'; } -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 }); diff --git a/src/player/render.js b/src/player/render.js index e836305..abecacc 100644 --- a/src/player/render.js +++ b/src/player/render.js @@ -103,7 +103,9 @@ function renderOnboardingFormBody(deviceId) { function renderPlayerPage(slug, initialData) { - const serviceWorkerScript = '' + getPlayerServiceWorkerRegistrationScript(); + const serviceWorkerScript = ['weather-icons.js', 'weather-data.js'].map(function (filename) { + return ''; + }).join('') + getPlayerServiceWorkerRegistrationScript(); const template = getPlayerPageTemplate(); const hlsScriptTag = ''; const chartScriptTag = ''; diff --git a/src/web/lib/media/slide-thumbnails.js b/src/web/lib/media/slide-thumbnails.js index b33efe8..dcffae7 100644 --- a/src/web/lib/media/slide-thumbnails.js +++ b/src/web/lib/media/slide-thumbnails.js @@ -17,6 +17,7 @@ const { createRequestAuthHeaders, createPageAuthToken } = require('#src/request- const { convertWeatherSnapshot } = require('#src/data/weather-units'); 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')); @@ -195,8 +196,9 @@ function weatherIconForCode(code) { function substitutePlaceholders(html, regionContent, options) { const source = String(html || ''); 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 (type === 'weather') item = weatherData.filterForecastSnapshot(item, item.timezone); return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/gi, function (_match, expression) { const weatherExpression = type === 'weather' ? normalizeWeatherExpression(expression) : expression; 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)) { 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 width = iconSize ? Number(iconSize[1]) : 0; const height = iconSize && iconSize[2] ? Number(iconSize[2]) : width; diff --git a/src/web/lib/region-scripts.js b/src/web/lib/region-scripts.js index 5744414..9ab46d8 100644 --- a/src/web/lib/region-scripts.js +++ b/src/web/lib/region-scripts.js @@ -6,7 +6,7 @@ const path = require('path'); const PUBLIC_JS_ROOT = path.join(__dirname, '..', 'public', 'js'); const REGION_ROOT_DIR = path.join(PUBLIC_JS_ROOT, 'regions'); 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) { if (!assetVersion) { diff --git a/src/web/public/js/regions/type/weather.js b/src/web/public/js/regions/type/weather.js index 6565f8d..87f4074 100644 --- a/src/web/public/js/regions/type/weather.js +++ b/src/web/public/js/regions/type/weather.js @@ -99,6 +99,7 @@ } 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 temperatureUnit = location.temperature_unit === 'fahrenheit' ? '°F' : '°C'; 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 sizeExpression = rawExpression.match(/\.icon\((\d+)(?:\s*,\s*(\d+))?(?:\s*,\s*(top|middle|bottom))?\)$/); 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 height = sizeExpression && sizeExpression[2] ? Math.max(1, Math.min(1000, Number(sizeExpression[2]))) : width; var alignment = sizeExpression && sizeExpression[3] || 'middle'; diff --git a/src/web/public/js/shared/weather-data.js b/src/web/public/js/shared/weather-data.js new file mode 100644 index 0000000..426cc97 --- /dev/null +++ b/src/web/public/js/shared/weather-data.js @@ -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 }; +})); \ No newline at end of file diff --git a/src/web/public/js/slides/slide-form.js b/src/web/public/js/slides/slide-form.js index 5dc7445..6568578 100644 --- a/src/web/public/js/slides/slide-form.js +++ b/src/web/public/js/slides/slide-form.js @@ -1030,10 +1030,20 @@ import { createSlideAnimationEditor } from '/assets/js/slides/slide-animation-ed previewResizeObserver.observe(slidePreviewStage); } 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('resize', requestPreviewRender); window.addEventListener('resize', requestSidebarSync); window.addEventListener('beforeunload', function () { + window.clearTimeout(weatherPreviewTimer); if (regionMediaController) { regionMediaController.queueUploadCleanup(regionMediaController.getPendingUploadCleanupPaths()); } diff --git a/test/weather-units.test.js b/test/weather-units.test.js index f5d2bf0..9f8cda2 100644 --- a/test/weather-units.test.js +++ b/test/weather-units.test.js @@ -5,6 +5,124 @@ const vm = require('node:vm'); const { convertWeatherSnapshot, normalizeOpenMeteoSnapshot, normalizePirateWeatherSnapshot } = require('../src/data/weather-units'); 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: '

{{' + mode + '.0.time}}:{{' + mode + '.0.' + alias + '}}

{{' + 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 = '

' + times[0] + ':12

'; + const after = '

' + times[1] + ':13

'; + 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: '

{{' + mode + '.0.time}}:{{' + mode + '.0.' + alias + '}}

' } } + }; + 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', () => { const time = Date.parse('2026-10-23T12:00:00Z') / 1000;