@@ -996,7 +996,7 @@ Price bars (top of the chart)
-
+
diff --git a/web/style.css b/web/style.css
index df06f951d..4a88171ef 100644
--- a/web/style.css
+++ b/web/style.css
@@ -2006,7 +2006,7 @@ body.advanced #ui-mode-toggle { color: #fbbf24; }
.twins-grid {
display: grid;
- grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr));
gap: 12px;
margin-top: 10px;
}
@@ -2029,17 +2029,34 @@ body.advanced #ui-mode-toggle { color: #fbbf24; }
}
.twin-card .twin-row span { color: var(--text-dim, #a0a0a0); }
.twin-card .twin-row b { color: var(--text, #e8e8e8); font-weight: 500; }
-.twin-quality {
- height: 4px;
- background: rgba(255,255,255,0.08);
- border-radius: 2px;
- overflow: hidden;
- margin-top: 6px;
-}
-.twin-quality-fill {
- height: 100%;
- background: linear-gradient(90deg, #22c55e, #86efac);
- transition: width 0.3s;
+#twins-section { margin-top: 24px; }
+.forecast-card { padding: 20px; align-self: start; min-width: 0; }
+.forecast-card-heading { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; }
+.forecast-card .forecast-card-heading h3 { margin: 0; font-family: var(--sans); font-size: 16px; color: var(--fg); }
+.forecast-state { display: inline-flex; align-items: center; gap: 7px; font-family: var(--sans); font-size: 12px; color: var(--fg-dim); }
+.forecast-state::before { content: ''; width: 7px; height: 7px; flex: 0 0 7px; border-radius: 50%; background: currentColor; }
+.forecast-state[data-tone="learning"], .forecast-state[data-tone="healthy"], .forecast-state[data-tone="warning"] { color: var(--fg); }
+.forecast-state[data-tone="learning"]::before { background: var(--accent-e); }
+.forecast-state[data-tone="healthy"]::before { background: var(--green-e); }
+.forecast-state[data-tone="warning"]::before { background: var(--amber); }
+.forecast-description { margin: 18px 0 5px; color: var(--fg-dim); font-family: var(--sans); font-size: 13px; line-height: 1.5; }
+.forecast-meta { margin: 0; font-family: var(--sans); font-size: 13px; color: var(--fg); }
+.forecast-details-button { display: flex; justify-content: space-between; align-items: center; gap: 12px; width: 100%; min-height: 44px; margin-top: 12px; padding: 8px 0; background: none; border: 0; color: var(--fg-dim); font-family: var(--sans); font-size: 12px; text-align: left; cursor: pointer; }
+.forecast-details-button:hover { color: var(--fg); }
+.forecast-details-button:focus-visible { outline: 2px solid var(--accent-e); outline-offset: 3px; border-radius: 2px; }
+.forecast-details { border-top: 1px solid var(--line); padding-top: 16px; }
+.forecast-details[hidden] { display: none; }
+.forecast-details h4 { margin: 22px 0 6px; font-family: var(--sans); font-size: 13px; font-weight: 600; color: var(--fg); }
+.forecast-note { margin: 8px 0 14px; font-family: var(--sans); font-size: 12px; line-height: 1.6; color: var(--fg-dim); }
+.forecast-card .twin-row { gap: 16px; padding: 5px 0; font-size: 12px; }
+.forecast-card .twin-row b { text-align: right; overflow-wrap: anywhere; min-width: 0; }
+.forecast-action-status { margin-top: 8px; color: var(--fg); font-family: var(--sans); font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; }
+.forecast-action-status:empty { display: none; }
+body.ftw-app .forecast-card .btn-reset-model { min-height: 44px; font-family: var(--sans); letter-spacing: 0; font-size: 12px; }
+body.ftw-app .forecast-card .twin-profile-toggle button { min-height: 40px; font-family: var(--sans); font-size: 12px; }
+@media (max-width: 420px) {
+ .forecast-card { padding: 16px; }
+ .forecast-card .twin-row { flex-wrap: wrap; gap: 4px 12px; }
}
.models-grid {
display: grid;
diff --git a/web/twins.js b/web/twins.js
index 7423c46bc..dcb2e851d 100644
--- a/web/twins.js
+++ b/web/twins.js
@@ -1,6 +1,5 @@
-// twins.js — advanced-mode forecast-learning diagnostics.
-// Polls only while advanced mode is visible. The box owns the model state;
-// this view only asks it to begin a new learning period for one signal.
+// Forecast status with model diagnostics behind an explicit disclosure.
+// The box owns learning state; elapsed time never establishes model quality.
(function () {
'use strict';
@@ -18,6 +17,7 @@
const availableActions = new Set();
const actionMessages = new Map();
const actionFocus = new Set();
+ const expanded = new Set();
function apiFetch(path, opts) { return fetch(path, opts); }
@@ -39,7 +39,7 @@
render(pv, load);
}
- function advancedVisible() { return !!(document.body && document.body.classList.contains('advanced')); }
+ function forecastsVisible() { return location.hash.split('/')[0] === '#more'; }
function startPolling() {
if (refreshTimer) return;
fetchAll();
@@ -51,16 +51,17 @@
refreshTimer = null;
}
function syncPolling() {
- if (advancedVisible() && !document.hidden) startPolling();
+ if (forecastsVisible() && !document.hidden) startPolling();
else stopPolling();
}
function fmtAge(ms) {
if (!ms) return '—';
- const s = Math.round((Date.now() - ms) / 1000);
+ const s = Math.max(0, Math.round((Date.now() - ms) / 1000));
if (s < 60) return s + 's ago';
if (s < 3600) return Math.round(s / 60) + 'm ago';
- return Math.round(s / 3600) + 'h ago';
+ if (s < 86400) return Math.round(s / 3600) + 'h ago';
+ return Math.floor(s / 86400) + 'd ago';
}
function fmtLocalTime(ms) {
if (!ms || !Number.isFinite(Number(ms))) return '—';
@@ -83,7 +84,7 @@
}
function engineLabel(learning) {
if (!learning) return 'Unavailable';
- return learning.engine === 'energyplan' ? 'Energyplan' : learning.engine === 'legacy' ? 'Legacy' : 'Unavailable';
+ return learning.engine === 'energyplan' ? 'Energyplan' : learning.engine === 'legacy' ? 'Local model' : 'Unavailable';
}
function actionAvailable(d) {
const learning = d && d.learning;
@@ -103,54 +104,99 @@
function loadProfileControl(d) {
if (!d || !d.enabled) return '';
- const active = d.profile || d.active_profile || 'home';
+ const active = d.profile || d.active_profile;
function btn(profile, label) {
const cls = profile === active ? ' class="active"' : '';
- return `${label} `;
+ return `${label} `;
}
- return 'profile ' +
- `
` + btn('home', 'Home') + btn('away', 'Away') + '
';
+ return 'Household profile ' +
+ '
' + btn('home', 'Home') + btn('away', 'Away') + '
';
}
+ function row(label, value) {
+ return `${esc(label)} ${esc(value)}
`;
+ }
+ function watts(value) { return Number.isFinite(value) ? Math.round(value).toLocaleString() + ' W' : 'Not available'; }
+
function modelRows(d) {
- const learning = d.learning;
- const legacy = learning && learning.engine === 'energyplan';
- const prefix = legacy ? 'legacy ' : '';
- const rows = [];
- if (legacy) rows.push('legacy model stats secondary
');
- rows.push(`${prefix}samples ${d.samples || 0}
`);
- if (d.mae_w != null) rows.push(`${prefix}MAE ${d.mae_w.toFixed(0)} W
`);
- if (d.peak_w != null) rows.push(`${prefix}peak ref ${(d.peak_w / 1000).toFixed(1)} kW
`);
- if (d.rated_w != null) rows.push(`${prefix}rated ${(d.rated_w / 1000).toFixed(1)} kW
`);
- if (d.heating_w_per_degc != null && d.heating_w_per_degc > 0) rows.push(`${prefix}heating ${d.heating_w_per_degc.toFixed(0)} W/°C
`);
- if (d.buckets_warm != null) rows.push(`${prefix}buckets warm ${d.buckets_warm}/${d.buckets_total}
`);
- rows.push(`${prefix}last update ${fmtAge(d.last_ms)}
`);
- if (d.quality != null) {
- const quality = Math.max(0, Math.min(1, d.quality));
- const qualityPct = (quality * 100).toFixed(0);
- const qualityColor = quality >= 0.7 ? '#22c55e' : quality >= 0.3 ? '#fbbf24' : '#ef4444';
- rows.push(`${prefix}quality ${qualityPct}%
`);
- rows.push(``);
+ const older = d.learning && d.learning.engine === 'energyplan';
+ const rows = [row('Training samples', d.samples ?? 'Not available')];
+ if (d.mae_w != null) rows.push(row('Average training error', watts(d.mae_w)));
+ if (Number.isFinite(d.peak_w)) rows.push(row('Load reference', (d.peak_w / 1000).toFixed(1) + ' kW'));
+ if (Number.isFinite(d.rated_w)) rows.push(row('Solar starting estimate', (d.rated_w / 1000).toFixed(1) + ' kW'));
+ if (d.heating_w_per_degc > 0) rows.push(row('Heating estimate below 18 °C', Math.round(d.heating_w_per_degc) + ' W/°C'));
+ if (d.buckets_warm != null) rows.push(row('Weekly hours with 8+ samples', d.buckets_warm + '/' + d.buckets_total));
+ rows.push(row('Last model update', fmtLocalTime(d.last_ms)));
+ return `${older ? 'Older local model' : 'Local model diagnostics'} ` +
+ `${older ? 'Kept for comparison and fallback. These figures describe the older model, not Energyplan.' : 'These figures describe the model’s training data.'} Training error is not measured forecast accuracy.
` + rows.join('');
+ }
+
+ function overview(d, signal) {
+ if (!d || d.unavailable) return { label: 'Unavailable', tone: 'muted', note: 'Cannot read forecast status. Trying again.' };
+ if (!d.enabled) return { label: 'Off', tone: 'muted', note: 'This forecast model is turned off.' };
+ const l = d.learning;
+ if (l && l.health === 'degraded') {
+ return { label: 'Needs attention', tone: 'warning', note: 'Learning has a problem. Open details for more information.' };
+ }
+ if (l && l.health === 'waiting_for_data') {
+ return { label: 'Waiting for data', tone: 'warning', note: 'Waiting for usable measurements to continue learning.' };
+ }
+ if (!l || !['cold_start', 'learning', 'ready'].includes(l.status)) {
+ return { label: 'Needs attention', tone: 'warning', note: 'Forecast learning is unavailable. Open details to check its status.' };
+ }
+ // Training is normally quarter-hourly. Allow two hours for load data
+ // and 36 hours for solar, which does not train throughout the night.
+ const maxAge = (signal === 'pv' ? 36 : 2) * 3600000;
+ const latest = Number(l.latest_training_ms);
+ const started = Number(l.started_ms);
+ const reference = latest > 0 ? latest : started;
+ if (reference > 0 && Date.now() - reference > maxAge) {
+ return { label: 'Waiting for data', tone: 'warning', note: latest > 0 ? 'Last learned from new measurements ' + fmtAge(latest) + '.' : 'Waiting for usable measurements to begin learning.' };
}
- return rows.join('');
+ if (latest > Date.now() || started > Date.now() || (l.status === 'ready' && !(latest > 0))) {
+ return { label: 'Status unknown', tone: 'muted', note: 'The box has not provided a valid training time.' };
+ }
+ if (l.status === 'ready' && l.health === 'healthy') return { label: 'Healthy', tone: 'healthy', note: 'Ready and continuing to learn from your home.' };
+ if (l.status === 'ready') return { label: 'Ready', tone: 'muted', note: 'The model is ready. Its health has not been confirmed.' };
+ return { label: 'Learning', tone: 'learning', note: l.status === 'cold_start' ? 'Getting started with your home’s measurements.' : 'Getting to know your home’s energy patterns.' };
+ }
+
+ function learningDuration(learning) {
+ const started = Number(learning && learning.started_ms);
+ if (!(started > 0) || started > Date.now()) return '';
+ const days = Math.floor((Date.now() - started) / 86400000);
+ return days < 1 ? 'First day of learning' : days + (days === 1 ? ' day learning' : ' days learning');
}
- function twinCard(title, d, endpoint, extraHtml) {
+ function twinCard(title, d, endpoint) {
const action = actions[endpoint];
const statusID = 'twin-status-' + action.id;
- if (!d || d.unavailable) return `${title} model state Unavailable
The box did not provide this model state.
`;
- if (!d.enabled) return `${title} model state Disabled
This model is disabled on the box.
`;
- const learning = d.learning;
- const message = actionMessages.get(endpoint) || (!actionAvailable(d) ? 'Relearning is unavailable for this model.' : '');
- const learningRows = [
- `engine ${engineLabel(learning)}
`,
- `learning state ${learningStatus(learning)}
`,
- ];
- if (learning) {
- learningRows.push(`learning started ${fmtLocalTime(learning.started_ms)}
`);
- learningRows.push(`latest training ${fmtLocalTime(learning.latest_training_ms)}
`);
- }
- return `${title} ${extraHtml || ''}${learningRows.join('')}${modelRows(d)}${resetButton(endpoint, d)}
${esc(message)}
`;
+ const state = overview(d, action.id);
+ const learning = d && d.learning;
+ const duration = learningDuration(learning);
+ const activeProfile = d && (d.profile || d.active_profile);
+ const profile = action.id === 'load' && d && d.enabled ? (activeProfile === 'away' ? 'Away profile' : activeProfile === 'home' ? 'Home profile' : '') : '';
+ const meta = [duration, profile].filter(Boolean).join(' · ');
+ const open = expanded.has(action.id);
+ const detailsID = 'forecast-details-' + action.id;
+ const usable = d && d.enabled && !d.unavailable;
+ const details = usable ? row('Learning model', engineLabel(learning)) +
+ row('Model state', learningStatus(learning)) +
+ row('Health check', learning && learning.health === 'healthy' ? 'Healthy' : learning && learning.health === 'degraded' ? 'Needs attention' : learning && learning.health === 'waiting_for_data' ? 'Waiting for data' : 'Not available') +
+ (learning && learning.health_reason ? `${esc(learning.health_reason)}
` : '') +
+ row('Learning period started', learning && learning.started_ms > 0 ? fmtLocalTime(learning.started_ms) : 'Start date not recorded') +
+ row('Latest training data', fmtLocalTime(learning && learning.latest_training_ms)) +
+ 'Healthy requires a health check from the box, a ready model and recent training data. It is not a forecast accuracy score. Learning days count elapsed time, not data coverage.
' +
+ (action.id === 'load' ? loadProfileControl(d) : '') +
+ 'Forecast accuracy Measured forecast accuracy is not available in this view yet.
' + modelRows(d) +
+ 'Restart learning Use this after a lasting change to your home or solar system. It restarts this model and its fallback; measured history stays saved.
' +
+ resetButton(endpoint, d) + (!actionAvailable(d) ? 'Relearning is unavailable for this model.
' : '') :
+ '' + esc(state.note) + '
';
+ return `
${title} ${state.label} ` +
+ `
${esc(state.note)}
${meta ? `
${esc(meta)}
` : ''}` +
+ `
${open ? 'Hide details' : 'Details'} ${open ? '−' : '+'} ` +
+ `
${details}
` +
+ `
${esc(actionMessages.get(endpoint) || '')}
`;
}
function focusedAction() {
@@ -162,6 +208,7 @@
if (!active || !active.dataset) return null;
if (actions[active.dataset.resetTwin]) return `[data-reset-twin="${active.dataset.resetTwin}"]`;
if (active.dataset.loadmodelProfile) return `[data-loadmodel-profile="${active.dataset.loadmodelProfile}"]`;
+ if (active.dataset.twinDetails) return `[data-twin-details="${active.dataset.twinDetails}"]`;
return null;
}
function focusMayReturn() {
@@ -173,13 +220,13 @@
if (!grid) return;
const selector = restoreFocus || focusedControl();
availableActions.clear();
- grid.innerHTML = twinCard('Solar production', pv, '/api/pvmodel/reset') + twinCard('Consumption', load, '/api/loadmodel/reset', loadProfileControl(load));
+ grid.innerHTML = twinCard('Solar production', pv, '/api/pvmodel/reset') + twinCard('Consumption', load, '/api/loadmodel/reset');
if (selector && grid.querySelector) {
const button = grid.querySelector(selector);
if (button && !button.disabled && typeof button.focus === 'function') button.focus();
}
const sub = document.getElementById('twins-subtitle');
- if (sub) sub.textContent = 'Forecast learning for solar production and consumption';
+ if (sub) sub.textContent = 'Learning from your home';
}
async function failedResponse(response) {
@@ -224,22 +271,30 @@
}
function onGridClick(e) {
- const profile = e.target && e.target.dataset && e.target.dataset.loadmodelProfile;
+ const target = e.target && e.target.closest ? e.target.closest('button') : e.target;
+ const details = target && target.dataset && target.dataset.twinDetails;
+ if (details) {
+ if (expanded.has(details)) expanded.delete(details);
+ else expanded.add(details);
+ render(lastPV, lastLoad, `[data-twin-details="${details}"]`);
+ return;
+ }
+ const profile = target && target.dataset && target.dataset.loadmodelProfile;
if (profile) {
- if (e.target.classList.contains('active')) return;
+ if (target.classList.contains('active')) return;
apiFetch('/api/loadmodel/profile', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profile }) })
.then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(() => fetchAll())
.catch(err => { actionMessages.set('/api/loadmodel/reset', 'Load profile switch failed: ' + err.message); render(lastPV, lastLoad); });
return;
}
- const endpoint = e.target && e.target.dataset && e.target.dataset.resetTwin;
+ const endpoint = target && target.dataset && target.dataset.resetTwin;
if (endpoint) startRelearn(endpoint);
}
function init() {
const grid = document.getElementById('twins-grid');
if (grid) grid.addEventListener('click', onGridClick);
- document.addEventListener('ftw-ui-mode-change', syncPolling);
+ window.addEventListener('hashchange', syncPolling);
document.addEventListener('visibilitychange', syncPolling);
syncPolling();
}
diff --git a/web/twins.test.mjs b/web/twins.test.mjs
index 616a998a1..464cbb782 100644
--- a/web/twins.test.mjs
+++ b/web/twins.test.mjs
@@ -27,22 +27,28 @@ async function settle() {
for (let i = 0; i < 5; i++) await new Promise(resolve => setImmediate(resolve));
}
-function load({ pv = model(), loadModel = model("ready"), confirm = () => true, post } = {}) {
+function load({ pv = model(), loadModel = model("ready"), confirm = () => true, post, hash = '#more', hidden = false } = {}) {
const listeners = new Map();
+ const documentListeners = new Map();
+ const windowListeners = new Map();
+ const timers = new Map();
+ let nextTimer = 0;
+ const location = { hash };
let html = "";
let focused = null;
let document;
const grid = {
addEventListener(type, handler) { listeners.set(type, handler); },
querySelector(selector) {
- const match = selector.match(/data-(reset-twin|loadmodel-profile)="([^"]+)"/);
+ const match = selector.match(/data-(reset-twin|loadmodel-profile|twin-details)="([^"]+)"/);
if (!match || !html.includes(`data-${match[1]}="${match[2]}"`)) return null;
const tag = html.match(new RegExp(`]*data-${match[1]}="${match[2]}"[^>]*>`));
return {
disabled: /\sdisabled(?:\s|=|>)/.test(tag?.[0] || ""),
focus() {
- document.activeElement = { dataset: { [match[1]]: match[2] } };
- focused = match[1] === "reset-twin" ? match[2] : "profile:" + match[2];
+ const key = { 'reset-twin': 'resetTwin', 'loadmodel-profile': 'loadmodelProfile', 'twin-details': 'twinDetails' }[match[1]];
+ document.activeElement = { dataset: { [key]: match[2] } };
+ focused = match[1] === "reset-twin" ? match[2] : (match[1] === 'twin-details' ? 'details:' : 'profile:') + match[2];
},
};
},
@@ -50,7 +56,7 @@ function load({ pv = model(), loadModel = model("ready"), confirm = () => true,
Object.defineProperty(grid, "innerHTML", {
get() { return html; },
set(value) {
- if (document?.activeElement?.dataset?.resetTwin || document?.activeElement?.dataset?.loadmodelProfile) document.activeElement = document.body;
+ if (document?.activeElement?.dataset) document.activeElement = document.body;
html = String(value);
},
});
@@ -58,9 +64,10 @@ function load({ pv = model(), loadModel = model("ready"), confirm = () => true,
const requests = [];
document = {
readyState: "complete",
+ hidden,
body: { classList: { contains: value => value === "advanced" } },
activeElement: null,
- addEventListener() {},
+ addEventListener(type, handler) { documentListeners.set(type, handler); },
getElementById(id) {
return id === "twins-grid" ? grid : id === "twins-subtitle" ? subtitle : null;
},
@@ -74,12 +81,18 @@ function load({ pv = model(), loadModel = model("ready"), confirm = () => true,
return Promise.resolve(response({}));
};
vm.runInNewContext(source, {
- document, fetch, confirm, setInterval: () => 1, clearInterval() {}, Date: class extends Date {
+ document, location, window: { addEventListener(type, handler) { windowListeners.set(type, handler); } }, fetch, confirm,
+ setInterval(fn) { timers.set(++nextTimer, fn); return nextTimer; }, clearInterval(id) { timers.delete(id); }, Date: class extends Date {
static now() { return now; }
}, Number, Math, String, Map, Set, Promise, Error,
}, { filename: "twins.js" });
return {
grid, subtitle, requests,
+ details(id) { listeners.get('click')({ target: { dataset: { twinDetails: id } } }); },
+ navigate(hash) { location.hash = hash; windowListeners.get('hashchange')(); },
+ visibility(hidden) { document.hidden = hidden; documentListeners.get('visibilitychange')(); },
+ poll() { for (const fn of timers.values()) fn(); },
+ timers: () => timers.size,
click(endpoint) {
listeners.get("click")({ target: { dataset: { resetTwin: endpoint } } });
},
@@ -92,19 +105,105 @@ function load({ pv = model(), loadModel = model("ready"), confirm = () => true,
};
}
-test("renders the Energyplan learning state, local training times, and legacy stats as secondary", async () => {
+test("shows simple status and keeps model internals and resets behind Details", async () => {
const ui = load();
await settle();
assert.match(ui.grid.innerHTML, /Solar production<\/h3>/);
- assert.match(ui.grid.innerHTML, /engine<\/span>Energyplan<\/b>/);
- assert.match(ui.grid.innerHTML, /learning state<\/span>Learning<\/b>/);
- assert.match(ui.grid.innerHTML, /learning started<\/span>.*2026.*<\/b>/);
- assert.match(ui.grid.innerHTML, /legacy model stats/);
- assert.match(ui.grid.innerHTML, /legacy samples/);
+ assert.match(ui.grid.innerHTML, /data-tone="learning">Learning<\/span>/);
+ assert.match(ui.grid.innerHTML, /First day of learning/);
+ assert.match(ui.grid.innerHTML, /id="forecast-details-pv" hidden/);
+ const main = ui.grid.innerHTML.split('id="forecast-details-pv"')[0];
+ assert.doesNotMatch(main, /Energyplan|samples|quality|MAE|Relearn/);
+ assert.match(ui.grid.innerHTML, /Learning model<\/span>Energyplan<\/b>/);
+ assert.match(ui.grid.innerHTML, /Older local model/);
+ assert.doesNotMatch(ui.grid.innerHTML, /twin-quality|legacy quality|60%/);
assert.match(ui.grid.innerHTML, /role="status" aria-live="polite"/);
assert.doesNotMatch(ui.grid.innerHTML, /50 minutes/i);
- assert.equal(ui.subtitle.textContent, "Forecast learning for solar production and consumption");
+ assert.equal(ui.subtitle.textContent, "Learning from your home");
+ ui.details('pv');
+ assert.match(ui.grid.innerHTML, /data-twin-details="pv" aria-expanded="true"/);
+ assert.doesNotMatch(ui.grid.innerHTML, /id="forecast-details-pv" hidden/);
+ ui.poll();
+ await settle();
+ assert.match(ui.grid.innerHTML, /data-twin-details="pv" aria-expanded="true"/);
+ assert.equal(ui.focused(), 'details:pv');
+ ui.details('pv');
+ assert.match(ui.grid.innerHTML, /id="forecast-details-pv" hidden/);
+});
+
+test('learning age uses only a recorded start and never implies readiness', async () => {
+ const pv = model();
+ pv.learning.started_ms = now - 3 * 86400000;
+ const ui = load({ pv });
+ await settle();
+ assert.match(ui.grid.innerHTML, /3 days learning/);
+ assert.doesNotMatch(ui.grid.innerHTML, /data-tone="healthy"/);
+ for (const start of [0, null, undefined, now + 86400000, NaN]) {
+ pv.learning.started_ms = start;
+ const missing = load({ pv, loadModel: { enabled: false } });
+ await settle();
+ assert.doesNotMatch(missing.grid.innerHTML, /\d+ days? learning|First day of learning/);
+ }
+});
+
+test('Healthy requires confirmed health, ready state, and recent valid training', async () => {
+ const pv = model('ready');
+ pv.learning.health = 'healthy';
+ let ui = load({ pv });
+ await settle();
+ assert.match(ui.grid.innerHTML, /data-tone="healthy">Healthy/);
+ for (const health of [undefined, 'unknown', 'degraded', 'waiting_for_data']) {
+ pv.learning.health = health;
+ ui = load({ pv });
+ await settle();
+ assert.doesNotMatch(ui.grid.innerHTML, /data-tone="healthy"/);
+ if (health === 'degraded') assert.match(ui.grid.innerHTML, /Needs attention/);
+ }
+ pv.learning.health = 'healthy';
+ for (const latest of [0, now + 1, now - 37 * 3600000]) {
+ pv.learning.latest_training_ms = latest;
+ ui = load({ pv });
+ await settle();
+ assert.doesNotMatch(ui.grid.innerHTML, /data-tone="healthy"/);
+ }
+});
+
+test('solar can pause overnight while missing load training needs attention', async () => {
+ const pv = model('ready');
+ pv.learning.health = 'healthy';
+ pv.learning.latest_training_ms = now - 12 * 3600000;
+ const ui = load({ pv, loadModel: pv });
+ await settle();
+ assert.equal((ui.grid.innerHTML.match(/data-tone="healthy"/g) || []).length, 1);
+ assert.match(ui.grid.innerHTML, /Waiting for data/);
+});
+
+test('polls only the visible More destination, including simple mode', async () => {
+ const ui = load({ hash: '#overview' });
+ await settle();
+ assert.equal(ui.requests.length, 0);
+ ui.navigate('#more');
+ await settle();
+ assert.equal(ui.requests.length, 2);
+ assert.equal(ui.timers(), 1);
+ ui.visibility(true);
+ assert.equal(ui.timers(), 0);
+ ui.visibility(false);
+ await settle();
+ assert.equal(ui.requests.length, 4);
+ assert.equal(ui.timers(), 1);
+ ui.navigate('#plan');
+ assert.equal(ui.timers(), 0);
+});
+
+test('escapes health details from the box', async () => {
+ const pv = model();
+ pv.learning.health_reason = ' ';
+ const ui = load({ pv });
+ await settle();
+ assert.doesNotMatch(ui.grid.innerHTML, / {