diff --git a/.changeset/hidden-tab-polls.md b/.changeset/hidden-tab-polls.md new file mode 100644 index 00000000..1ca1662d --- /dev/null +++ b/.changeset/hidden-tab-polls.md @@ -0,0 +1,7 @@ +--- +"ftw": patch +--- + +Pause plan, heating, settings, and dashboard card polls while the tab is +hidden, and stop Settings EV/System timers when the modal closes. Returning +to the tab refreshes once, then resumes a single timer. diff --git a/web/components/ftw-energy-flow.js b/web/components/ftw-energy-flow.js index 3337f5e6..000ac263 100644 --- a/web/components/ftw-energy-flow.js +++ b/web/components/ftw-energy-flow.js @@ -501,6 +501,13 @@ class FtwEnergyFlow extends FtwElement { this._particles = []; this._bound = []; this._snapshot = null; + this._onVisibility = () => { + if (document.hidden) this._stopParticleLoop(); + else this._startParticleLoop(); + }; + if (typeof document !== "undefined" && document.addEventListener) { + document.addEventListener("visibilitychange", this._onVisibility); + } // Anchored once at construction so `t = now - tickStart` is on the // same timeline for the entire component lifetime. Resetting it // each afterRender would make restored bornAt values (from the @@ -539,8 +546,11 @@ class FtwEnergyFlow extends FtwElement { } disconnectedCallback() { - if (this._rafId) cancelAnimationFrame(this._rafId); - this._rafId = null; + this._stopParticleLoop(); + if (this._onVisibility && typeof document !== "undefined") { + document.removeEventListener("visibilitychange", this._onVisibility); + this._onVisibility = null; + } this._particles = []; if (this._resizeRaf) { cancelAnimationFrame(this._resizeRaf); @@ -703,10 +713,7 @@ class FtwEnergyFlow extends FtwElement { // every particle — cheaper than SMIL when you have hundreds of them, // and gives us per-frame noise terms SMIL can't express. afterRender() { - if (this._rafId) { - cancelAnimationFrame(this._rafId); - this._rafId = null; - } + this._stopParticleLoop(); // Aggregation toggle — flipping the aria-checked attribute and // the svg's data-agg triggers the CSS opacity transition between // layers. Intentionally NOT calling this.update() here: a full @@ -752,9 +759,15 @@ class FtwEnergyFlow extends FtwElement { } // Static means "not now": a cached view must hold still, because a // moving particle is a claim that power is flowing at this moment. - if (this.hasAttribute("static")) return; + if (this.hasAttribute("static")) { + this._bound = []; + return; + } const nodes = this.shadowRoot.querySelectorAll('.ef-p'); - if (!nodes.length || !this._particles.length) return; + if (!nodes.length || !this._particles.length) { + this._bound = []; + return; + } // Wire each DOM node to its param slot. `render()` assigned indices // via `data-i`; we trust those rather than node order in case the // browser reorders subtree attribute-only nodes in the future. @@ -790,48 +803,65 @@ class FtwEnergyFlow extends FtwElement { this._snapshot = null; } this._bound = bound; - const tick = (now) => { - const t = (now - this._tickStart) / 1000; - for (let k = 0; k < bound.length; k++) { - const b = bound[k]; - const p = b.p; - let age = t - p.bornAt; - if (age >= p.life || p.life === 0) { - rollLife(p, t); - // First-ever spawn: backdate bornAt uniformly across the - // pool's lifetime so particles are spread evenly instead of - // bursting together. p._warmUpIdx is in (0, 1), so this - // seeds the fountain with a steady state. - if (p._warmUp) { - p.bornAt = t - p._warmUpIdx * p.life; - p._warmUp = false; - } - age = t - p.bornAt; + this._startParticleLoop(); + } + + _stopParticleLoop() { + if (this._rafId) { + cancelAnimationFrame(this._rafId); + this._rafId = null; + } + } + + _startParticleLoop() { + if (this._rafId || document.hidden || this.hasAttribute("static") || !this._bound || !this._bound.length) return; + this._rafId = requestAnimationFrame((now) => this._pumpParticles(now)); + } + + _pumpParticles(now) { + this._rafId = null; + if (document.hidden) return; + const bound = this._bound; + if (!bound || !bound.length) return; + const t = (now - this._tickStart) / 1000; + for (let k = 0; k < bound.length; k++) { + const b = bound[k]; + const p = b.p; + let age = t - p.bornAt; + if (age >= p.life || p.life === 0) { + rollLife(p, t); + // First-ever spawn: backdate bornAt uniformly across the + // pool's lifetime so particles are spread evenly instead of + // bursting together. p._warmUpIdx is in (0, 1), so this + // seeds the fountain with a steady state. + if (p._warmUp) { + p.bornAt = t - p._warmUpIdx * p.life; + p._warmUp = false; } - // Along-path progress: linear travel from spawn toward target. - // No easing — real electrons don't decelerate. - const along = p.vx * age; // along-vector component - const alongY = p.vy * age; - // Perpendicular offset: damped harmonic oscillator. This is - // the "gravity circling the beam" effect — a spring pulls the - // particle toward the beam centerline with angular frequency - // omega, while γ damps amplitude over time so particles - // spiral IN as they approach the target. - // perp(t) = A * e^(−γt) * cos(ωt + φ) - const envelope = Math.exp(-p.damp * age); - const wave = Math.cos(p.omega * age + p.phase); - const perp = p.amp * envelope * wave; - const x = p.sx + along + p.perpX * perp; - const y = p.sy + alongY + p.perpY * perp; - // Opacity is fixed — set at render time, never touched here. - // Size variance (per-particle `radius`) replaces the old - // opacity pulse as the "texture" cue. - b.el.setAttribute('cx', x.toFixed(1)); - b.el.setAttribute('cy', y.toFixed(1)); + age = t - p.bornAt; } - this._rafId = requestAnimationFrame(tick); - }; - this._rafId = requestAnimationFrame(tick); + // Along-path progress: linear travel from spawn toward target. + // No easing — real electrons don't decelerate. + const along = p.vx * age; // along-vector component + const alongY = p.vy * age; + // Perpendicular offset: damped harmonic oscillator. This is + // the "gravity circling the beam" effect — a spring pulls the + // particle toward the beam centerline with angular frequency + // omega, while γ damps amplitude over time so particles + // spiral IN as they approach the target. + // perp(t) = A * e^(−γt) * cos(ωt + φ) + const envelope = Math.exp(-p.damp * age); + const wave = Math.cos(p.omega * age + p.phase); + const perp = p.amp * envelope * wave; + const x = p.sx + along + p.perpX * perp; + const y = p.sy + alongY + p.perpY * perp; + // Opacity is fixed — set at render time, never touched here. + // Size variance (per-particle `radius`) replaces the old + // opacity pulse as the "texture" cue. + b.el.setAttribute('cx', x.toFixed(1)); + b.el.setAttribute('cy', y.toFixed(1)); + } + this._rafId = requestAnimationFrame((ts) => this._pumpParticles(ts)); } render() { diff --git a/web/components/ftw-history-card.js b/web/components/ftw-history-card.js index ed659092..2cb46d35 100644 --- a/web/components/ftw-history-card.js +++ b/web/components/ftw-history-card.js @@ -226,11 +226,18 @@ class FtwHistoryCard extends FtwElement { connectedCallback() { super.connectedCallback(); - this._refresh(); - this._restartPolling(); + if (!this._onVisibility) { + this._onVisibility = () => this._syncPolling(); + document.addEventListener("visibilitychange", this._onVisibility); + } + this._syncPolling(); } disconnectedCallback() { if (this._timer) { clearInterval(this._timer); this._timer = null; } + if (this._onVisibility) { + document.removeEventListener("visibilitychange", this._onVisibility); + this._onVisibility = null; + } } attributeChangedCallback(name) { @@ -248,21 +255,20 @@ class FtwHistoryCard extends FtwElement { } this.update(); if (name === "metric" || name === "poll-ms") { - this._refresh(); - this._restartPolling(); + this._syncPolling(); } if (rangeChanged) this._refresh(); } - _restartPolling() { + _syncPolling() { if (this._timer) { clearInterval(this._timer); this._timer = null; } + if (!this.isConnected || document.hidden) return; + this._refresh(); // `??` not `||`: poll-ms="0" must disable polling, but "0" is truthy // in the ||-fallback so that path silently reverts to 300000. const raw = this.getAttribute("poll-ms"); const ms = Number(raw ?? 300000); - if (ms > 0 && this.isConnected) { - this._timer = setInterval(() => this._refresh(), ms); - } + if (ms > 0) this._timer = setInterval(() => this._refresh(), ms); } _accent() { diff --git a/web/components/ftw-price-chart.js b/web/components/ftw-price-chart.js index 4719b7de..2042b2fe 100644 --- a/web/components/ftw-price-chart.js +++ b/web/components/ftw-price-chart.js @@ -458,8 +458,11 @@ class FtwPriceChart extends FtwElement { super.connectedCallback(); if (!this.hasAttribute("fed")) { this._loadConfig(); - this._loadPrices(); - this._refreshTimer = setInterval(() => this._loadPrices(), 5 * 60 * 1000); + if (!this._onVisibility) { + this._onVisibility = () => this._syncPolling(); + document.addEventListener("visibilitychange", this._onVisibility); + } + this._syncPolling(); } // Re-render when the viewport crosses the small-screen breakpoint // — render() picks a different viewBox H per side, so a rotation @@ -479,7 +482,21 @@ class FtwPriceChart extends FtwElement { window.addEventListener("ftw-price-mode-change", this._modeSyncListener); } + _syncPolling() { + if (this._refreshTimer) { + clearInterval(this._refreshTimer); + this._refreshTimer = null; + } + if (this.hasAttribute("fed") || !this.isConnected || document.hidden) return; + this._loadPrices(); + this._refreshTimer = setInterval(() => this._loadPrices(), 5 * 60 * 1000); + } + disconnectedCallback() { + if (this._onVisibility) { + document.removeEventListener("visibilitychange", this._onVisibility); + this._onVisibility = null; + } if (this._refreshTimer) { clearInterval(this._refreshTimer); this._refreshTimer = null; diff --git a/web/components/ftw-savings-card.js b/web/components/ftw-savings-card.js index fa7f8b6e..31aa47f2 100644 --- a/web/components/ftw-savings-card.js +++ b/web/components/ftw-savings-card.js @@ -357,12 +357,19 @@ class FtwSavingsCard extends FtwElement { connectedCallback() { super.connectedCallback(); - this._refresh(); - this._restartPolling(); + if (!this._onVisibility) { + this._onVisibility = () => this._syncPolling(); + document.addEventListener("visibilitychange", this._onVisibility); + } + this._syncPolling(); } disconnectedCallback() { if (this._timer) { clearInterval(this._timer); this._timer = null; } if (this._abort) { this._abort.abort(); this._abort = null; } + if (this._onVisibility) { + document.removeEventListener("visibilitychange", this._onVisibility); + this._onVisibility = null; + } } attributeChangedCallback(name) { @@ -376,19 +383,18 @@ class FtwSavingsCard extends FtwElement { } this.update(); if (name === "poll-ms") { - this._refresh(); - this._restartPolling(); + this._syncPolling(); } if (rangeChanged) this._refresh(); } - _restartPolling() { + _syncPolling() { if (this._timer) { clearInterval(this._timer); this._timer = null; } + if (!this.isConnected || document.hidden) return; + this._refresh(); const raw = this.getAttribute("poll-ms"); const ms = Number(raw ?? 300000); - if (ms > 0 && this.isConnected) { - this._timer = setInterval(() => this._refresh(), ms); - } + if (ms > 0) this._timer = setInterval(() => this._refresh(), ms); } _daysFor(range) { diff --git a/web/heating-control.test.mjs b/web/heating-control.test.mjs index ce9cac47..f58a967f 100644 --- a/web/heating-control.test.mjs +++ b/web/heating-control.test.mjs @@ -248,7 +248,7 @@ test('the operator sees the result of a press even mid-refresh', () => { // A refresh requested during a long cycle must run after that cycle settles. assert.match(source, /function refreshAfterControl\(\)/); assert.match(source, /if \(refreshInFlight\) \{[\s\S]{0,120}refreshQueued = true;[\s\S]{0,120}refreshWaiters\.push/); - assert.match(source, /if \(refreshQueued\) \{[\s\S]{0,200}refreshQueued = false;[\s\S]{0,200}refresh\(\)/); + assert.match(source, /if \(refreshQueued\) \{[\s\S]{0,400}refreshQueued = false;[\s\S]{0,400}refresh\(\)/); }); test('stepper buttons rather than an input that a re-render would clear', () => { diff --git a/web/heating.js b/web/heating.js index e87ebdf5..a591a5d1 100644 --- a/web/heating.js +++ b/web/heating.js @@ -719,6 +719,13 @@ refreshQueued = false; var waiters = refreshWaiters; refreshWaiters = []; + // A timer tick that overlapped a live refresh must not catch up + // after the tab has gone hidden; visibilitychange starts the next + // poll when the document is shown again. + if (document.hidden) { + waiters.forEach(function (waiter) { waiter(); }); + return; + } refresh().then(function () { waiters.forEach(function (waiter) { waiter(); }); }); @@ -908,8 +915,30 @@ if (card && card.dataset.hpDriver) openDetail(card.dataset.hpDriver); } + var started = false; + + function pollHeating() { + if (document.hidden) return; + refresh(); + } + + function syncHeatingPolling() { + if (document.hidden) { + if (timer !== null) { + clearInterval(timer); + timer = null; + } + return; + } + pollHeating(); + if (timer === null) { + timer = setInterval(pollHeating, REFRESH_MS); + } + } + function start() { - if (timer) return; + if (started) return; + started = true; var grid = document.getElementById('heating-grid'); if (grid) { grid.addEventListener('click', onGridClick); @@ -919,8 +948,8 @@ } }); } - refresh(); - timer = setInterval(refresh, REFRESH_MS); + document.addEventListener('visibilitychange', syncHeatingPolling); + syncHeatingPolling(); } if (document.readyState === 'loading') { diff --git a/web/hidden-tab-polls.test.mjs b/web/hidden-tab-polls.test.mjs new file mode 100644 index 00000000..7fd2aec9 --- /dev/null +++ b/web/hidden-tab-polls.test.mjs @@ -0,0 +1,288 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +import vm from "node:vm"; + +const heatingSource = readFileSync(new URL("./heating.js", import.meta.url), "utf8"); +const evSource = readFileSync(new URL("./settings/tabs/ev.js", import.meta.url), "utf8"); +const readWeb = (name) => readFileSync(new URL(name, import.meta.url), "utf8"); + +function inertElement() { + return { + hidden: false, + innerHTML: "", + classList: { add() {}, remove() {}, contains() { return false; } }, + addEventListener() {}, + querySelector() { return null; }, + querySelectorAll() { return []; }, + }; +} + +function heatingBody(path) { + if (path === "/api/drivers") return { pump: { status: "ok" } }; + if (path === "/api/drivers/pump") { + return { metrics: [{ name: "hp_power_w", value: 800 }] }; + } + return { points: [] }; +} + +function rigHeating({ hidden = false } = {}) { + const documentListeners = new Map(); + const intervals = new Map(); + const fetches = []; + let nextTimer = 1; + const section = inertElement(); + const grid = inertElement(); + const document = { + hidden, + readyState: "complete", + head: { appendChild() {} }, + body: inertElement(), + getElementById(id) { + if (id === "heating-section") return section; + if (id === "heating-grid") return grid; + return null; + }, + createElement() { return inertElement(); }, + addEventListener(type, fn) { documentListeners.set(type, fn); }, + }; + const sandbox = { + window: {}, + document, + fetch(path) { + const entry = { path: String(path), settled: false }; + entry.promise = new Promise((resolve, reject) => { + entry.resolve = resolve; + entry.reject = reject; + }); + fetches.push(entry); + return entry.promise; + }, + setInterval(fn, ms) { + const id = nextTimer++; + intervals.set(id, { fn, ms }); + return id; + }, + clearInterval(id) { intervals.delete(id); }, + console, Date, Math, JSON, Promise, Object, Array, encodeURIComponent, + }; + sandbox.window.document = document; + vm.createContext(sandbox); + vm.runInContext(heatingSource, sandbox); + + async function settleFetches() { + for (let round = 0; round < 20; round++) { + const pending = fetches.filter((entry) => !entry.settled); + if (pending.length === 0) { + await new Promise((resolve) => setImmediate(resolve)); + if (fetches.every((entry) => entry.settled)) return; + continue; + } + for (const entry of pending) { + entry.settled = true; + const body = heatingBody(entry.path); + entry.resolve({ ok: true, json() { return Promise.resolve(body); } }); + } + await new Promise((resolve) => setImmediate(resolve)); + } + } + + return { + fetches, + liveFetches() { return fetches.filter((entry) => entry.path === "/api/drivers/pump"); }, + pollTimers() { return [...intervals.values()].filter((timer) => timer.ms === 30_000); }, + setHidden(next) { + document.hidden = next; + documentListeners.get("visibilitychange")(); + }, + runPollTimers() { + for (const timer of [...intervals.values()]) { + if (timer.ms === 30_000) timer.fn(); + } + }, + settleFetches, + }; +} + +function rigEv({ hidden = false } = {}) { + const documentListeners = new Map(); + const intervals = new Map(); + const fetches = []; + const observers = []; + let nextTimer = 1; + const modalHidden = new Set(); + let indicator = { className: "", textContent: "" }; + const modal = { + classList: { + contains: (name) => modalHidden.has(name), + add(name) { + modalHidden.add(name); + for (const observer of observers) observer.fn(); + }, + remove(name) { modalHidden.delete(name); }, + }, + }; + class MutationObserver { + constructor(fn) { this.fn = fn; observers.push(this); } + observe() {} + disconnect() {} + } + const document = { + hidden, + getElementById(id) { + if (id === "ev-status-indicator") return indicator; + if (id === "settings-modal") return modal; + return null; + }, + addEventListener(type, fn) { documentListeners.set(type, fn); }, + removeEventListener(type, fn) { + if (documentListeners.get(type) === fn) documentListeners.delete(type); + }, + }; + const windowObj = { FTWSettings: { tabs: {} } }; + const sandbox = { + window: windowObj, + document, + fetch(path) { + const entry = { path: String(path), settled: false }; + entry.promise = new Promise((resolve, reject) => { + entry.resolve = resolve; + entry.reject = reject; + }); + fetches.push(entry); + return entry.promise; + }, + setInterval(fn, ms) { + const id = nextTimer++; + intervals.set(id, { fn, ms }); + return id; + }, + clearInterval(id) { intervals.delete(id); }, + MutationObserver, + console, JSON, Object, String, Array, + }; + windowObj.window = windowObj; + vm.createContext(sandbox); + vm.runInContext(evSource, sandbox); + function mount() { sandbox.window.FTWSettings.tabs.ev.after({ + bodyEl: { querySelector() { return null; } }, + config: { ev_charger: {} }, + getByPath() { return ""; }, + captureCurrentTab() {}, + renderTab() {}, + }); } + mount(); + return { + indicator() { return indicator; }, + rerender() { indicator = { className: "", textContent: "" }; mount(); }, + fetches, + pollTimers() { return [...intervals.values()].filter((timer) => timer.ms === 5000); }, + setHidden(next) { + document.hidden = next; + documentListeners.get("visibilitychange")(); + }, + closeSettings() { modal.classList.add("hidden"); }, + runPollTimers() { + for (const timer of [...intervals.values()]) { + if (timer.ms === 5000) timer.fn(); + } + }, + }; +} + +test("heating polling stays single-flight while visible and pauses when hidden", async () => { + const heating = rigHeating(); + + assert.equal(heating.fetches.length, 1, "startup should discover drivers once"); + assert.equal(heating.pollTimers().length, 1, "startup should own one 30s timer"); + + heating.runPollTimers(); + heating.runPollTimers(); + assert.equal(heating.fetches.length, 1, "timer ticks must not overlap an unresolved refresh"); + + heating.setHidden(true); + heating.runPollTimers(); + assert.equal(heating.fetches.length, 1, "hidden heating should make no more GETs"); + assert.equal(heating.pollTimers().length, 0, "hidden heating should clear its timer"); + + await heating.settleFetches(); + const afterHiddenSettle = heating.liveFetches().length; + assert.ok(afterHiddenSettle >= 1, "in-flight discovery may finish while hidden"); + assert.equal(heating.fetches.filter((entry) => !entry.settled).length, 0); + + heating.setHidden(false); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal( + heating.liveFetches().length, + afterHiddenSettle + 1, + "visible heating should refresh live pump detail once", + ); + assert.equal(heating.pollTimers().length, 1, "visible heating should restore only one timer"); + + heating.runPollTimers(); + heating.runPollTimers(); + assert.equal( + heating.liveFetches().length, + afterHiddenSettle + 1, + "timer ticks must not overlap the visible catch-up", + ); +}); + +test("heating starts dormant when loaded in a hidden document", () => { + const heating = rigHeating({ hidden: true }); + + assert.equal(heating.fetches.length, 0); + assert.equal(heating.pollTimers().length, 0); + + heating.setHidden(false); + assert.equal(heating.fetches.length, 1); + assert.equal(heating.pollTimers().length, 1); +}); + +test("EV settings poll pauses when hidden and stops on close", () => { + const ev = rigEv(); + + assert.equal(ev.fetches.length, 1, "opening the EV tab should fetch status once"); + assert.equal(ev.pollTimers().length, 1, "opening the EV tab should own one 5s timer"); + + ev.setHidden(true); + ev.runPollTimers(); + assert.equal(ev.fetches.length, 1, "hidden settings should make no more status GETs"); + assert.equal(ev.pollTimers().length, 0, "hidden settings should clear its timer"); + + ev.setHidden(false); + assert.equal(ev.fetches.length, 2, "visible settings should refresh once"); + assert.equal(ev.pollTimers().length, 1, "visible settings should restore only one timer"); + + ev.closeSettings(); + ev.runPollTimers(); + assert.equal(ev.fetches.length, 2, "closing settings should make no more status GETs"); + assert.equal(ev.pollTimers().length, 0, "closing settings should clear its timer"); +}); + +test("plan, cards, and remaining settings pollers hook visibilitychange", () => { + assert.match(readWeb("./plan.js"), /visibilitychange/); + assert.match(readWeb("./loadpoints.js"), /visibilitychange/); + assert.match(readWeb("./twins.js"), /visibilitychange/); + assert.match(readWeb("./components/ftw-history-card.js"), /visibilitychange/); + assert.match(readWeb("./components/ftw-savings-card.js"), /visibilitychange/); + assert.match(readWeb("./components/ftw-price-chart.js"), /visibilitychange/); + assert.match(readWeb("./settings/tabs/system.js"), /visibilitychange/); + assert.match(readWeb("./components/ftw-energy-flow.js"), /document\.hidden/); +}); + + +test("EV status polling follows the new element after a provider rerender", async () => { + const ev = rigEv(); + const old = ev.indicator(); + ev.rerender(); + assert.equal(ev.pollTimers().length, 1); + for (const entry of ev.fetches) entry.resolve({ json: async () => ({}) }); + await new Promise(resolve => setImmediate(resolve)); + const oldText = old.textContent; + ev.runPollTimers(); + ev.fetches.at(-1).resolve({ json: async () => ({ drivers: { easee: { status: "online", device_id: "new-status" } } }) }); + await new Promise(resolve => setImmediate(resolve)); + assert.match(ev.indicator().textContent, /new-status/); + assert.equal(old.textContent, oldText, "the old timer must not update its detached element"); +}); diff --git a/web/loadpoints.js b/web/loadpoints.js index cf935d49..beb8ccdc 100644 --- a/web/loadpoints.js +++ b/web/loadpoints.js @@ -305,10 +305,11 @@ refreshTimer = null; } function syncPolling() { - if (advancedVisible()) startPolling(); + if (advancedVisible() && !document.hidden) startPolling(); else stopPolling(); } document.addEventListener('ftw-ui-mode-change', syncPolling); + document.addEventListener('visibilitychange', syncPolling); syncPolling(); } diff --git a/web/plan-polling.test.mjs b/web/plan-polling.test.mjs new file mode 100644 index 00000000..fcfb6e31 --- /dev/null +++ b/web/plan-polling.test.mjs @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; +import vm from 'node:vm'; +import * as brief from './plan-brief.js'; +import * as soc from './plan-soc.js'; +import * as units from './components/price-units.js'; +import * as prefs from './plan-prefs.js'; + +const source = readFileSync(new URL('./plan.js', import.meta.url), 'utf8') + .replace(/^import[\s\S]*?from "[^"]+";\n/gm, ''); + +function rigPlan() { + const listeners = new Map(); + const intervals = new Map(); + const fetches = []; + let timer = 0; + const document = { + hidden: false, readyState: 'complete', + body: { appendChild() {} }, + createElement() { return { style: {} }; }, + getElementById() { return null; }, querySelectorAll() { return []; }, + addEventListener(name, fn) { listeners.set(name, fn); }, + }; + const sandbox = { + ...brief, ...soc, ...units, ...prefs, document, + window: { addEventListener() {}, dispatchEvent() {} }, + CustomEvent: class { constructor(name, options) { this.type = name; this.detail = options.detail; } }, + fetch(path) { + let resolve; + const promise = new Promise(r => { resolve = r; }); + fetches.push({ path, resolve }); + return promise; + }, + setInterval(fn, ms) { intervals.set(++timer, { fn, ms }); return timer; }, + clearInterval(id) { intervals.delete(id); }, + console, + }; + vm.runInNewContext(source, sandbox); + return { + batches() { return fetches.filter(f => f.path === '/api/config').length; }, + setHidden(value) { document.hidden = value; listeners.get('visibilitychange')(); }, + poll() { for (const { fn, ms } of intervals.values()) if (ms === 30000) fn(); }, + async settle() { + for (const f of fetches.splice(0)) f.resolve({ json: async () => ({}) }); + await new Promise(resolve => setImmediate(resolve)); + }, + }; +} + +test('plan resume waits for the current batch then catches up once', async () => { + const plan = rigPlan(); + assert.equal(plan.batches(), 1); + plan.setHidden(true); + plan.setHidden(false); + plan.poll(); + plan.poll(); + assert.equal(plan.batches(), 1, 'resume must not start an overlapping six-request batch'); + await plan.settle(); + assert.equal(plan.batches(), 1, 'one catch-up starts after the first batch settles'); + await plan.settle(); + assert.equal(plan.batches(), 0, 'catch-up must stop once current'); +}); + +test('plan drops its queued refresh if hidden again before the response', async () => { + const plan = rigPlan(); + plan.setHidden(true); + plan.setHidden(false); + plan.setHidden(true); + await plan.settle(); + assert.equal(plan.batches(), 0); + plan.setHidden(false); + assert.equal(plan.batches(), 1, 'the next visible transition still refreshes'); + await plan.settle(); +}); diff --git a/web/plan.js b/web/plan.js index f5f21074..25b5fe43 100644 --- a/web/plan.js +++ b/web/plan.js @@ -18,6 +18,7 @@ import { 'use strict'; const PLAN_REFRESH_MS = 30000; + const HINT_REFRESH_MS = 5000; function apiFetch(path, opts) { return fetch(path, opts); @@ -129,7 +130,27 @@ import { return d.getTime(); } - async function fetchAll() { + let planFetchInFlight = null; + let planRefreshQueued = false; + + function fetchAll() { + if (planFetchInFlight) { + planRefreshQueued = true; + return planFetchInFlight; + } + planFetchInFlight = (async function () { + do { + planRefreshQueued = false; + await fetchPlanData(); + } while (planRefreshQueued && !document.hidden); + })().finally(function () { + planFetchInFlight = null; + planRefreshQueued = false; + }); + return planFetchInFlight; + } + + async function fetchPlanData() { const [p, f, m, c, s, pv] = await Promise.all([ apiFetch('/api/prices').then(r => r.json()).catch(() => ({})), apiFetch('/api/forecast').then(r => r.json()).catch(() => ({})), @@ -1269,13 +1290,46 @@ import { .catch(function () {}); } - function init() { + var planPollTimer = null; + var hintPollTimer = null; + + function pollPlan() { + if (document.hidden) return; fetchAll(); + } + + function pollHint() { + if (document.hidden) return; + renderStrategyHint(); + } + + function syncPlanPolling() { + if (document.hidden) { + if (planPollTimer !== null) { + clearInterval(planPollTimer); + planPollTimer = null; + } + if (hintPollTimer !== null) { + clearInterval(hintPollTimer); + hintPollTimer = null; + } + return; + } + pollPlan(); + pollHint(); + if (planPollTimer === null) { + planPollTimer = setInterval(pollPlan, PLAN_REFRESH_MS); + } + if (hintPollTimer === null) { + hintPollTimer = setInterval(pollHint, HINT_REFRESH_MS); + } + } + + function init() { setupHover(); initPrefs(); - renderStrategyHint(); - setInterval(fetchAll, PLAN_REFRESH_MS); - setInterval(renderStrategyHint, 5000); + document.addEventListener('visibilitychange', syncPlanPolling); + syncPlanPolling(); window.addEventListener('resize', render); window.addEventListener('ftw-theme-change', render); const btn = document.getElementById('plan-replan'); diff --git a/web/settings/tabs/ev.js b/web/settings/tabs/ev.js index f35f139b..6a9441cf 100644 --- a/web/settings/tabs/ev.js +++ b/web/settings/tabs/ev.js @@ -139,9 +139,49 @@ el.textContent = "? status endpoint unreachable"; }); } - refresh(); - if (window._evStatusTimer) clearInterval(window._evStatusTimer); - window._evStatusTimer = setInterval(refresh, 5000); + function settingsOpen() { + var modal = document.getElementById("settings-modal"); + return !!(modal && !modal.classList.contains("hidden")); + } + function stopTimer() { + if (window._evStatusTimer) { + clearInterval(window._evStatusTimer); + window._evStatusTimer = null; + } + } + function shouldPoll() { + return !document.hidden && settingsOpen() && !!document.getElementById("ev-status-indicator"); + } + function syncPolling() { + if (!shouldPoll()) { + stopTimer(); + return; + } + refresh(); + if (!window._evStatusTimer) { + window._evStatusTimer = setInterval(function () { + if (!shouldPoll()) { + stopTimer(); + return; + } + refresh(); + }, 5000); + } + } + // A rerender replaces the status element and its refresh callback. + stopTimer(); + if (window._evOnVisibility) { + document.removeEventListener("visibilitychange", window._evOnVisibility); + } + window._evOnVisibility = syncPolling; + document.addEventListener("visibilitychange", syncPolling); + if (window._evModalObserver) window._evModalObserver.disconnect(); + var modal = document.getElementById("settings-modal"); + if (modal && typeof MutationObserver === "function") { + window._evModalObserver = new MutationObserver(syncPolling); + window._evModalObserver.observe(modal, { attributes: true, attributeFilter: ["class"] }); + } + syncPolling(); }, }; })(); diff --git a/web/settings/tabs/system.js b/web/settings/tabs/system.js index 5e257eca..75df320b 100644 --- a/web/settings/tabs/system.js +++ b/web/settings/tabs/system.js @@ -420,11 +420,49 @@ }); }; - refresh(); + function settingsOpen() { + var modal = document.getElementById("settings-modal"); + return !!(modal && !modal.classList.contains("hidden")); + } + function stopTimer() { + if (window._systemStatusTimer) { + clearInterval(window._systemStatusTimer); + window._systemStatusTimer = null; + } + } + function shouldPoll() { + return !document.hidden && settingsOpen() && !!document.getElementById("sys-hostname"); + } + function syncPolling() { + if (!shouldPoll()) { + stopTimer(); + return; + } + refresh(); + if (!window._systemStatusTimer) { + window._systemStatusTimer = setInterval(function () { + if (!shouldPoll()) { + stopTimer(); + return; + } + refresh(); + }, 5000); + } + } + if (window._systemOnVisibility) { + document.removeEventListener("visibilitychange", window._systemOnVisibility); + } + window._systemOnVisibility = syncPolling; + document.addEventListener("visibilitychange", syncPolling); + if (window._systemModalObserver) window._systemModalObserver.disconnect(); + var modal = document.getElementById("settings-modal"); + if (modal && typeof MutationObserver === "function") { + window._systemModalObserver = new MutationObserver(syncPolling); + window._systemModalObserver.observe(modal, { attributes: true, attributeFilter: ["class"] }); + } refreshComponents(); refreshLanAuth(); - if (window._systemStatusTimer) clearInterval(window._systemStatusTimer); - window._systemStatusTimer = setInterval(refresh, 5000); + syncPolling(); }, }; S.tabs.system._pure = { optimizerStatus: optimizerStatus, bundleDisplay: bundleDisplay }; diff --git a/web/twins.js b/web/twins.js index 7982731c..7423c46b 100644 --- a/web/twins.js +++ b/web/twins.js @@ -50,7 +50,10 @@ clearInterval(refreshTimer); refreshTimer = null; } - function syncPolling() { if (advancedVisible()) startPolling(); else stopPolling(); } + function syncPolling() { + if (advancedVisible() && !document.hidden) startPolling(); + else stopPolling(); + } function fmtAge(ms) { if (!ms) return '—'; @@ -237,6 +240,7 @@ const grid = document.getElementById('twins-grid'); if (grid) grid.addEventListener('click', onGridClick); document.addEventListener('ftw-ui-mode-change', syncPolling); + document.addEventListener('visibilitychange', syncPolling); syncPolling(); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);