From c3aeff2c7c4d4b97360cf7e418065b669246004e Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 17 Sep 2026 08:40:13 +0200 Subject: [PATCH] feat(web): simplify forecast status and learning details --- .changeset/simple-forecast-status.md | 5 + web/diagnose.js | 2 +- web/index.html | 10 +- web/style.css | 41 +++++-- web/twins.js | 157 ++++++++++++++++++--------- web/twins.test.mjs | 127 +++++++++++++++++++--- 6 files changed, 259 insertions(+), 83 deletions(-) create mode 100644 .changeset/simple-forecast-status.md diff --git a/.changeset/simple-forecast-status.md b/.changeset/simple-forecast-status.md new file mode 100644 index 000000000..7f8772421 --- /dev/null +++ b/.changeset/simple-forecast-status.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Show simple forecast status and recorded learning days in More. Put model diagnostics and relearning behind Details, remove misleading training-quality bars, and require an explicit health check before showing Healthy. diff --git a/web/diagnose.js b/web/diagnose.js index 054cfacb5..49296e831 100644 --- a/web/diagnose.js +++ b/web/diagnose.js @@ -81,7 +81,7 @@ append(energy, '.history-row'); append(plan, '#plan-section'); append(plan, '#plan-history-details'); - ['#ui-mode-row', '#twins-section', '#loadpoints-section', '#drivers-section', '#models-section'] + ['#twins-section', '#ui-mode-row', '#loadpoints-section', '#drivers-section', '#models-section'] .forEach(selector => append(more, selector)); const oldChartRow = document.getElementById('live-plan-row'); diff --git a/web/index.html b/web/index.html index 6c5416220..c78a8ab8d 100644 --- a/web/index.html +++ b/web/index.html @@ -30,7 +30,7 @@ - + -
+ +
-

ML twins

+

Forecasts

@@ -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 ``; + return ``; } - 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 statssecondary
'); - 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 stateUnavailable
The box did not provide this model state.
`; - if (!d.enabled) return `

${title}

model stateDisabled
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)}

` : ''}` + + `` + + `
${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, / {