Files
gree-controller/web/js/history.js
T

375 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
function outdoorHistorySeries(devices, rows) {
const selectedIds = new Set((devices || []).map(device => device.id));
const handled = new Set();
const definitions = [];
(app.deviceGroups || []).forEach(group => {
if (!group.outdoor_temperature_device_id) return;
const members = (group.device_ids || []).filter(id => selectedIds.has(id));
if (!members.length) return;
const representative = members.includes(group.outdoor_temperature_device_id) ? group.outdoor_temperature_device_id : members[0];
if (!rows.some(row => row.device_id === representative && Number.isFinite(historyNumber(row.outdoor_temperature)))) return;
members.forEach(id => handled.add(id));
definitions.push({ representative, label: `${group.name} · ${tr('history.sharedOutdoor')}` });
});
(devices || []).filter(device => !handled.has(device.id)).forEach(device => {
if (rows.some(row => row.device_id === device.id && Number.isFinite(historyNumber(row.outdoor_temperature)))) {
definitions.push({ representative: device.id, label: device.name });
}
});
return definitions.map((item, index) => ({
label: item.label,
color: historySeriesColor(index),
value: row => row.device_id === item.representative ? historyNumber(row.outdoor_temperature) : NaN,
}));
}
function renderHistorySummary() {
const host = $('#historySummary'); if (!host) return;
if (app.historyTab === 'energy') { renderEnergyHistorySummary(); return; }
if (app.historyTab === 'network') { renderNetworkHistorySummary(); return; }
const deviceRows = app.historyData.devices, zoneRows = app.historyData.zones, sensorRows = app.historyData.sensors;
const cards = [
[tr('history.deviceSamples'), app.historyCounts.devices ?? deviceRows.length, tr('history.greeHistory')],
[tr('history.zoneSamples'), app.historyCounts.zones ?? zoneRows.length, tr('history.zoneHistory')],
[tr('history.haSamples'), app.historyCounts.ha ?? sensorRows.length, tr('history.haHistory')],
];
host.innerHTML = cards.map(([label, value, detail]) => `<div class="history-stat"><small>${esc(label)}</small><strong>${esc(value)}</strong><span>${esc(detail)}</span></div>`).join('');
}
function renderOverviewHistory() {
const host = $('#historyCharts');
host.innerHTML = historyChartMarkup('overviewIndoorChart', tr('history.allGreeIndoor'), tr('history.allGreeIndoorHint'))
+ historyChartMarkup('overviewOutdoorChart', tr('history.allOutdoor'), tr('history.allOutdoorHint'))
+ historyChartMarkup('overviewZonesChart', tr('history.allZoneControl'), tr('history.allZoneControlHint'));
const deviceRows = app.historyData.devices;
const indoorSeries = app.devices.map((device, index) => ({ label: device.name, color: historySeriesColor(index), value: row => !row.zone_id && !row.entity_id && row.device_id === device.id ? historyNumber(row.indoor_temperature) : NaN }));
drawLineChart($('#overviewIndoorChart'), indoorSeries, deviceRows, { height: 360 }); renderLegend($('#overviewIndoorChartLegend'), indoorSeries);
const outdoorDeviceSeries = outdoorHistorySeries(app.devices, deviceRows).map(item => ({ ...item, label: item.label.includes(' · ') ? item.label : `${item.label} · ${tr('history.greeOutdoor')}` }));
const outdoorEntities = [...new Set(app.historyData.sensors.filter(row => row.kind === 'outdoor').map(row => row.entity_id))];
const outdoorHaSeries = outdoorEntities.map((entity, index) => ({ label: `HA · ${haSensorLabel(entity)}`, color: historySeriesColor(index + outdoorDeviceSeries.length), dash: [6, 4], value: row => row.entity_id === entity ? historyNumber(row.temperature) : NaN }));
const outdoorRows = [...deviceRows, ...app.historyData.sensors.filter(row => row.kind === 'outdoor')];
const outdoorSeries = [...outdoorDeviceSeries, ...outdoorHaSeries];
drawLineChart($('#overviewOutdoorChart'), outdoorSeries, outdoorRows, { height: 320 }); renderLegend($('#overviewOutdoorChartLegend'), outdoorSeries);
const zoneRows = app.historyData.zones;
const zoneSeries = app.zones.map((zone, index) => ({ label: zone.name, color: historySeriesColor(index), value: row => row.zone_id === zone.id ? historyNumber(row.control_temperature) : NaN }));
drawLineChart($('#overviewZonesChart'), zoneSeries, zoneRows, { height: 340 }); renderLegend($('#overviewZonesChartLegend'), zoneSeries);
}
function renderZoneHistory() {
const selected = app.historyZone;
const rows = selected === 'all' ? app.historyData.zones : app.historyData.zones.filter(row => row.zone_id === selected);
const host = $('#historyCharts');
host.innerHTML = historyChartMarkup('zoneTemperatureChart', tr('history.temperatureOverview'), tr('history.temperatureOverviewHint')) + historyChartMarkup('zoneOperationChart', tr('history.operationOverview'), tr('history.operationOverviewHint'), true);
if (selected === 'all') {
const series = app.zones.map((zone, index) => ({ label: zone.name, color: historySeriesColor(index), value: row => row.zone_id === zone.id ? historyNumber(row.control_temperature) : NaN }));
drawLineChart($('#zoneTemperatureChart'), series, rows, { height: 360 }); renderLegend($('#zoneTemperatureChartLegend'), series);
const targets = app.zones.map((zone, index) => ({ label: `${zone.name} · ${tr('history.targetShort')}`, color: historySeriesColor(index), dash: [5, 4], value: row => row.zone_id === zone.id ? historyNumber(row.target_temperature) : NaN }));
drawLineChart($('#zoneOperationChart'), targets, rows, { height: 260 }); renderLegend($('#zoneOperationChartLegend'), targets);
return;
}
const temperatureSeries = [
{ label: tr('history.greeSensor'), color: cssColor('--accent', '#3ecf8e'), value: row => historyNumber(row.gree_temperature) },
{ label: tr('history.roomSensor'), color: cssColor('--info', '#60a5fa'), value: row => historyNumber(row.external_temperature) },
{ label: tr('history.controlTemperature'), color: cssColor('--teal', '#2dd4bf'), width: 2.8, value: row => historyNumber(row.control_temperature) },
{ label: tr('history.comfortTarget'), color: cssColor('--warning', '#f59e0b'), dash: [7, 5], value: row => historyNumber(row.target_temperature) },
{ label: tr('history.deviceSetpoint'), color: cssColor('--purple', '#a78bfa'), dash: [3, 4], value: row => historyNumber(row.device_setpoint) },
{ label: tr('history.outdoorTemperature'), color: cssColor('--muted-strong', '#9ca3af'), dash: [2, 5], value: row => historyNumber(row.outdoor_temperature) },
];
drawLineChart($('#zoneTemperatureChart'), temperatureSeries, rows, { height: 360 }); renderLegend($('#zoneTemperatureChartLegend'), temperatureSeries);
const operationSeries = [
{ label: tr('history.fanSpeed'), color: cssColor('--info', '#60a5fa'), step: true, value: row => historyNumber(row.fan_speed), tooltipValue: value => fanLabel(Math.round(value)) },
{ label: tr('history.demand'), color: cssColor('--accent', '#3ecf8e'), step: true, width: 2.4, value: row => row.demand ? 4.5 : 0.5, tooltipValue: (_, row) => row.demand ? tr('common.active') : tr('common.disabled') },
{ label: tr('history.power'), color: cssColor('--warning', '#f59e0b'), step: true, dash: [5, 4], value: row => row.power ? 3.5 : 0.5, tooltipValue: (_, row) => row.power ? tr('common.on') : tr('common.off') },
];
drawLineChart($('#zoneOperationChart'), operationSeries, rows, { height: 260, minValue: 0, maxValue: 5, binaryLabels: true }); renderLegend($('#zoneOperationChartLegend'), operationSeries);
}
function renderDeviceHistory() {
const selected = app.historyDevice;
const rows = selected === 'all' ? app.historyData.devices : app.historyData.devices.filter(row => row.device_id === selected);
const host = $('#historyCharts');
host.innerHTML = historyChartMarkup('deviceIndoorChart', tr('history.deviceIndoor'), tr('history.deviceIndoorHint')) + historyChartMarkup('deviceOutdoorChart', tr('history.deviceOutdoor'), tr('history.deviceOutdoorHint')) + historyChartMarkup('deviceTargetChart', tr('history.deviceTargets'), tr('history.deviceTargetsHint'), true);
const devices = selected === 'all' ? app.devices : app.devices.filter(device => device.id === selected);
const indoor = devices.map((device, index) => ({ label: device.name, color: historySeriesColor(index), value: row => row.device_id === device.id ? historyNumber(row.indoor_temperature) : NaN }));
const outdoor = outdoorHistorySeries(devices, rows);
const targets = devices.map((device, index) => ({ label: device.name, color: historySeriesColor(index), dash: [6, 4], value: row => row.device_id === device.id ? historyNumber(row.target_temperature) : NaN }));
drawLineChart($('#deviceIndoorChart'), indoor, rows, { height: 350 }); renderLegend($('#deviceIndoorChartLegend'), indoor);
drawLineChart($('#deviceOutdoorChart'), outdoor, rows, { height: 310 }); renderLegend($('#deviceOutdoorChartLegend'), outdoor);
drawLineChart($('#deviceTargetChart'), targets, rows, { height: 260 }); renderLegend($('#deviceTargetChartLegend'), targets);
}
function renderSensorHistory() {
const selected = app.historySensor;
const rows = selected === 'all' ? app.historyData.sensors : app.historyData.sensors.filter(row => row.entity_id === selected);
const entities = [...new Set(rows.map(row => row.entity_id))];
const host = $('#historyCharts');
host.innerHTML = historyChartMarkup('haSensorsChart', tr('history.haSensors'), tr('history.haSensorsHint'));
const series = entities.map((entity, index) => ({ label: haSensorLabel(entity), color: historySeriesColor(index), dash: rows.some(row => row.entity_id === entity && row.kind === 'outdoor') ? [6, 4] : [], value: row => row.entity_id === entity ? historyNumber(row.temperature) : NaN }));
drawLineChart($('#haSensorsChart'), series, rows, { height: 370 }); renderLegend($('#haSensorsChartLegend'), series);
}
function networkTargetRows() {
const selected = app.historyNetworkTarget || 'all';
return selected === 'all' ? (app.historyNetwork || []) : (app.historyNetwork || []).filter(row => row.target_id === selected);
}
function networkTargetLabel(id) {
return (app.historyNetworkTargets || []).find(target => target.id === id)?.name || id;
}
function renderNetworkHistorySummary() {
const host = $('#historySummary'); if (!host) return;
const rows = networkTargetRows();
const ids = [...new Set(rows.map(row => row.target_id))];
if (!ids.length) { host.innerHTML = ''; return; }
host.innerHTML = ids.map(id => {
const latest = rows.filter(row => row.target_id === id).sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))[0];
const latency = Number.isFinite(Number(latest?.latency_ms)) ? `${Number(latest.latency_ms).toLocaleString(locale(), { maximumFractionDigits: 1 })} ms` : '—';
const jitter = Number.isFinite(Number(latest?.jitter_ms)) ? `${Number(latest.jitter_ms).toLocaleString(locale(), { maximumFractionDigits: 1 })} ms` : '—';
const loss = `${Number(latest?.packet_loss_pct || 0).toLocaleString(locale(), { maximumFractionDigits: 1 })}%`;
return `<div class="history-stat"><small>${esc(networkTargetLabel(id))}</small><strong>${esc(latency)}</strong><span>${esc(tr('history.networkJitter'))}: ${esc(jitter)} · ${esc(tr('history.networkLoss'))}: ${esc(loss)} · ${esc(latest?.successful_samples ?? 0)}/${esc(latest?.sample_count ?? 0)}</span></div>`;
}).join('');
}
function renderNetworkHistory() {
const host = $('#historyCharts'); if (!host) return;
renderNetworkHistorySummary();
const rows = networkTargetRows();
if (!rows.length) {
host.innerHTML = `<div class="empty"><strong>${esc(tr('history.networkNoData'))}</strong><span>${esc(tr('history.networkNoDataHint'))}</span></div>`;
return;
}
const ids = [...new Set(rows.map(row => row.target_id))];
const latencySeries = [];
const lossSeries = [];
ids.forEach((id, index) => {
const label = networkTargetLabel(id);
const color = historySeriesColor(index);
latencySeries.push({
key: `${id}:latency`, label: `${label} · ${tr('history.networkLatency')}`, color,
value: row => row.target_id === id ? historyNumber(row.latency_ms) : NaN,
tooltipValue: value => `${Number(value).toLocaleString(locale(), { maximumFractionDigits: 2 })} ms`,
});
latencySeries.push({
key: `${id}:jitter`, label: `${label} · ${tr('history.networkJitter')}`, color, dash: [5, 4],
value: row => row.target_id === id ? historyNumber(row.jitter_ms) : NaN,
tooltipValue: value => `${Number(value).toLocaleString(locale(), { maximumFractionDigits: 2 })} ms`,
});
lossSeries.push({
key: `${id}:loss`, label, color,
value: row => row.target_id === id ? historyNumber(row.packet_loss_pct) : NaN,
tooltipValue: value => `${Number(value).toLocaleString(locale(), { maximumFractionDigits: 2 })}%`,
});
});
host.innerHTML = historyChartMarkup('networkLatencyChart', tr('history.networkLatencyJitter'), tr('history.networkLatencyJitterHint'))
+ historyChartMarkup('networkLossChart', tr('history.networkLoss'), tr('history.networkLossHint'));
drawLineChart($('#networkLatencyChart'), latencySeries, rows, { height: 360, minValue: 0, axisSuffix: ' ms', tooltipUnit: ' ms', axisDigits: 0 });
renderLegend($('#networkLatencyChartLegend'), latencySeries);
drawLineChart($('#networkLossChart'), lossSeries, rows, { height: 300, minValue: 0, maxValue: 100, axisSuffix: '%', tooltipUnit: '%', axisDigits: 0 });
renderLegend($('#networkLossChartLegend'), lossSeries);
}
function customSeriesOptions() {
const items = [];
const groupedOutdoorIds = new Set();
(app.deviceGroups || []).forEach(group => {
if (!group.outdoor_temperature_device_id) return;
const representative = (group.device_ids || []).includes(group.outdoor_temperature_device_id)
? group.outdoor_temperature_device_id
: (group.device_ids || [])[0];
if (!representative) return;
(group.device_ids || []).forEach(id => groupedOutdoorIds.add(id));
items.push([`installation|${group.id}|outdoor`, `${group.name} · ${tr('history.sharedOutdoor')}`]);
});
app.devices.forEach(device => {
items.push([`device|${device.id}|indoor`, `${device.name} · ${tr('history.indoorTemperature')}`]);
if (!groupedOutdoorIds.has(device.id)) items.push([`device|${device.id}|outdoor`, `${device.name} · ${tr('history.greeOutdoor')}`]);
items.push([`device|${device.id}|target`, `${device.name} · ${tr('history.deviceTarget')}`]);
});
app.zones.forEach(zone => {
items.push([`zone|${zone.id}|control`, `${zone.name} · ${tr('history.controlTemperature')}`]);
items.push([`zone|${zone.id}|gree`, `${zone.name} · ${tr('history.greeSensor')}`]);
items.push([`zone|${zone.id}|external`, `${zone.name} · ${tr('history.roomSensor')}`]);
items.push([`zone|${zone.id}|target`, `${zone.name} · ${tr('history.comfortTarget')}`]);
items.push([`zone|${zone.id}|device_target`, `${zone.name} · ${tr('history.deviceSetpoint')}`]);
items.push([`zone|${zone.id}|outdoor`, `${zone.name} · ${tr('history.outdoorTemperature')}`]);
});
[...new Set(app.historyData.sensors.map(row => row.entity_id))].forEach(entity => items.push([`ha|${entity}|temperature`, `HA · ${haSensorLabel(entity)}`]));
return items;
}
function customSeriesDefinition(key, index = 0) {
const [kind, id, field] = String(key).split('|');
const color = historySeriesColor(index);
if (kind === 'device') {
const device = app.devices.find(item => item.id === id); if (!device) return null;
const labels = { indoor: tr('history.indoorTemperature'), outdoor: tr('history.greeOutdoor'), target: tr('history.deviceTarget') };
const fields = { indoor: 'indoor_temperature', outdoor: 'outdoor_temperature', target: 'target_temperature' };
return { key, label: `${device.name} · ${labels[field] || field}`, color, dash: field === 'target' ? [6, 4] : [], value: row => !row.zone_id && !row.entity_id && row.device_id === id ? historyNumber(row[fields[field]]) : NaN };
}
if (kind === 'installation' && field === 'outdoor') {
const group = (app.deviceGroups || []).find(item => item.id === id); if (!group) return null;
const representative = (group.device_ids || []).includes(group.outdoor_temperature_device_id)
? group.outdoor_temperature_device_id
: (group.device_ids || [])[0];
if (!representative) return null;
return { key, label: `${group.name} · ${tr('history.sharedOutdoor')}`, color, value: row => !row.zone_id && !row.entity_id && row.device_id === representative ? historyNumber(row.outdoor_temperature) : NaN };
}
if (kind === 'zone') {
const zone = app.zones.find(item => item.id === id); if (!zone) return null;
const fields = { control: ['control_temperature', tr('history.controlTemperature')], gree: ['gree_temperature', tr('history.greeSensor')], external: ['external_temperature', tr('history.roomSensor')], target: ['target_temperature', tr('history.comfortTarget')], device_target: ['device_setpoint', tr('history.deviceSetpoint')], outdoor: ['outdoor_temperature', tr('history.outdoorTemperature')] };
const info = fields[field]; if (!info) return null;
return { key, label: `${zone.name} · ${info[1]}`, color, dash: ['target', 'device_target', 'outdoor'].includes(field) ? [6, 4] : [], value: row => row.zone_id === id ? historyNumber(row[info[0]]) : NaN };
}
if (kind === 'ha') return { key, label: `HA · ${haSensorLabel(id)}`, color, dash: [3, 4], value: row => row.entity_id === id ? historyNumber(row.temperature) : NaN };
return null;
}
function persistSavedCharts() {
localStorage.setItem('gree_controller_saved_charts', JSON.stringify(app.savedCharts.slice(0, 30)));
}
function renderCustomBuilder() {
const host = $('#historyCustomBuilder'); if (!host) return;
if (app.historyTab !== 'custom') { host.innerHTML = ''; host.classList.remove('active'); return; }
host.classList.add('active');
const options = customSeriesOptions();
const selected = app.customChartSeries.map((key, index) => customSeriesDefinition(key, index)).filter(Boolean);
host.innerHTML = `<div class="panel custom-chart-panel"><div class="chart-title-row"><div><h3>${esc(tr('history.customTitle'))}</h3><p>${esc(tr('history.customDescription'))}</p></div></div>
<div class="custom-chart-add"><select id="customSeriesSelect">${options.map(([value, label]) => `<option value="${esc(value)}">${esc(label)}</option>`).join('')}</select><button class="secondary" data-history-action="add-series">${esc(tr('history.addSeries'))}</button></div>
<div class="custom-series-list">${selected.length ? selected.map((item, index) => `<span class="custom-series-chip"><i style="--chip-color:${esc(item.color)}"></i>${esc(item.label)}<button data-history-action="remove-series" data-index="${index}" aria-label="${esc(tr('actions.remove'))}">×</button></span>`).join('') : `<span class="field-note">${esc(tr('history.noCustomSeries'))}</span>`}</div>
<div class="custom-chart-save"><input id="customChartName" placeholder="${esc(tr('history.chartName'))}"><button class="secondary" data-history-action="save-chart">${esc(tr('actions.save'))}</button><button class="primary" data-history-action="copy-chart-link">${esc(tr('history.copyLink'))}</button></div>
<div class="saved-chart-list">${app.savedCharts.length ? app.savedCharts.map(item => `<div class="saved-chart-row"><button data-history-action="load-chart" data-id="${esc(item.id)}"><strong>${esc(item.name)}</strong><small>${esc(item.series.length)} ${esc(tr('history.series'))}</small></button><button class="danger" data-history-action="delete-chart" data-id="${esc(item.id)}">×</button></div>`).join('') : `<span class="field-note">${esc(tr('history.noSavedCharts'))}</span>`}</div>
</div>`;
}
function renderCustomHistory() {
renderCustomBuilder();
const host = $('#historyCharts');
host.innerHTML = historyChartMarkup('customHistoryChart', tr('history.customChart'), tr('history.customChartHint'));
const series = app.customChartSeries.map((key, index) => customSeriesDefinition(key, index)).filter(Boolean);
const rows = [...app.historyData.devices, ...app.historyData.zones, ...app.historyData.sensors];
drawLineChart($('#customHistoryChart'), series, rows, { height: 390 }); renderLegend($('#customHistoryChartLegend'), series);
}
function renderHistoryPage() {
renderHistorySummary();
renderCustomBuilder();
if (app.historyTab === 'overview') renderOverviewHistory();
else if (app.historyTab === 'zones') renderZoneHistory();
else if (app.historyTab === 'devices') renderDeviceHistory();
else if (app.historyTab === 'energy') renderEnergyHistory();
else if (app.historyTab === 'network') renderNetworkHistory();
else if (app.historyTab === 'sensors') renderSensorHistory();
else renderCustomHistory();
}
function drawCurrentChartIfVisible() {
if (app.currentView === 'history') renderHistoryPage();
}
async function handleHistoryAction(button) {
const action = button.dataset.historyAction;
if (action === 'add-series') {
const value = $('#customSeriesSelect')?.value;
if (value && !app.customChartSeries.includes(value)) app.customChartSeries.push(value);
renderCustomHistory(); updateBrowserUrl(currentHistoryPath(), true); return;
}
if (action === 'remove-series') {
app.customChartSeries.splice(Number(button.dataset.index), 1);
renderCustomHistory(); updateBrowserUrl(currentHistoryPath(), true); return;
}
if (action === 'save-chart') {
if (!app.customChartSeries.length) return toast(tr('history.noCustomSeries'), true);
const name = $('#customChartName')?.value.trim() || tr('history.customChart');
const item = { id: `chart-${Date.now()}`, name, series: [...app.customChartSeries], hours: $('#historyHours')?.value || '24' };
app.savedCharts.unshift(item); persistSavedCharts(); renderCustomHistory(); toast(tr('history.chartSaved')); return;
}
if (action === 'load-chart') {
const item = app.savedCharts.find(entry => entry.id === button.dataset.id); if (!item) return;
app.customChartSeries = [...item.series]; if ($('#historyHours') && item.hours) $('#historyHours').value = String(item.hours);
renderCustomHistory(); updateBrowserUrl(currentHistoryPath()); return;
}
if (action === 'delete-chart') {
app.savedCharts = app.savedCharts.filter(entry => entry.id !== button.dataset.id); persistSavedCharts(); renderCustomHistory(); return;
}
if (action === 'copy-chart-link') {
if (!app.customChartSeries.length) return toast(tr('history.noCustomSeries'), true);
const path = currentHistoryPath(); updateBrowserUrl(path, true); const link = `${location.origin}${APP_BASE}${path}`;
try { await navigator.clipboard.writeText(link); } catch (_) { const area = document.createElement('textarea'); area.value = link; document.body.appendChild(area); area.select(); document.execCommand('copy'); area.remove(); }
toast(tr('history.linkCopied')); return;
}
}
async function loadEnergyHistory() {
const targets = normalizeEnergyHistoryTargets();
if (!targets.length || !app.historyEnergyTargets.length) {
app.historyEnergy = [];
return;
}
const hours = Number($('#historyHours')?.value || 24);
const days = Math.max(1, Math.ceil(hours / 24));
const selected = app.historyEnergyTargets.slice(0, 8);
app.historyEnergy = await Promise.all(selected.map(targetId => api(`/api/history/energy?target_id=${encodeURIComponent(targetId)}&interval=${encodeURIComponent(app.historyEnergyInterval)}&days=${days}&limit=100000&compare=${encodeURIComponent(app.historyEnergyCompare)}`)));
}
function renderEnergyHistorySummary() {
const host = $('#historySummary'); if (!host) return;
const data = (app.historyEnergy || []).filter(item => item && item.source !== 'none');
if (!data.length) { host.innerHTML = ''; return; }
if (data.length === 1) {
const item = data[0];
const summary = item.summary || {};
const rows = [
[tr('energy.today'), summary.today],
[tr('energy.yesterday'), summary.yesterday],
[tr('energy.currentMonth'), summary.current_month],
[tr('energy.previousMonth'), summary.previous_month],
[tr('energy.periodTotal'), summary.period_total],
];
if (item.comparison) rows.push([tr('energy.comparisonPeriod'), item.comparison.period_total]);
host.innerHTML = rows.map(([label, value]) => `<div class="history-stat"><small>${esc(label)}</small><strong>${Number(value || 0).toLocaleString(locale(), { maximumFractionDigits: 3 })} kWh</strong><span>${esc(item.target_name || '')} · ${esc(item.source === 'gree_cloud' ? 'GREE Cloud' : 'Home Assistant')}</span></div>`).join('');
return;
}
host.innerHTML = data.map(item => `<div class="history-stat"><small>${esc(item.target_name || item.target_id)}</small><strong>${Number(item.summary?.period_total || 0).toLocaleString(locale(), { maximumFractionDigits: 3 })} kWh</strong><span>${esc(item.source === 'gree_cloud' ? 'GREE Cloud' : 'Home Assistant')}${item.comparison ? ` · ${esc(tr('energy.comparisonPeriod'))}: ${Number(item.comparison.period_total || 0).toLocaleString(locale(), { maximumFractionDigits: 3 })} kWh` : ''}</span></div>`).join('');
}
function renderEnergyHistory() {
const host = $('#historyCharts'); if (!host) return;
const data = (app.historyEnergy || []).filter(item => item && item.source !== 'none');
renderEnergyHistorySummary();
if (!data.length) {
host.innerHTML = `<div class="empty"><strong>${esc(tr('energy.noData'))}</strong></div>`;
return;
}
const series = [];
data.forEach((item, index) => {
const color = historySeriesColor(index);
series.push({
key: `${item.target_id}:current`,
label: data.length === 1 ? `${item.target_name} · ${tr('energy.currentPeriod')}` : item.target_name,
color,
buckets: item.buckets || [],
});
if (app.historyEnergyCompare !== 'none' && item.comparison?.buckets?.length) {
series.push({
key: `${item.target_id}:comparison`,
label: `${item.target_name} · ${tr('energy.comparisonPeriod')}`,
color,
comparison: true,
buckets: item.comparison.buckets,
});
}
});
host.innerHTML = historyChartMarkup('energyConsumptionChart', tr('energy.title'), tr('energy.chartHint'));
drawEnergyBarChart($('#energyConsumptionChart'), series, { height: 360, interval: app.historyEnergyInterval });
}