Files
gree-controller/web/js/history.js
T
2026-09-16 22:22:09 +02:00

473 lines
32 KiB
JavaScript

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,
}));
}
async function openOutdoorHistory() {
const host = $('#outdoorHistoryChartHost');
const current = $('#outdoorHistoryCurrent');
if (!host || !current) return;
current.textContent = app.outdoorTemperature == null ? tr('common.unavailable') : fmtTemp(app.outdoorTemperature);
host.innerHTML = `<div class="panel outdoor-history-loading">${esc(tr('common.loading'))}</div>`;
$$('#outdoorHistoryDialog [data-history-route]').forEach(link => {
const hours = link.dataset.historyHours;
link.href = withBase(`/history/overview${hours ? `?hours=${encodeURIComponent(hours)}` : ''}`);
});
openDialog('outdoorHistoryDialog');
try {
const data = await api('/api/history?scope=overview&hours=24&limit=20000');
const deviceRows = data.devices || [];
const outdoorDeviceSeries = outdoorHistorySeries(app.devices, deviceRows).map(item => ({
...item,
label: item.label.includes(' · ') ? item.label : `${item.label} · ${tr('history.greeOutdoor')}`,
}));
const sensorRows = (data.sensors || []).filter(row => row.kind === 'outdoor');
const outdoorEntities = [...new Set(sensorRows.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 series = [...outdoorDeviceSeries, ...outdoorHaSeries];
const rows = [...deviceRows, ...sensorRows];
host.innerHTML = historyChartMarkup('outdoorHistoryModalChart', tr('history.allOutdoor'), tr('house.outdoorHistoryHint'));
drawLineChart($('#outdoorHistoryModalChart'), series, rows, { height: 350 });
renderLegend($('#outdoorHistoryModalChartLegend'), series);
} catch (error) {
host.innerHTML = `<div class="empty"><strong>${esc(tr('history.noData'))}</strong><span>${esc(error.message)}</span></div>`;
toast(error.message, true);
}
}
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`,
});
if (app.historyNetworkShowJitter !== false) latencySeries.push({
key: `${id}:jitter`, label: `${label} · ${tr('history.networkJitter')}`, color, dash: [7, 5],
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 savedChartRangeLabel(hours) {
const value = Number(hours || 24);
if (value === 168) return tr('history.range7d');
if (value === 720) return tr('history.range30d');
if (value === 2160) return tr('history.range90d');
if (value === 8760) return tr('history.range1y');
return `${value} h`;
}
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);
const editing = app.savedCharts.find(item => item.id === app.customChartEditingId) || null;
const draftName = app.customChartNameDraft !== null ? app.customChartNameDraft : (editing?.name || '');
const editNote = editing ? `<span class="custom-chart-edit-note">${esc(tr('history.editingChart'))}: <strong>${esc(editing.name)}</strong></span>` : '';
const cancelEdit = editing ? `<button class="secondary" data-history-action="cancel-chart-edit">${esc(tr('actions.cancel'))}</button>` : '';
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'))}">${uiIcon('close')}</button></span>`).join('') : `<span class="field-note">${esc(tr('history.noCustomSeries'))}</span>`}</div>
${editNote}
<div class="custom-chart-save"><input id="customChartName" value="${esc(draftName)}" placeholder="${esc(tr('history.chartName'))}"><button class="secondary" data-history-action="save-chart">${esc(editing ? tr('history.saveChanges') : tr('actions.save'))}</button>${cancelEdit}<button class="primary" data-history-action="copy-chart-link">${esc(tr('history.copyLink'))}</button></div>
<div class="saved-chart-section"><div class="saved-chart-heading"><strong>${esc(tr('history.savedCharts'))}</strong><small>${app.savedCharts.length}</small></div><div class="saved-chart-list">${app.savedCharts.length ? app.savedCharts.map(item => `<div class="saved-chart-row${item.id === app.customChartEditingId ? ' is-editing' : ''}"><button class="saved-chart-open" data-history-action="load-chart" data-id="${esc(item.id)}"><strong>${esc(item.name)}</strong><small>${esc(item.series.length)} ${esc(tr('history.series'))} · ${esc(savedChartRangeLabel(item.hours))}</small></button><div class="saved-chart-actions"><button class="secondary" data-history-action="edit-chart" data-id="${esc(item.id)}" title="${esc(tr('actions.edit'))}" aria-label="${esc(tr('actions.edit'))}">${uiIcon('edit')}</button><button class="danger" data-history-action="delete-chart" data-id="${esc(item.id)}" title="${esc(tr('actions.delete'))}" aria-label="${esc(tr('actions.delete'))}">${uiIcon('close')}</button></div></div>`).join('') : `<span class="field-note">${esc(tr('history.noSavedCharts'))}</span>`}</div></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();
}
function publicCustomChartUrl(path) {
const configuredPublicBase = String(app.system?.public_chart_base_url || '').trim().replace(/\/+$/, '');
if (configuredPublicBase) return `${configuredPublicBase}${path}`;
if (!APP_BASE.startsWith('/api/hassio_ingress/')) return `${location.origin}${APP_BASE}${path}`;
const bind = String(app.system?.bind || '');
const port = bind.match(/:(\d+)$/)?.[1] || '8787';
const rawHost = location.hostname || 'localhost';
const host = rawHost.includes(':') && !rawHost.startsWith('[') ? `[${rawHost}]` : rawHost;
const configuredBase = String(app.system?.base_path || '').trim();
const directBase = configuredBase === '/' ? '' : configuredBase.replace(/\/$/, '');
return `http://${host}:${port}${directBase}${path}`;
}
async function handleHistoryAction(button) {
const action = button.dataset.historyAction;
if (action === 'add-series') {
if ($('#customChartName')) app.customChartNameDraft = $('#customChartName').value;
const value = $('#customSeriesSelect')?.value;
if (value && !app.customChartSeries.includes(value)) app.customChartSeries.push(value);
renderCustomHistory(); updateBrowserUrl(currentHistoryPath(), true); return;
}
if (action === 'remove-series') {
if ($('#customChartName')) app.customChartNameDraft = $('#customChartName').value;
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 hours = $('#historyHours')?.value || '24';
const editingIndex = app.savedCharts.findIndex(entry => entry.id === app.customChartEditingId);
if (editingIndex >= 0) {
app.savedCharts[editingIndex] = { ...app.savedCharts[editingIndex], name, series: [...app.customChartSeries], hours };
app.customChartEditingId = null; app.customChartNameDraft = null;
persistSavedCharts(); renderCustomHistory(); toast(tr('history.chartUpdated')); return;
}
const item = { id: `chart-${Date.now()}`, name, series: [...app.customChartSeries], hours };
app.savedCharts.unshift(item); app.customChartNameDraft = null; 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.customChartEditingId = null; app.customChartNameDraft = item.name || '';
app.customChartSeries = [...item.series]; if ($('#historyHours') && item.hours) $('#historyHours').value = String(item.hours);
renderCustomHistory(); updateBrowserUrl(currentHistoryPath()); return;
}
if (action === 'edit-chart') {
const item = app.savedCharts.find(entry => entry.id === button.dataset.id); if (!item) return;
app.customChartEditingId = item.id; app.customChartNameDraft = item.name || ''; app.customChartSeries = [...item.series];
if ($('#historyHours') && item.hours) $('#historyHours').value = String(item.hours);
renderCustomHistory(); updateBrowserUrl(currentHistoryPath()); $('#customChartName')?.focus(); return;
}
if (action === 'cancel-chart-edit') {
app.customChartEditingId = null; app.customChartNameDraft = null; renderCustomHistory(); return;
}
if (action === 'delete-chart') {
if (app.customChartEditingId === button.dataset.id) { app.customChartEditingId = null; app.customChartNameDraft = null; }
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);
try {
const share = await api('/api/charts/custom/share', {
method: 'POST',
body: {
title: $('#customChartName')?.value.trim() || tr('history.customChart'),
series: [...app.customChartSeries],
hours: Number($('#historyHours')?.value || 24),
lang: app.language === 'pl' ? 'pl' : 'en',
},
});
const link = publicCustomChartUrl(share.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'));
} catch (error) {
toast(error.message, true);
}
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 });
}