'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 = '']) => `