Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/hidden-tab-polls.md
Original file line number Diff line number Diff line change
@@ -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.
126 changes: 78 additions & 48 deletions web/components/ftw-energy-flow.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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() {
Expand Down
22 changes: 14 additions & 8 deletions web/components/ftw-history-card.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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() {
Expand Down
21 changes: 19 additions & 2 deletions web/components/ftw-price-chart.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down
22 changes: 14 additions & 8 deletions web/components/ftw-savings-card.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion web/heating-control.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
35 changes: 32 additions & 3 deletions web/heating.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(); });
});
Expand Down Expand Up @@ -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);
Expand All @@ -919,8 +948,8 @@
}
});
}
refresh();
timer = setInterval(refresh, REFRESH_MS);
document.addEventListener('visibilitychange', syncHeatingPolling);
syncHeatingPolling();
}

if (document.readyState === 'loading') {
Expand Down
Loading