'use strict'; const LANG_KEY = 'heatpump.lang'; const HOUR = 3600e3; const state = { lang: 'en', events: [], // [{ state, t }] sorted by time (ms) phases: [], // [{ start, end, dur }] completed on-phases (ms) dataMin: 0, dataMax: 0, range: null, // [from, to] in ms, or null = everything source: '', error: null, // { key, params } so it can be re-rendered on language change }; const $ = id => document.getElementById(id); const css = name => getComputedStyle(document.documentElement).getPropertyValue(name).trim(); // ---------- i18n ---------- function t(key, params = {}) { const s = I18N[state.lang][key] ?? I18N.en[key] ?? key; return typeof s === 'string' ? s.replace(/\{(\w+)\}/g, (_, k) => params[k] ?? '') : s; } function detectLang() { try { const saved = localStorage.getItem(LANG_KEY); if (saved in I18N) return saved; } catch { /* storage unavailable */ } return (navigator.language || '').toLowerCase().startsWith('de') ? 'de' : 'en'; } function setLang(lang) { state.lang = lang; try { localStorage.setItem(LANG_KEY, lang); } catch { /* storage unavailable */ } document.documentElement.lang = lang; document.title = t('title'); document.querySelectorAll('[data-i18n]').forEach(el => { // only the note contains markup, and it comes from our own dictionary if (el.dataset.i18n === 'note') el.innerHTML = t('note'); else el.textContent = t(el.dataset.i18n); }); document.querySelectorAll('[data-lang]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.lang === lang))); render(); } // ---------- formatting ---------- const pad = n => String(n).padStart(2, '0'); // Local-time string for Plotly: it treats date strings without zone as-is, // so the axis shows the viewer's local time instead of UTC. function plotlyDate(ms) { const d = new Date(ms); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` + `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; } const fromPlotlyDate = v => typeof v === 'number' ? v : new Date(String(v).replace(' ', 'T')).getTime(); const fmtDateTime = ms => new Date(ms).toLocaleString(t('locale'), { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }); const fmtTime = ms => new Date(ms).toLocaleTimeString(t('locale'), { hour: '2-digit', minute: '2-digit' }); const fmtTableDate = ms => new Date(ms).toLocaleString(t('locale'), { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }); function fmtDur(ms) { if (ms == null || !Number.isFinite(ms)) return '–'; const u = t('units'); const s = Math.round(ms / 1000), h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60); return h ? `${h}${u.h} ${pad(m)}${u.m}` : `${m}${u.m} ${pad(s % 60)}${u.s}`; } function fmtSpan(ms) { const u = t('units'); const m = Math.round(ms / 60000), d = Math.floor(m / 1440), h = Math.floor(m % 1440 / 60); if (d) return h ? `${d} ${u.d} ${h} ${u.h}` : `${d} ${u.d}`; return h ? `${h} ${u.h} ${pad(m % 60)} ${u.m}` : `${m} ${u.m}`; } const fmtPercent = x => new Intl.NumberFormat(t('locale'), { style: 'percent', minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(x); const escapeHtml = s => String(s).replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`); // ---------- data ---------- class CsvError extends Error { constructor(key) { super(key); this.key = key; } } // Parses a Home Assistant history export (entity_id,state,last_changed). function parseCsv(text) { const lines = text.replace(/^/, '').trim().split(/\r?\n/); const cells = line => line.split(',').map(c => c.trim().replace(/^"(.*)"$/, '$1')); const head = cells(lines.shift() || ''); const iState = head.indexOf('state'), iTime = head.indexOf('last_changed'); if (iState < 0 || iTime < 0) throw new CsvError('errColumns'); const events = lines .map(cells) .map(c => ({ state: c[iState], t: Date.parse(c[iTime]) })) .filter(e => e.state && Number.isFinite(e.t)) .sort((a, b) => a.t - b.t); if (!events.length) throw new CsvError('errEmpty'); // An on-phase starts at the first "on" and ends at the next non-"on" state. const phases = []; let start = null; for (const e of events) { if (e.state === 'on') { if (start === null) start = e.t; } else if (start !== null) { phases.push({ start, end: e.t, dur: e.t - start }); start = null; } } return { events, phases }; } function load(text, source) { try { const { events, phases } = parseCsv(text); Object.assign(state, { events, phases, source, error: null, range: null, dataMin: events[0].t, dataMax: events[events.length - 1].t, }); } catch (err) { if (!(err instanceof CsvError)) throw err; state.error = { key: err.key }; } render(); } function stats(from, to) { const inRange = state.phases.filter(p => p.start >= from && p.start <= to); const d = inRange.map(p => p.dur).sort((a, b) => a - b); const n = d.length; const mean = arr => arr.length ? arr.reduce((s, v) => s + v, 0) / arr.length : null; const onTime = state.phases.reduce((s, p) => s + Math.max(0, Math.min(p.end, to) - Math.max(p.start, from)), 0); return { inRange, n, min: n ? d[0] : null, max: n ? d[n - 1] : null, mean: mean(d), median: n ? (n % 2 ? d[(n - 1) / 2] : (d[n / 2 - 1] + d[n / 2]) / 2) : null, onTime, duty: to > from ? onTime / (to - from) : null, meanGap: mean(inRange.slice(1).map((p, i) => p.start - inRange[i].end)), }; } // ---------- rendering ---------- const tilesHtml = tiles => tiles.map(([key, value, cls = '']) => `
${escapeHtml(t(key))}
${escapeHtml(value)}
`).join(''); function render() { const src = $('source'); if (state.error) { src.textContent = t(state.error.key, state.error.params); src.className = 'sub error'; } else { src.textContent = state.source || t('loading'); src.className = 'sub'; } if (!state.events.length) return; $('dataTiles').innerHTML = tilesHtml([ ['from', fmtDateTime(state.dataMin), 'sm'], ['to', fmtDateTime(state.dataMax), 'sm'], ['span', fmtSpan(state.dataMax - state.dataMin)], ['events', state.events.length], ['onPhases', state.phases.length], ]); drawChart(); renderStats(); } function drawChart() { const accent = css('--accent'), ink = css('--ink'), ink2 = css('--ink-2'), line = css('--line'); // Step line for context; unknown/unavailable states become gaps. const stepTrace = { type: 'scatter', mode: 'lines', hoverinfo: 'skip', connectgaps: false, x: state.events.map(e => plotlyDate(e.t)), y: state.events.map(e => e.state === 'on' ? 1 : e.state === 'off' ? 0 : null), line: { shape: 'hv', width: 1.5, color: accent }, }; // One bar per on-phase spanning its real duration; it carries the hover tooltip. const phaseTrace = { type: 'bar', x: state.phases.map(p => plotlyDate(p.start + p.dur / 2)), y: state.phases.map(() => 1), width: state.phases.map(p => p.dur), customdata: state.phases.map(p => [fmtDateTime(p.start), fmtTime(p.end), fmtDur(p.dur)]), hovertemplate: '%{customdata[2]}
%{customdata[0]} → %{customdata[1]}', marker: { color: css('--accent-fill'), line: { width: 0 } }, }; const axis = { gridcolor: line, zerolinecolor: line, linecolor: line, tickfont: { color: ink2 } }; const layout = { margin: { l: 40, r: 12, t: 8, b: 36 }, paper_bgcolor: 'rgba(0,0,0,0)', plot_bgcolor: 'rgba(0,0,0,0)', font: { family: 'system-ui, sans-serif', color: ink2, size: 12 }, showlegend: false, dragmode: 'zoom', hovermode: 'x', hoverdistance: 30, bargap: 0, hoverlabel: { bgcolor: css('--surface'), bordercolor: line, font: { color: ink } }, xaxis: { ...axis, type: 'date', ...(state.range ? { range: state.range.map(plotlyDate) } : { autorange: true }), }, yaxis: { ...axis, fixedrange: true, range: [-0.05, 1.15], tickvals: [0, 1], ticktext: [t('off'), t('on')], }, }; const config = { responsive: true, displaylogo: false, scrollZoom: true, locale: state.lang, modeBarButtonsToRemove: ['select2d', 'lasso2d', 'autoScale2d'], }; const chart = $('chart'); Plotly.react(chart, [stepTrace, phaseTrace], layout, config); if (!chart.dataset.bound) { chart.dataset.bound = '1'; chart.on('plotly_relayout', onRelayout); } } // Keeps the statistics in sync with zoom / pan / reset in the chart. function onRelayout(ev) { if (ev['xaxis.autorange']) state.range = null; else if (ev['xaxis.range[0]'] !== undefined) state.range = [ev['xaxis.range[0]'], ev['xaxis.range[1]']].map(fromPlotlyDate); else if (ev['xaxis.range']) state.range = ev['xaxis.range'].map(fromPlotlyDate); else return; renderStats(); } function setRange(range) { state.range = range; Plotly.relayout($('chart'), range ? { 'xaxis.range': range.map(plotlyDate) } : { 'xaxis.autorange': true }); renderStats(); } function renderStats() { const [from, to] = state.range || [state.dataMin, state.dataMax]; const s = stats(from, to); $('range').textContent = `${fmtDateTime(from)} → ${fmtDateTime(to)} (${fmtSpan(to - from)})`; $('tiles').innerHTML = tilesHtml([ ['phases', s.n], ['min', fmtDur(s.min)], ['max', fmtDur(s.max)], ['mean', fmtDur(s.mean)], ['median', fmtDur(s.median)], ['totalOn', fmtDur(s.onTime)], ['dutyCycle', s.duty == null ? '–' : fmtPercent(s.duty)], ['meanGap', fmtDur(s.meanGap)], ]); const first = state.phases.indexOf(s.inRange[0]); $('rows').innerHTML = s.inRange.map((p, i) => { const prev = state.phases[first + i - 1]; return `${i + 1}${fmtTableDate(p.start)}${fmtTableDate(p.end)}` + `${fmtDur(p.dur)}${prev ? fmtDur(p.start - prev.end) : '–'}`; }).join(''); } // ---------- wiring ---------- document.querySelectorAll('[data-lang]').forEach(btn => btn.addEventListener('click', () => setLang(btn.dataset.lang))); document.querySelectorAll('[data-span]').forEach(btn => btn.addEventListener('click', () => { if (!state.events.length) return; const span = btn.dataset.span; if (span === 'all') return setRange(null); const to = state.range ? state.range[1] : state.dataMax; setRange([to - Number(span) * HOUR, to]); })); $('file').addEventListener('change', async e => { const file = e.target.files[0]; e.target.value = ''; // allow re-selecting the same file if (file) load(await file.text(), file.name); }); matchMedia('(prefers-color-scheme: dark)').addEventListener('change', render); setLang(detectLang()); fetch('history.csv', { cache: 'no-cache' }) .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.text(); }) .then(text => load(text, 'history.csv')) .catch(err => { state.error = { key: 'errFetch', params: { status: err.message } }; render(); });