History UI: visualization and export (#29846)

This commit is contained in:
Michael Geers 2026-05-15 17:25:28 +02:00 • committed by GitHub
parent a74982acc5
commit 431b606230
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 1997 additions and 554 deletions

1
.gitignore vendored
View file

@ -6,6 +6,7 @@ __debug_bin*
*.db
*.gz
*.py
testdata/**/*
*.log
*.json
!i18n/*.json

View file

@ -37,18 +37,23 @@ const colors: {
light: null,
selfPalette: ["#0FDE41FF", "#FFBD2FFF", "#FD6158FF", "#03C1EFFF", "#0F662DFF", "#FF922EFF"],
palette: [
"#03C1EFFF",
"#FD6158FF",
"#31AB4AFF",
"#41517AFF",
"#FF922EFF",
"#0F662DFF",
"#0470D4FF",
"#FFBD2FFF",
"#77C93EFF",
"#4E1D10FF",
"#0AAFBFFF",
"#813504FF",
// Dynamic palette (vehicles, loadpoints, …): optimized for neighbor contrast, avoids overlap with system colors (solar, battery, grid, …).
"#06B6D4FF", // Cyan
"#2B7FFFFF", // Blue
"#6366F1FF", // Indigo
"#A855F7FF", // Violet
"#D946EFFF", // Magenta
"#EC4899FF", // Pink
"#FB7185FF", // Coral
"#475569FF", // Slate
"#1E40AFFF", // Royal
"#6D28D9FF", // Purple
"#BE185DFF", // Crimson
"#D2691EFF", // Sienna
"#67E8F9FF", // Glacier
"#C084FCFF", // Lilac
"#FFD580FF", // Sand
"#94A3B8FF", // Steel
],
});

View file

@ -70,7 +70,7 @@
Optimize 🧪
</router-link>
<router-link v-if="experimental" class="dropdown-item" to="/history" active-class="active">
History 🧪
{{ $t("main.history.title") }} 🧪
</router-link>
</div>
</template>

View file

@ -1,170 +0,0 @@
<template>
<div class="mb-5">
<div class="chart-container my-3">
<Bar ref="chartRef" :data="chartData" :options="chartOptions" :height="300" />
</div>
<LegendList :legends="legends" />
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarController,
BarElement,
Tooltip,
type ChartData,
type ChartOptions,
} from "chart.js";
import { Bar } from "vue-chartjs";
import colors, { lighterColor } from "@/colors";
import { commonOptions } from "../Sessions/chartConfig";
import LegendList from "../Sessions/LegendList.vue";
import type { Legend } from "../Sessions/types";
import formatter from "@/mixins/formatter";
import type { SeriesData } from "./PowerChart.vue";
ChartJS.register(CategoryScale, LinearScale, BarController, BarElement, Tooltip);
export default defineComponent({
name: "HistoryEnergyChart",
components: { Bar, LegendList },
mixins: [formatter],
props: {
series: { type: Array as PropType<SeriesData[]>, default: () => [] },
from: { type: Date, required: true },
days: { type: Number, default: 14 },
},
computed: {
dayDates(): Date[] {
const result: Date[] = [];
for (let i = 0; i < this.days; i++) {
const d = new Date(this.from);
d.setDate(d.getDate() + i);
result.push(d);
}
return result;
},
labels(): string[] {
const locale = this.$i18n?.locale;
const fmt = new Intl.DateTimeFormat(locale, {
weekday: "short",
day: "numeric",
month: "short",
});
return this.dayDates.map((d) => fmt.format(d));
},
chartData(): ChartData<"bar"> {
const datasets: ChartData<"bar">["datasets"] = [];
const dayKeys = this.dayDates.map(
(d) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
);
this.series.forEach((s, i) => {
const color = colors.palette[i % colors.palette.length];
// index data by day
const byDay: Record<string, { import: number; export: number }> = {};
s.data.forEach((slot) => {
const d = new Date(slot.start);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
byDay[key] = { import: slot.import, export: slot.export };
});
const importData = dayKeys.map((key) => byDay[key]?.import ?? 0);
const exportData = dayKeys.map((key) => -(byDay[key]?.export ?? 0));
const hasExport = exportData.some((v) => v !== 0);
const importLabel = hasExport ? `${s.group} (import)` : s.group;
datasets.push({
label: importLabel,
data: importData,
backgroundColor: color,
stack: `s${i}`,
});
if (hasExport) {
datasets.push({
label: `${s.group} (export)`,
data: exportData,
backgroundColor: lighterColor(color),
stack: `s${i}`,
});
}
});
return { labels: this.labels, datasets };
},
chartOptions(): ChartOptions<"bar"> {
return {
...commonOptions,
scales: {
x: {
border: { display: false },
grid: { display: false },
ticks: {
color: colors.muted || undefined,
maxRotation: 0,
},
},
y: {
border: { display: false },
title: {
display: true,
text: "kWh",
color: colors.muted || undefined,
},
ticks: {
color: colors.muted || undefined,
callback: (value: number | string) => Number(value).toFixed(1),
},
grid: {
color: (ctx: { tick: { value: number } }) =>
ctx.tick.value === 0
? colors.muted || undefined
: colors.border || undefined,
},
},
},
plugins: {
...commonOptions.plugins,
tooltip: {
...commonOptions.plugins.tooltip,
callbacks: {
label: (ctx) => {
const val = ctx.parsed.y ?? 0;
const abs = Math.abs(val);
const label = ctx.dataset.label || "";
return `${label}: ${abs.toFixed(1)} kWh`;
},
},
},
},
} as ChartOptions<"bar">;
},
legends(): Legend[] {
const result: Legend[] = [];
this.series.forEach((s, i) => {
const color = colors.palette[i % colors.palette.length];
const hasExport = s.data.some((slot) => slot.export > 0);
const importLabel = hasExport ? `${s.group} (import)` : s.group;
result.push({ label: importLabel, color, value: "" });
if (hasExport) {
result.push({
label: `${s.group} (export)`,
color: lighterColor(color),
value: "",
});
}
});
return result;
},
},
});
</script>

View file

@ -0,0 +1,661 @@
<template>
<div class="history-chart-wrapper">
<div ref="chartEl" class="history-chart"></div>
</div>
</template>
<script lang="ts">
import { defineComponent, markRaw, type PropType } from "vue";
import {
echarts,
FONT_FAMILY,
forecastGrid,
forecastYAxis,
tooltipStyle,
} from "../Forecast/echarts";
import colors, { lighterColor } from "@/colors";
import formatter, { POWER_UNIT } from "@/mixins/formatter";
import { PERIODS } from "../Sessions/types";
import { is12hFormat } from "@/units";
export interface HistorySlot {
start: string;
end: string;
energy: number;
returnEnergy: number;
}
export interface HistorySeries {
name: string;
group: string;
data: HistorySlot[];
// Marks a synthetic / derived series (e.g. "other consumers"). Gets a neutral
// color and is excluded from the source-data table.
virtual?: boolean;
}
type WithChartOption = { chartOption: Record<string, unknown> };
// Alpha (0..1) for entity at position `i` in a `n`-entity stack: top is opaque,
// each step below fades by 20%, capped at 50%.
export function stepAlpha(i: number, n: number): number {
const step = 0.2;
const minAlpha = 0.5;
return Math.max(minAlpha, 1 - (n - 1 - i) * step);
}
export function alphaColor(color: string, alpha: number): string {
const c = (color || "").trim().toLowerCase();
const a = Math.round(Math.max(0, Math.min(1, alpha)) * 255)
.toString(16)
.padStart(2, "0");
if (c.length === 7) return c + a;
if (c.length === 9) return c.slice(0, 7) + a;
return c;
}
export default defineComponent({
name: "GroupChart",
mixins: [formatter],
props: {
group: { type: String, required: true },
color: { type: String, required: true },
series: { type: Array as PropType<HistorySeries[]>, default: () => [] },
overlay: { type: Array as PropType<HistorySeries[]>, default: () => [] },
overlayColor: { type: String, default: "" },
overlayLabel: { type: String, default: "" },
showOverlay: { type: Boolean, default: false },
focusedEntity: { type: Number as PropType<number | null>, default: null },
period: { type: String as PropType<PERIODS>, required: true },
from: { type: Date, required: true },
to: { type: Date, required: true },
},
data(): {
chart: echarts.ECharts | null;
isMobile: boolean;
mediaQuery: MediaQueryList | null;
previousFocusedEntity: number | null;
previousPeriod: PERIODS;
} {
return {
chart: null,
isMobile: false,
mediaQuery: null,
previousFocusedEntity: this.focusedEntity as number | null,
previousPeriod: this.period as PERIODS,
};
},
computed: {
// In day view a slot is 15 minutes of energy (kWh). Display as
// average power (kW): 15 min slot ⇒ kW = kWh × 4.
valueFactor(): number {
return this.period === PERIODS.DAY ? 4 : 1;
},
unit(): "kW" | "kWh" {
return this.period === PERIODS.DAY ? "kW" : "kWh";
},
// Consumer groups have many palette colours per entity — a single neutral
// tooltip background reads better than picking one entity's colour.
tooltipColor(): string {
if (this.group === "loadpoint" || this.group === "meter") {
return colors.text || this.color;
}
return this.color;
},
visibleSeries(): HistorySeries[] {
if (this.focusedEntity === null) return this.series;
const idx = this.focusedEntity;
return this.series.filter((_, i) => i === idx);
},
isBidirectional(): boolean {
for (const s of this.visibleSeries) {
let energy = 0;
let returnEnergy = 0;
for (const slot of s.data) {
energy += slot.energy;
returnEnergy += slot.returnEnergy;
}
if (energy > 0 && returnEnergy > 0) return true;
}
return false;
},
// Soft symmetric limit. Rounded up to an even-friendly nice number so
// splitNumber: 4 gives integer ticks ±X, ±X/2, 0.
bidirectionalLimit(): number {
let m = 0;
const factor = this.valueFactor;
for (const s of this.visibleSeries) {
for (const slot of s.data) {
const a = Math.abs(slot.energy * factor);
const b = Math.abs(slot.returnEnergy * factor);
if (a > m) m = a;
if (b > m) m = b;
}
}
if (m <= 0) return 2;
const mag = Math.pow(10, Math.floor(Math.log10(m)));
const r = m / mag;
let n;
if (r <= 2) n = 2;
else if (r <= 4) n = 4;
else if (r <= 6) n = 6;
else if (r <= 8) n = 8;
else n = 10;
return n * mag;
},
categoryTimestamps(): number[] {
const out: number[] = [];
const cursor = new Date(this.from);
const end = this.to.getTime();
if (this.period === PERIODS.YEAR) {
while (cursor.getTime() < end) {
out.push(cursor.getTime());
cursor.setMonth(cursor.getMonth() + 1);
}
} else if (this.period === PERIODS.MONTH) {
while (cursor.getTime() < end) {
out.push(cursor.getTime());
cursor.setDate(cursor.getDate() + 1);
}
} else {
// DAY → 15-minute slots
while (cursor.getTime() < end) {
out.push(cursor.getTime());
cursor.setMinutes(cursor.getMinutes() + 15);
}
}
return out;
},
// Per-period stable keys so echarts treats positions as the SAME category
// across navigations (year→year: 12 month keys; day→day: 96 slot keys),
// which lets the diff animate value transitions instead of replacing bars.
categoryKeys(): string[] {
return this.categoryTimestamps.map((t) => this.timestampKey(t));
},
entryColors(): string[] {
// Loadpoint and meter use the palette per entity (distinct entities).
// Production and battery use the group color with subtle alpha steps so
// stacked segments stay visually distinguishable.
if (this.group === "loadpoint" || this.group === "meter") {
const mutedColor = colors.muted || this.color;
return this.series.map((s, i) =>
// Virtual "other consumers" entity renders in a neutral gray to set
// it apart from explicit meter entities.
s.virtual ? mutedColor : colors.palette[i % colors.palette.length] || this.color
);
}
if (this.series.length <= 1) return [this.color];
return this.series.map((_, i) =>
alphaColor(this.color, stepAlpha(i, this.series.length))
);
},
echartsSeries() {
const cats = this.categoryKeys;
const index = new Map<string, number>();
cats.forEach((k, i) => index.set(k, i));
const slotKey = (start: string) => this.timestampKey(new Date(start).getTime());
const radius = 4;
const factor = this.valueFactor;
const result: Record<string, unknown>[] = [];
// Always render overlay slot (line series) so series structure is stable;
// data is all-null when toggled off. Prepend so it renders BEHIND bars.
const overlayValues: (number | null)[] = new Array(cats.length).fill(null);
if (this.showOverlay && this.overlay.length) {
for (const s of this.overlay) {
for (const slot of s.data) {
const idx = index.get(slotKey(slot.start));
if (idx === undefined) continue;
const v = (slot.energy - slot.returnEnergy) * factor;
overlayValues[idx] = (overlayValues[idx] || 0) + v;
}
}
}
const overlayCol = this.overlayColor || this.color;
result.push({
id: "overlay",
name: this.overlayLabel || "overlay",
type: "line",
data: overlayValues,
smooth: true,
symbol: "none",
lineStyle: { color: overlayCol, width: 2, type: "dotted" },
itemStyle: { color: overlayCol },
z: 1,
});
// Always render import + export series per entity, even if one direction
// is empty (null-filled). Stable series ids/structure across renders so
// echarts can animate value transitions instead of redrawing from zero.
// Groups with multiple entities stack them; grid keeps bars side-by-side.
const stackEntities =
this.group === "loadpoint" ||
this.group === "meter" ||
this.group === "pv" ||
this.group === "battery";
// Build value arrays per entity first so we can determine, per slot,
// which entity is the *visible* top/bottom of the stack — that one
// gets the rounded cap even if higher-index entities are zero/null.
const energyByEntity: (number | null)[][] = [];
const returnEnergyByEntity: (number | null)[][] = [];
this.series.forEach((s, i) => {
const energyValues: (number | null)[] = new Array(cats.length).fill(null);
const returnEnergyValues: (number | null)[] = new Array(cats.length).fill(null);
const hidden = this.focusedEntity !== null && this.focusedEntity !== i;
if (!hidden) {
for (const slot of s.data) {
const idx = index.get(slotKey(slot.start));
if (idx === undefined) continue;
if (slot.energy > 0) energyValues[idx] = slot.energy * factor;
if (slot.returnEnergy > 0)
returnEnergyValues[idx] = -slot.returnEnergy * factor;
}
}
energyByEntity.push(energyValues);
returnEnergyByEntity.push(returnEnergyValues);
});
// Per slot: index of the topmost (largest i) entity with a non-zero
// value. -1 = no entity has data at that slot.
const topEnergyPerSlot: number[] = new Array(cats.length).fill(-1);
const topReturnEnergyPerSlot: number[] = new Array(cats.length).fill(-1);
for (let i = 0; i < this.series.length; i++) {
for (let idx = 0; idx < cats.length; idx++) {
if ((energyByEntity[i]![idx] ?? 0) > 0) topEnergyPerSlot[idx] = i;
if ((returnEnergyByEntity[i]![idx] ?? 0) < 0) topReturnEnergyPerSlot[idx] = i;
}
}
this.series.forEach((s, i) => {
const c = this.entryColors[i] || this.color;
const returnEnergyColor =
(s.group === "grid" && colors.export) || lighterColor(c) || c;
const energyValues = energyByEntity[i]!;
const returnEnergyValues = returnEnergyByEntity[i]!;
const energyName =
this.series.length > 1 || this.isBidirectional
? this.directionLabel(s, "energy")
: this.singleEntityName(s);
const returnEnergyName = this.directionLabel(s, "returnEnergy");
// Same stack name for import and export means they share one x slot
// (positive values stack up, negative stack down, no width penalty).
const stackName = stackEntities ? `group-${this.group}` : `entity-${i}`;
// For non-stacked groups every bar is its own cap. For stacked groups
// the cap moves to the topmost non-zero entity per slot, so when the
// last entity is empty at a given slot the next-lower one still gets
// the rounded top. A focused entity is rendered solo → always caps.
const energyData: (
| number
| null
| { value: number; itemStyle: { borderRadius: number[] } }
)[] = energyValues.map((v, idx) => {
if (v == null) return v;
const isTop =
!stackEntities || topEnergyPerSlot[idx] === i || this.focusedEntity === i;
if (!isTop) return v;
return { value: v, itemStyle: { borderRadius: [radius, radius, 0, 0] } };
});
const returnEnergyData: (
| number
| null
| { value: number; itemStyle: { borderRadius: number[] } }
)[] = returnEnergyValues.map((v, idx) => {
if (v == null) return v;
const isBottom =
!stackEntities ||
topReturnEnergyPerSlot[idx] === i ||
this.focusedEntity === i;
if (!isBottom) return v;
return { value: v, itemStyle: { borderRadius: [0, 0, radius, radius] } };
});
result.push({
id: `entity-${i}-energy`,
name: energyName,
type: "bar",
stack: stackName,
data: energyData,
itemStyle: { color: c, borderRadius: [0, 0, 0, 0] },
barCategoryGap: "25%",
barGap: "10%",
});
result.push({
id: `entity-${i}-returnEnergy`,
name: returnEnergyName,
type: "bar",
stack: stackName,
data: returnEnergyData,
itemStyle: { color: returnEnergyColor, borderRadius: [0, 0, 0, 0] },
barCategoryGap: "25%",
barGap: "10%",
});
});
return result;
},
labelForTimestamp(): (t: number) => string {
if (this.period === PERIODS.DAY) {
// Skip 00:00 so the chart can align with the section title on the left.
// Use every 6h on mobile (06/12/18) and every 3h on desktop. 12 is a
// multiple of both, so noon stays visible. Drop minutes; honor am/pm.
const stepHours = this.isMobile ? 6 : 3;
const h12 = is12hFormat();
return (t: number) => {
const d = new Date(t);
if (d.getMinutes() !== 0) return "";
const h = d.getHours();
if (h === 0) return "";
if (h % stepHours !== 0) return "";
if (h12) {
const hh = h % 12 || 12;
return `${hh} ${h < 12 ? "AM" : "PM"}`;
}
return String(h);
};
}
if (this.period === PERIODS.MONTH) {
return (t: number) => `${new Date(t).getDate()}`;
}
// YEAR — narrow (single-letter) on mobile, short month name otherwise
return this.isMobile
? (t: number) => this.fmtMonthNarrow(new Date(t))
: (t: number) => this.fmtMonth(new Date(t), true);
},
tooltipDateLabel(): (t: number) => string {
if (this.period === PERIODS.DAY) {
return (t) => this.fmtTimeSlot(new Date(t), 15 * 60 * 1000);
}
if (this.period === PERIODS.MONTH) {
return (t) => this.fmtDayMonth(new Date(t));
}
return (t) => this.fmtMonthYear(new Date(t));
},
chartOption(): Record<string, unknown> {
const cats = this.categoryTimestamps;
const keys = this.categoryKeys;
const formatLabel = this.labelForTimestamp;
const tooltipDate = this.tooltipDateLabel;
return {
animation: true,
animationDuration: 0,
animationDurationUpdate: 400,
textStyle: { fontFamily: FONT_FAMILY },
grid: { ...forecastGrid(), left: 0, right: 36 },
tooltip: {
trigger: "axis",
axisPointer: { type: "shadow", shadowStyle: { color: "transparent" } },
...tooltipStyle(this.tooltipColor),
// Allow the tooltip to float above the 180px chart container instead
// of being clamped by `confine: true` — otherwise tall bars push the
// tooltip onto the bar.
confine: false,
position: (
point: [number, number],
params:
| { value: number | null; seriesId: string }[]
| { value: number | null; seriesId: string },
el: HTMLElement
): [number, number] => {
const w = el?.offsetWidth || 0;
const h = el?.offsetHeight || 0;
const margin = 8;
// Anchor the tooltip just above the top edge of the bar at this
// slot. Top edge = sum of positive imports; for export-only slots
// (bidirectional groups with discharge) that's 0 (the zero line),
// which still sits above the visible bar.
const arr = Array.isArray(params) ? params : [params];
let sum = 0;
let hasBar = false;
for (const p of arr) {
if (!/^entity-\d+-(energy|returnEnergy)$/.test(p.seriesId || ""))
continue;
if (p.value == null) continue;
hasBar = true;
if (typeof p.value === "number" && p.value > 0) {
if (/-energy$/.test(p.seriesId)) sum += p.value;
}
}
let x = point[0] - w / 2;
let y = point[1] - h - margin;
if (hasBar && this.chart) {
const pixelY = this.chart.convertToPixel({ yAxisIndex: 0 }, sum);
if (typeof pixelY === "number" && isFinite(pixelY)) {
y = pixelY - h - margin;
}
}
// Clamp X to the viewport so the tooltip never escapes the browser
// edges. The chart container is in CSS-pixel coordinates relative
// to the chart's bounding box, so map via getBoundingClientRect.
const dom = this.chart?.getDom();
const rect = dom?.getBoundingClientRect();
if (rect) {
const minX = -rect.left + margin;
const maxX = window.innerWidth - rect.left - w - margin;
if (x < minX) x = minX;
if (x > maxX) x = maxX;
}
return [x, y];
},
formatter: (
params: {
value: number | null;
seriesName: string;
seriesId: string;
dataIndex: number;
}[]
) => {
if (!params?.length) return "";
const hasData = params.some((p) => p.value != null);
if (!hasData) return "";
const first = params.find((p) => p.dataIndex != null);
if (!first) return "";
const ts = cats[first.dataIndex];
const head = `<div>${ts != null ? tooltipDate(ts) : ""}</div>`;
const formatValue = (v: number) => {
const watts = Math.abs(v) * 1000;
return this.period === PERIODS.DAY
? this.fmtW(watts, POWER_UNIT.KW, true, 1)
: this.fmtWh(watts, POWER_UNIT.KW, true, 1);
};
// Collect energy/returnEnergy values per entity from this slot's params.
const totals = new Map<number, { energy: number; returnEnergy: number }>();
for (const p of params) {
const m = /^entity-(\d+)-(energy|returnEnergy)$/.exec(p.seriesId || "");
if (!m) continue;
const i = parseInt(m[1] || "", 10);
const t = totals.get(i) ?? { energy: 0, returnEnergy: 0 };
const v = Math.abs(p.value ?? 0);
if (m[2] === "energy") t.energy = v;
else t.returnEnergy = v;
totals.set(i, t);
}
// Always list every visible entity, even when its values for
// this slot are zero or missing — keeps the tooltip layout
// stable across slots.
const indices =
this.focusedEntity !== null
? [this.focusedEntity]
: this.series.map((_, i) => i);
const showName = this.series.length > 1 && this.focusedEntity === null;
if (this.isBidirectional) {
const rows = indices
.map((i) => {
const t = totals.get(i) ?? { energy: 0, returnEnergy: 0 };
const val = `<strong>${formatValue(t.energy)} / ${formatValue(t.returnEnergy)}</strong>`;
const name = this.series[i]?.name ?? "";
return showName
? `<div>${name}: ${val}</div>`
: `<div>${val}</div>`;
})
.join("");
return head + rows;
}
const rows = indices
.map((i) => {
const t = totals.get(i) ?? { energy: 0, returnEnergy: 0 };
const val = `<strong>${formatValue(t.energy + t.returnEnergy)}</strong>`;
const name = this.series[i]?.name ?? "";
return showName
? `<div>${name}: ${val}</div>`
: `<div>${val}</div>`;
})
.join("");
return head + rows;
},
},
xAxis: {
type: "category",
data: keys,
axisLine: this.isBidirectional
? {
show: true,
onZero: true,
lineStyle: { color: colors.muted || "", width: 1 },
}
: { show: false },
axisTick: { show: false },
splitLine: { show: false },
axisLabel: {
color: colors.muted || "",
fontSize: 11,
hideOverlap:
this.period !== PERIODS.DAY &&
!(this.period === PERIODS.YEAR && this.isMobile),
interval:
this.period === PERIODS.DAY ||
(this.period === PERIODS.YEAR && this.isMobile)
? 0
: "auto",
formatter: (_value: string, index: number) => formatLabel(cats[index] ?? 0),
},
},
yAxis: forecastYAxis({
...(this.isBidirectional
? {
min: -this.bidirectionalLimit,
max: this.bidirectionalLimit,
interval: this.bidirectionalLimit / 2,
}
: {}),
position: "right",
splitNumber: 3,
splitLine: {
showMinLine: true,
showMaxLine: true,
lineStyle: { color: colors.border || "" },
},
name: this.unit,
nameLocation: "end",
nameGap: 18,
nameTextStyle: {
color: colors.muted || "",
fontFamily: FONT_FAMILY,
fontSize: 10,
opacity: 0.75,
align: "left",
// Axis name anchors at the axis line; axis labels have a default
// 8px margin, so shift the name right by the same amount to land
// flush with the value labels' left edge.
padding: [0, 0, 0, 8],
},
axisLabel: {
color: colors.muted || "",
hideOverlap: true,
formatter: (v: number) =>
this.period === PERIODS.DAY
? this.fmtW(v * 1000, POWER_UNIT.KW, false, 0)
: this.fmtWh(v * 1000, POWER_UNIT.KW, false, 0),
},
}),
series: this.echartsSeries,
};
},
},
watch: {
chartOption: {
handler() {
const opt = (this as unknown as WithChartOption).chartOption;
const focusChanged = this.previousFocusedEntity !== this.focusedEntity;
const periodChanged = this.previousPeriod !== this.period;
// Snap (no animation) on period switches and on legend focus toggles.
// Other updates use the default merge so bars animate value transitions
// per stable key.
this.chart?.setOption({
animation: !(focusChanged || periodChanged),
xAxis: opt["xAxis"],
yAxis: opt["yAxis"],
series: opt["series"],
});
this.previousFocusedEntity = this.focusedEntity as number | null;
this.previousPeriod = this.period as PERIODS;
},
deep: true,
},
},
mounted() {
const el = this.$refs["chartEl"] as HTMLElement;
this.chart = markRaw(echarts.init(el));
this.chart.setOption((this as unknown as WithChartOption).chartOption);
this.mediaQuery = window.matchMedia("(max-width: 575.98px)");
this.isMobile = this.mediaQuery.matches;
this.mediaQuery.addEventListener("change", this.onMediaChange);
window.addEventListener("resize", this.resize);
},
beforeUnmount() {
window.removeEventListener("resize", this.resize);
this.mediaQuery?.removeEventListener("change", this.onMediaChange);
this.chart?.dispose();
},
methods: {
resize() {
this.chart?.resize();
},
onMediaChange(e: MediaQueryListEvent) {
this.isMobile = e.matches;
},
timestampKey(t: number): string {
const d = new Date(t);
if (this.period === PERIODS.YEAR) return `m${d.getMonth()}`;
if (this.period === PERIODS.MONTH) return `d${d.getDate()}`;
return `t${d.getHours()}:${d.getMinutes()}`;
},
niceCeil(v: number): number {
if (v <= 0) return 0;
const mag = Math.pow(10, Math.floor(Math.log10(v)));
const r = v / mag;
let n;
if (r <= 1) n = 1;
else if (r <= 2) n = 2;
else if (r <= 2.5) n = 2.5;
else if (r <= 5) n = 5;
else n = 10;
return n * mag;
},
directionLabel(s: HistorySeries, dir: "energy" | "returnEnergy"): string {
const key = `main.history.direction.${s.group}.${dir}`;
const label = this.$t(key);
if (label === key) return s.name;
if (this.series.length > 1) return `${s.name} ${label}`;
return String(label);
},
singleEntityName(s: HistorySeries): string {
if (this.series.length > 1) return s.name;
const key = `main.history.group.${s.group}`;
const label = this.$t(key);
return label === key ? s.name : String(label);
},
},
});
</script>
<style scoped>
.history-chart-wrapper {
width: 100%;
}
.history-chart {
width: 100%;
height: 180px;
}
</style>

View file

@ -1,182 +0,0 @@
<template>
<div class="mb-5">
<div class="chart-container my-3">
<Line ref="chartRef" :data="chartData" :options="chartOptions" :height="300" />
</div>
<LegendList :legends="legends" />
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import {
Chart as ChartJS,
LinearScale,
TimeScale,
LineController,
LineElement,
PointElement,
Filler,
Tooltip,
type ChartData,
type ChartOptions,
} from "chart.js";
import "chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm";
import { Line } from "vue-chartjs";
import colors, { dimColor } from "@/colors";
import { commonOptions } from "../Sessions/chartConfig";
import LegendList from "../Sessions/LegendList.vue";
import type { Legend } from "../Sessions/types";
import formatter from "@/mixins/formatter";
import { is12hFormat } from "@/units";
ChartJS.register(
LinearScale,
TimeScale,
LineController,
LineElement,
PointElement,
Filler,
Tooltip
);
interface Slot {
start: string;
end: string;
import: number;
export: number;
}
export interface SeriesData {
name?: string;
group: string;
data: Slot[];
}
function slotPower(slot: Slot, field: "import" | "export"): number {
const hours = (new Date(slot.end).getTime() - new Date(slot.start).getTime()) / 3_600_000;
return hours > 0 ? slot[field] / hours : 0;
}
export default defineComponent({
name: "HistoryPowerChart",
components: { Line, LegendList },
mixins: [formatter],
props: {
series: { type: Array as PropType<SeriesData[]>, default: () => [] },
from: { type: Date, required: true },
to: { type: Date, required: true },
},
computed: {
chartData(): ChartData<"line"> {
const datasets = this.series.map((s, i) => {
const color = colors.palette[i % colors.palette.length];
const fill = dimColor(color);
const points = s.data.map((slot) => {
const start = new Date(slot.start).getTime();
const end = new Date(slot.end).getTime();
return {
x: (start + end) / 2,
y: slotPower(slot, "import") - slotPower(slot, "export"),
};
});
return {
label: s.group,
data: points,
borderColor: color,
backgroundColor: fill,
fill: true,
pointRadius: 0,
borderWidth: 1.5,
tension: 0.05,
};
});
return { datasets } as ChartData<"line">;
},
chartOptions(): ChartOptions<"line"> {
const locale = this.$i18n?.locale;
const fmtTime = new Intl.DateTimeFormat(locale, {
hour: "2-digit",
minute: "2-digit",
hour12: is12hFormat(),
});
const fmtDayShort = new Intl.DateTimeFormat(locale, {
weekday: "short",
day: "numeric",
});
return {
...commonOptions,
scales: {
x: {
type: "time",
min: this.from.getTime(),
max: this.to.getTime(),
border: { display: false },
grid: { display: false },
ticks: {
maxTicksLimit: 12,
color: colors.muted || undefined,
autoSkip: true,
maxRotation: 0,
callback: (value: number | string) => {
const d = new Date(value);
if (d.getHours() === 0 && d.getMinutes() === 0) {
return [fmtTime.format(d), fmtDayShort.format(d)];
}
return fmtTime.format(d);
},
},
},
y: {
border: { display: false },
suggestedMin: 0,
title: {
display: true,
text: "kW",
color: colors.muted || undefined,
},
ticks: {
color: colors.muted || undefined,
callback: (value: number | string) => Number(value).toFixed(1),
},
grid: {
color: (ctx: { tick: { value: number } }) =>
ctx.tick.value === 0
? colors.muted || undefined
: colors.border || undefined,
},
},
},
plugins: {
...commonOptions.plugins,
tooltip: {
...commonOptions.plugins.tooltip,
callbacks: {
title: (items) => {
const val = items[0]?.parsed?.x ?? 0;
if (!val) return "";
const d = new Date(val);
return `${fmtDayShort.format(d)} ${fmtTime.format(d)}`;
},
label: (ctx) => {
const val = (ctx.parsed.y ?? 0).toFixed(1);
return `${ctx.dataset.label}: ${val} kW`;
},
},
},
},
} as ChartOptions<"line">;
},
legends(): Legend[] {
return this.series.map((s, i) => ({
label: s.group,
color: colors.palette[i % colors.palette.length],
value: "",
}));
},
},
});
</script>

View file

@ -0,0 +1,16 @@
export const GROUP_ORDER = ["pv", "battery", "grid", "loadpoint", "meter"] as const;
const GROUP_COLOR_VAR: Record<string, string> = {
pv: "--evcc-dark-green",
forecast: "--evcc-dark-yellow",
loadpoint: "--evcc-dark-green",
grid: "--evcc-grid",
battery: "--evcc-darker-green",
meter: "--evcc-price",
};
export function groupColor(group: string): string {
const v = GROUP_COLOR_VAR[group];
if (!v) return "";
return window.getComputedStyle(document.documentElement).getPropertyValue(v).trim() || "";
}

View file

@ -1,8 +1,46 @@
<template>
<div
class="d-sm-flex justify-content-lg-end gap-lg-4"
:class="showMonth ? 'justify-content-between' : 'justify-content-end'"
:class="showDay || showMonth ? 'justify-content-between' : 'justify-content-end'"
>
<div v-if="showDay" class="d-flex justify-content-between">
<DateNavigatorButton
prev
:disabled="!hasPrevDay"
:onClick="emitPrevDay"
data-testid="navigate-prev-day"
/>
<label class="day-picker position-relative d-block" role="button">
<input
type="date"
class="day-picker-input"
:value="dayIsoValue"
:min="dayIsoMin"
:max="dayIsoMax"
data-testid="navigate-day"
@change="
onDateInputChange(
($event.target as HTMLInputElement).value,
$event.target as HTMLInputElement
)
"
/>
<button
type="button"
class="btn btn-sm border-0 text-truncate h-100"
style="width: 11em"
tabindex="-1"
>
{{ dayLongName }}
</button>
</label>
<DateNavigatorButton
next
:disabled="!hasNextDay"
:onClick="emitNextDay"
data-testid="navigate-next-day"
/>
</div>
<div v-if="showMonth" class="d-none d-sm-flex justify-content-between">
<DateNavigatorButton
prev
@ -31,7 +69,7 @@
data-testid="navigate-next-month"
/>
</div>
<div v-if="showMonth" class="d-flex d-sm-none justify-content-between">
<div v-if="showMonth && !showDay" class="d-flex d-sm-none justify-content-between">
<DateNavigatorButton
prev
:disabled="!hasPrevMonth"
@ -61,7 +99,7 @@
<div
v-if="showYear"
class="justify-content-between"
:class="showMonth ? 'd-none d-sm-flex' : 'd-flex'"
:class="showMonth || showDay ? 'd-none d-sm-flex' : 'd-flex'"
>
<DateNavigatorButton
prev
@ -100,6 +138,14 @@ import DateNavigatorButton from "./DateNavigatorButton.vue";
import formatter from "@/mixins/formatter";
import type { SelectOption } from "@/types/evcc";
function daysInMonth(year: number, month: number) {
return new Date(year, month, 0).getDate();
}
function dateOnly(d: Date) {
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}
export default defineComponent({
name: "DateNavigator",
components: {
@ -108,14 +154,24 @@ export default defineComponent({
},
mixins: [formatter],
props: {
day: { type: Number, default: 1 },
month: { type: Number, required: true },
year: { type: Number, required: true },
startDate: { type: Date, required: true },
showDay: Boolean,
showMonth: Boolean,
showYear: Boolean,
},
emits: ["update-date"],
computed: {
hasPrevDay() {
const prev = new Date(this.year, this.month - 1, this.day - 1);
return dateOnly(prev).getTime() >= dateOnly(this.startDate).getTime();
},
hasNextDay() {
const next = new Date(this.year, this.month - 1, this.day + 1);
return dateOnly(next).getTime() <= dateOnly(new Date()).getTime();
},
hasPrevMonth() {
return (
this.year > this.startDate.getFullYear() ||
@ -132,6 +188,13 @@ export default defineComponent({
hasNextYear() {
return this.year < new Date().getFullYear();
},
dayOptions(): SelectOption<number>[] {
const total = daysInMonth(this.year, this.month);
return Array.from({ length: total }, (_, i) => i + 1).map((d) => ({
name: this.fmtDayMonth(new Date(this.year, this.month - 1, d)),
value: d,
}));
},
monthOptions() {
return Array.from({ length: 12 }, (_, i) => i + 1).map((month) => ({
name: this.fmtMonth(new Date(this.year, month - 1, 1), false),
@ -163,6 +226,26 @@ export default defineComponent({
}
return years;
},
dayName() {
return this.fmtDayMonth(new Date(this.year, this.month - 1, this.day));
},
dayLongName() {
return this.fmtDayMonthYear(new Date(this.year, this.month - 1, this.day));
},
dayIsoValue() {
const pad = (n: number) => String(n).padStart(2, "0");
return `${this.year}-${pad(this.month)}-${pad(this.day)}`;
},
dayIsoMin() {
const d = this.startDate;
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
},
dayIsoMax() {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
},
monthName() {
const date = new Date();
date.setMonth(this.month - 1, 1);
@ -176,38 +259,96 @@ export default defineComponent({
},
},
methods: {
emitPrevMonth() {
const prevMonthDate = new Date(this.year, this.month - 2, 1);
emitPrevDay() {
const prev = new Date(this.year, this.month - 1, this.day - 1);
this.$emit("update-date", {
year: prevMonthDate.getFullYear(),
month: prevMonthDate.getMonth() + 1,
year: prev.getFullYear(),
month: prev.getMonth() + 1,
day: prev.getDate(),
});
},
emitNextDay() {
const next = new Date(this.year, this.month - 1, this.day + 1);
this.$emit("update-date", {
year: next.getFullYear(),
month: next.getMonth() + 1,
day: next.getDate(),
});
},
emitDay(day: number) {
const clamped = Math.min(Number(day), daysInMonth(this.year, this.month));
this.$emit("update-date", { year: this.year, month: this.month, day: clamped });
},
onDateInputChange(value: string, target?: HTMLInputElement) {
if (target) target.blur();
if (!value) return;
const [y, m, d] = value.split("-").map((s) => parseInt(s, 10));
if (!y || !m || !d) return;
this.$emit("update-date", { year: y, month: m, day: d });
},
clampDay(year: number, month: number) {
return Math.min(this.day, daysInMonth(year, month));
},
emitPrevMonth() {
const prev = new Date(this.year, this.month - 2, 1);
const year = prev.getFullYear();
const month = prev.getMonth() + 1;
this.$emit("update-date", {
year,
month,
...(this.showDay ? { day: this.clampDay(year, month) } : {}),
});
},
emitNextMonth() {
const nextMonthDate = new Date(this.year, this.month, 1);
const next = new Date(this.year, this.month, 1);
const year = next.getFullYear();
const month = next.getMonth() + 1;
this.$emit("update-date", {
year: nextMonthDate.getFullYear(),
month: nextMonthDate.getMonth() + 1,
year,
month,
...(this.showDay ? { day: this.clampDay(year, month) } : {}),
});
},
emitPrevYear() {
this.$emit("update-date", { year: this.year - 1, month: undefined });
const year = this.year - 1;
this.$emit("update-date", {
year,
month: undefined,
...(this.showDay ? { day: this.clampDay(year, this.month) } : {}),
});
},
emitNextYear() {
this.$emit("update-date", { year: this.year + 1, month: undefined });
const year = this.year + 1;
this.$emit("update-date", {
year,
month: undefined,
...(this.showDay ? { day: this.clampDay(year, this.month) } : {}),
});
},
emitMonth(month: number) {
this.$emit("update-date", { year: this.year, month });
this.$emit("update-date", {
year: this.year,
month,
...(this.showDay ? { day: this.clampDay(this.year, Number(month)) } : {}),
});
},
emitMonthYear(monthYear: string) {
const [year, month] = monthYear.split("-");
const [yearStr, monthStr] = monthYear.split("-");
const year = parseInt(yearStr || "0");
const month = parseInt(monthStr || "0");
this.$emit("update-date", {
year: parseInt(year || "0"),
month: parseInt(month || "0"),
year,
month,
...(this.showDay ? { day: this.clampDay(year, month) } : {}),
});
},
emitYear(year: number) {
this.$emit("update-date", { year, month: undefined });
const y = Number(year);
this.$emit("update-date", {
year: y,
month: undefined,
...(this.showDay ? { day: this.clampDay(y, this.month) } : {}),
});
},
},
});
@ -217,4 +358,21 @@ export default defineComponent({
.btn {
color: inherit;
}
.day-picker {
border-radius: var(--bs-border-radius);
}
.day-picker:focus-within {
outline: var(--bs-focus-ring-width) solid var(--bs-focus-ring-color);
outline-offset: var(--bs-focus-ring-width);
}
.day-picker-input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
border: 0;
padding: 0;
}
</style>

View file

@ -0,0 +1,73 @@
<template>
<div class="header-outer sticky-top">
<div class="container px-4">
<div class="row py-3 py-sm-3 d-flex flex-column flex-sm-row gap-3 gap-lg-0 mb-lg-2">
<div class="col-lg-5 d-flex mb-lg-0">
<slot name="period" />
</div>
<div v-if="$slots['navigator']" class="col-lg-6 offset-lg-1">
<slot name="navigator" />
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
export default defineComponent({
name: "PeriodHeader",
});
</script>
<style scoped>
@import "../../../css/breakpoints.css";
.header-outer {
--vertical-shift: 0rem;
left: 0;
right: 0;
top: max(0rem, env(safe-area-inset-top)) !important;
margin: 0 calc(calc(1.5rem + var(--vertical-shift)) * -1);
background-color: var(--evcc-background);
box-shadow: 0 1px 8px 0px var(--evcc-background);
}
@supports (backdrop-filter: blur(1px)) {
.header-outer {
background-color: #0000;
backdrop-filter: var(--evcc-backdrop-blur);
}
}
@media (--sm-and-up) {
.header-outer {
--vertical-shift: calc((100vw - 560px) / 2);
}
}
@media (--md-and-up) {
.header-outer {
--vertical-shift: calc((100vw - 740px) / 2);
}
}
@media (--lg-and-up) {
.header-outer {
--vertical-shift: calc((100vw - 980px) / 2);
}
}
@media (--xl-and-up) {
.header-outer {
--vertical-shift: calc((100vw - 1160px) / 2);
}
}
@media (--xxl-and-up) {
.header-outer {
--vertical-shift: calc((100vw - 1340px) / 2);
}
}
</style>

View file

@ -44,6 +44,7 @@ export enum GROUPS {
}
export enum PERIODS {
DAY = "day",
MONTH = "month",
YEAR = "year",
TOTAL = "total",

View file

@ -283,6 +283,30 @@ export default defineComponent({
month: "short",
}).format(date);
},
fmtDayMonthYear(date: Date) {
return new Intl.DateTimeFormat(this.$i18n?.locale, {
weekday: "short",
day: "numeric",
month: "short",
year: "numeric",
}).format(date);
},
fmtDayMonthShort(date: Date) {
return new Intl.DateTimeFormat(this.$i18n?.locale, {
day: "numeric",
month: "short",
}).format(date);
},
fmtMonthNarrow(date: Date) {
return new Intl.DateTimeFormat(this.$i18n?.locale, {
month: "narrow",
}).format(date);
},
// "HH:mm – HH:mm" honoring 12h/24h preference.
fmtTimeSlot(start: Date, durationMs: number) {
const end = new Date(start.getTime() + durationMs);
return `${this.fmtHourMinute(start)} – ${this.fmtHourMinute(end)}`;
},
fmtDurationUnit(value: number, unit = "second") {
return new Intl.NumberFormat(this.$i18n?.locale, {
style: "unit",

View file

@ -122,7 +122,15 @@ export default function setupRouter(i18n: VueI18nInstance) {
{
path: "/history",
component: () => import("./views/History.vue"),
props: true,
props: (route) => {
const { day, month, year, period } = route.query;
return {
day: day ? parseInt(day as string, 10) : undefined,
month: month ? parseInt(month as string, 10) : undefined,
year: year ? parseInt(year as string, 10) : undefined,
period,
};
},
},
{
path: "/optimize",

View file

@ -1,4 +1,4 @@
import { reactive } from "vue";
import { computed, reactive } from "vue";
import type { State } from "./types/evcc";
import { convertToUiLoadpoints } from "./uiLoadpoints";
import { useDebouncedComputed } from "./utils/useDebouncedComputed";
@ -43,9 +43,25 @@ const uiLoadpoints = useDebouncedComputed(
50
);
// name → title lookup for loadpoints and meters
const deviceTitles = computed(() => {
const map: Record<string, string> = {};
const add = (e: { name?: string; title?: string } | undefined) => {
if (e?.name && e.title) map[e.name] = e.title;
};
state.loadpoints?.forEach(add);
state.pv?.forEach(add);
state.battery?.devices?.forEach(add);
state.aux?.forEach(add);
state.ext?.forEach(add);
add(state.grid);
return map;
});
export interface Store {
state: State; // raw state from websocket
uiLoadpoints: typeof uiLoadpoints;
deviceTitles: typeof deviceTitles;
offline(value: boolean): void;
update(msg: any): void;
reset(): void;
@ -54,6 +70,7 @@ export interface Store {
const store: Store = {
state,
uiLoadpoints,
deviceTitles,
offline(value: boolean) {
state.offline = value;
},

View file

@ -80,6 +80,7 @@ export interface State {
timezone?: string;
battery?: Battery;
batteryMode?: BATTERY_MODE;
grid?: Meter;
pv?: Meter[];
aux?: Meter[];
ext?: Meter[];
@ -253,6 +254,7 @@ export enum LENGTH_UNIT {
}
export interface Loadpoint {
name: string;
batteryBoost: boolean;
chargeCurrents?: number[];
chargeDuration: number;
@ -583,10 +585,12 @@ export interface Notification {
}
export interface Meter {
name?: string;
power: number;
title?: string;
icon?: string;
energy?: number;
returnEnergy?: number;
}
export interface BatteryForecast {

View file

@ -1,109 +1,589 @@
<template>
<div class="container px-4 safe-area-inset">
<TopHeader title="History" />
<div class="alert alert-light mb-5">
This page is for development purposes only. Helps verify logged data. A proper
visualization is coming soon, stay tuned.
</div>
<div class="row">
<main class="col-12">
<div v-if="loading" class="my-5 text-center text-muted">loading...</div>
<template v-else>
<section v-if="powerSeries.length" class="mb-5">
<h3 class="fw-normal mb-3">Power <small class="ms-2">48 hours</small></h3>
<PowerChart :series="powerSeries" :from="powerFrom" :to="powerTo" />
</section>
<section v-if="energySeries.length" class="mb-5">
<h3 class="fw-normal mb-3">Energy <small class="ms-2">14 days</small></h3>
<EnergyChart :series="energySeries" :from="energyFrom" :days="14" />
</section>
<div
class="container px-4 safe-area-inset d-flex flex-column"
:class="{ 'empty-container': !visibleGroups.length }"
>
<TopHeader :title="$t('main.history.title')" />
<div class="row flex-grow-1 d-flex">
<main class="col-12 d-flex flex-column">
<PeriodHeader>
<template #period>
<PeriodSelector
:period="effectivePeriod"
:periodOptions="periodOptions"
@update:period="changePeriod"
/>
</template>
<template #navigator>
<DateNavigator
:day="effectiveDay"
:month="effectiveMonth"
:year="effectiveYear"
:startDate="startDate"
:showDay="showDayNavigation"
:showMonth="showMonthNavigation"
:showYear="showYearNavigation"
@update-date="updateDate"
/>
</template>
</PeriodHeader>
<p class="text-muted small history-disclaimer mb-4">
Work in progress. Visualizations will change. The current focus is verifying
that the data is correct. Please report anything implausible.
</p>
<div v-if="loading && !visibleGroups.length" class="skeleton-stack">
<div
v-if="!powerSeries.length && !energySeries.length"
class="my-5 text-center text-muted"
>
no data
v-for="i in 3"
:key="i"
class="history-tile history-tile-skeleton mb-4"
aria-hidden="true"
></div>
</div>
<div v-else-if="!visibleGroups.length" class="flex-grow-1 d-flex">
<div class="empty-box p-5 text-center">
<span class="text-muted">{{ $t("main.history.empty") }}</span>
</div>
</template>
</div>
<section
v-for="group in visibleGroups"
:key="group"
class="history-tile mb-4"
:data-testid="`history-section-${group}`"
>
<div class="d-flex align-items-baseline gap-3">
<h3
class="fw-normal my-0 d-flex gap-3 flex-wrap align-items-baseline overflow-hidden history-tile-title flex-grow-1"
>
<span class="d-block no-wrap text-truncate">
{{ $t(`main.history.group.${group}`) }}
</span>
<small class="d-block no-wrap text-truncate">
{{ groupTotalLabel(group) }}
</small>
</h3>
<div
v-if="group === 'pv' && hasForecast && effectivePeriod === 'day'"
class="form-check form-switch mb-0 ms-auto forecast-toggle"
:style="{ '--forecast-color': groupColor('forecast') }"
>
<input
id="historyShowForecast"
v-model="showForecast"
class="form-check-input"
type="checkbox"
role="switch"
/>
<label
class="form-check-label text-muted ms-1"
for="historyShowForecast"
>
{{ $t("main.history.group.forecast") }}
</label>
</div>
</div>
<GroupChart
v-if="displayFrom && displayTo && displayPeriod"
:group="group"
:color="groupColor(group)"
:series="displaySeries(group)"
:overlay="
group === 'pv' && displayPeriod === 'day'
? seriesByGroup['forecast']
: []
"
:overlayColor="groupColor('forecast')"
:overlayLabel="$t('main.history.group.forecast')"
:showOverlay="group === 'pv' && displayPeriod === 'day' && showForecast"
:focusedEntity="focusedEntity[group] ?? null"
:period="displayPeriod"
:from="displayFrom"
:to="displayTo"
/>
<ul
v-if="hasEntityLegend(group)"
class="entity-legend p-0 mt-4 mb-0 d-flex flex-wrap column-gap-4 row-gap-2"
>
<li
v-for="legend in entityLegends(group)"
:key="legend.entityIndex"
class="entity-legend-item d-flex align-items-baseline gap-2 no-wrap"
:class="{
'entity-legend-item--dim': isDimmed(group, legend.entityIndex),
}"
role="button"
tabindex="0"
@click="toggleFocus(group, legend.entityIndex)"
@keydown.enter.prevent="toggleFocus(group, legend.entityIndex)"
@keydown.space.prevent="toggleFocus(group, legend.entityIndex)"
>
<span
class="entity-legend-dot align-self-center"
:style="{ backgroundColor: legend.color }"
></span>
<span class="text-nowrap">{{ legend.label }}</span>
<span class="text-muted text-nowrap">{{ legend.value }}</span>
</li>
</ul>
</section>
<p v-if="visibleGroups.length" class="text-end mt-3 mb-0">
<a
:href="csvLink"
download
class="text-muted small history-csv-link"
data-testid="history-csv-download"
>
{{ $t("main.history.downloadCsv") }}
</a>
</p>
</main>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import { defineComponent, type PropType } from "vue";
import Header from "../components/Top/Header.vue";
import PowerChart from "../components/History/PowerChart.vue";
import EnergyChart from "../components/History/EnergyChart.vue";
import PeriodSelector from "../components/Sessions/PeriodSelector.vue";
import DateNavigator from "../components/Sessions/DateNavigator.vue";
import PeriodHeader from "../components/Sessions/PeriodHeader.vue";
import GroupChart, {
type HistorySeries,
alphaColor,
stepAlpha,
} from "../components/History/GroupChart.vue";
import type { Legend } from "../components/Sessions/types";
import { PERIODS } from "../components/Sessions/types";
import { GROUP_ORDER, groupColor } from "../components/History/groups";
import colors from "../colors";
import formatter, { POWER_UNIT } from "../mixins/formatter";
import api from "../api";
import store from "../store";
const HISTORY_PERIODS = [PERIODS.DAY, PERIODS.MONTH, PERIODS.YEAR];
function daysInMonth(year: number, month: number) {
return new Date(year, month, 0).getDate();
}
export default defineComponent({
name: "History",
components: {
TopHeader: Header,
PowerChart,
EnergyChart,
PeriodSelector,
DateNavigator,
PeriodHeader,
GroupChart,
},
mixins: [formatter],
props: {
day: { type: Number, default: undefined },
month: { type: Number, default: undefined },
year: { type: Number, default: undefined },
period: { type: String as PropType<PERIODS>, default: undefined },
},
data() {
return {
powerSeries: [] as any[],
energySeries: [] as any[],
powerFrom: new Date(),
powerTo: new Date(),
energyFrom: new Date(),
rawSeries: [] as HistorySeries[],
displayFrom: null as Date | null,
displayTo: null as Date | null,
displayPeriod: null as PERIODS | null,
loading: true,
interval: null as ReturnType<typeof setInterval> | null,
startDate: new Date(2020, 0, 1),
showForecast: true,
focusedEntity: {} as Record<string, number | null>,
};
},
head() {
return { title: "History" };
return { title: this.$t("main.history.title") };
},
computed: {
effectivePeriod(): PERIODS {
return this.period && HISTORY_PERIODS.includes(this.period)
? (this.period as PERIODS)
: PERIODS.DAY;
},
effectiveYear(): number {
return this.year ?? new Date().getFullYear();
},
effectiveMonth(): number {
return this.month ?? new Date().getMonth() + 1;
},
effectiveDay(): number {
const fallback = new Date().getDate();
const d = this.day ?? fallback;
return Math.min(d, daysInMonth(this.effectiveYear, this.effectiveMonth));
},
periodOptions() {
return HISTORY_PERIODS.map((value) => ({
name: this.$t(`sessions.period.${value}`),
value,
}));
},
showDayNavigation(): boolean {
return this.effectivePeriod === PERIODS.DAY;
},
showMonthNavigation(): boolean {
return this.effectivePeriod === PERIODS.MONTH;
},
showYearNavigation(): boolean {
return [PERIODS.MONTH, PERIODS.YEAR].includes(this.effectivePeriod);
},
from(): Date {
switch (this.effectivePeriod) {
case PERIODS.DAY:
return new Date(this.effectiveYear, this.effectiveMonth - 1, this.effectiveDay);
case PERIODS.YEAR:
return new Date(this.effectiveYear, 0, 1);
case PERIODS.MONTH:
default:
return new Date(this.effectiveYear, this.effectiveMonth - 1, 1);
}
},
to(): Date {
switch (this.effectivePeriod) {
case PERIODS.DAY: {
const d = new Date(this.from);
d.setDate(d.getDate() + 1);
return d;
}
case PERIODS.YEAR:
return new Date(this.effectiveYear + 1, 0, 1);
case PERIODS.MONTH:
default:
return new Date(this.effectiveYear, this.effectiveMonth, 1);
}
},
aggregate(): string {
switch (this.effectivePeriod) {
case PERIODS.DAY:
return "15m";
case PERIODS.YEAR:
return "month";
case PERIODS.MONTH:
default:
return "day";
}
},
fetchKey(): string {
return `${this.aggregate}|${this.from.getTime()}|${this.to.getTime()}`;
},
seriesByGroup(): Record<string, HistorySeries[]> {
const titles = store.deviceTitles.value;
const map: Record<string, HistorySeries[]> = {};
for (const s of this.rawSeries) {
if (!s.group) continue;
if (!map[s.group]) map[s.group] = [];
map[s.group]!.push({ ...s, name: titles[s.name] || s.name });
}
return map;
},
visibleGroups(): string[] {
return GROUP_ORDER.filter((g) => {
// Consumption uses `home` as the source of truth, so the section
// shows up whenever home has data, even without explicit meters.
if (g === "meter") {
const home = this.seriesByGroup["home"];
if (
home?.some((s) =>
s.data.some((slot) => slot.energy !== 0 || slot.returnEnergy !== 0)
)
) {
return true;
}
}
const list = this.seriesByGroup[g];
if (!list?.length) return false;
return list.some((s) =>
s.data.some((slot) => slot.energy !== 0 || slot.returnEnergy !== 0)
);
});
},
// Series shown in the chart. For the consumption (`meter`) group we append
// a virtual "Other consumers" series = home.net − sum(meter entities).
displaySeries(): (group: string) => HistorySeries[] {
return (group: string): HistorySeries[] => {
if (group !== "meter") return this.seriesByGroup[group] || [];
const meters = this.seriesByGroup["meter"] || [];
const home = (this.seriesByGroup["home"] || [])[0];
if (!home) return meters;
const meterTotals = new Map<string, number>();
for (const s of meters) {
for (const slot of s.data) {
const net = slot.energy - slot.returnEnergy;
meterTotals.set(slot.start, (meterTotals.get(slot.start) || 0) + net);
}
}
const other: HistorySeries = {
name: this.$t("main.history.otherConsumers") as string,
group: "meter",
virtual: true,
data: home.data.map((slot) => {
const homeNet = slot.energy - slot.returnEnergy;
const v = Math.max(0, homeNet - (meterTotals.get(slot.start) || 0));
return { start: slot.start, end: slot.end, energy: v, returnEnergy: 0 };
}),
};
// First entry in the array = bottom of the stack, so "Other consumers"
// always sits underneath the explicit meters.
return [other, ...meters];
};
},
hasForecast(): boolean {
const list = this.seriesByGroup["forecast"];
if (!list?.length) return false;
return list.some((s) =>
s.data.some((slot) => slot.energy !== 0 || slot.returnEnergy !== 0)
);
},
csvLink(): string {
const params = new URLSearchParams({
format: "csv",
lang: this.$i18n?.locale,
from: this.from.toISOString(),
to: this.to.toISOString(),
aggregate: this.aggregate,
});
return `./api/history/energy?${params.toString()}`;
},
},
watch: {
fetchKey() {
this.fetchData();
},
},
mounted() {
this.fetchData();
this.interval = setInterval(() => this.fetchData(), 15 * 60 * 1e3);
},
unmounted() {
if (this.interval) {
clearInterval(this.interval);
}
if (this.interval) clearInterval(this.interval);
},
methods: {
groupColor,
hasEntityLegend(group: string): boolean {
const list = this.displaySeries(group);
if (!list.length) return false;
if (group === "loadpoint" || group === "meter") return true;
if (group === "pv" || group === "battery") return list.length > 1;
return false;
},
entityLegends(group: string): (Legend & { entityIndex: number })[] {
const list = this.displaySeries(group);
const baseColor = groupColor(group);
const n = list.length;
const colorFor = (i: number, s: HistorySeries) => {
if (s.virtual) return colors.muted || baseColor;
if (group === "loadpoint" || group === "meter") {
return colors.palette[i % colors.palette.length] || baseColor;
}
return alphaColor(baseColor, stepAlpha(i, Math.max(n, 1)));
};
return list
.map((s, i) => {
let sum = 0;
for (const slot of s.data) sum += slot.energy - slot.returnEnergy;
return { s, i, sum };
})
.filter(({ sum }) => group !== "meter" || sum !== 0)
.map(({ s, i, sum }) => {
const watts = Math.abs(sum) * 1000;
return {
entityIndex: i,
label: s.name,
color: colorFor(i, s),
value: this.fmtWh(watts, POWER_UNIT.AUTO),
};
});
},
toggleFocus(group: string, i: number) {
const current = this.focusedEntity[group] ?? null;
this.focusedEntity = {
...this.focusedEntity,
[group]: current === i ? null : i,
};
},
isDimmed(group: string, i: number): boolean {
const focused = this.focusedEntity[group] ?? null;
return focused !== null && focused !== i;
},
groupTotalLabel(group: string): string {
// Consumption total comes from `home` (overall consumption),
// not the sum of explicit meter entities.
const list =
group === "meter"
? this.seriesByGroup["home"] || []
: this.seriesByGroup[group] || [];
let sumEnergy = 0;
let sumReturnEnergy = 0;
for (const s of list) {
for (const slot of s.data) {
sumEnergy += slot.energy;
sumReturnEnergy += slot.returnEnergy;
}
}
const fmt = (v: number) => this.fmtWh(v * 1000, POWER_UNIT.KW);
const directionKey = `main.history.direction.${group}`;
const energyKey = `${directionKey}.energy`;
const returnEnergyKey = `${directionKey}.returnEnergy`;
const energyLabel = this.$t(energyKey);
const returnEnergyLabel = this.$t(returnEnergyKey);
const hasDirectionLabels =
energyLabel !== energyKey && returnEnergyLabel !== returnEnergyKey;
if (sumEnergy > 0 && sumReturnEnergy > 0 && hasDirectionLabels) {
return `${fmt(sumEnergy)} ${energyLabel} · ${fmt(sumReturnEnergy)} ${returnEnergyLabel}`;
}
return fmt(Math.abs(sumEnergy - sumReturnEnergy) || sumEnergy + sumReturnEnergy);
},
async fetchData() {
this.loading = true;
const requestFrom = this.from;
const requestTo = this.to;
const requestPeriod = this.effectivePeriod;
try {
this.powerTo = new Date();
this.powerFrom = new Date();
this.powerFrom.setDate(this.powerFrom.getDate() - 2);
this.powerFrom.setHours(0, 0, 0, 0);
this.energyFrom = new Date();
this.energyFrom.setDate(this.energyFrom.getDate() - 13);
this.energyFrom.setHours(0, 0, 0, 0);
const [powerRes, energyRes] = await Promise.all([
api.get("history/energy", {
params: {
from: this.powerFrom.toISOString(),
to: this.powerTo.toISOString(),
grouped: true,
},
}),
api.get("history/energy", {
params: {
from: this.energyFrom.toISOString(),
to: this.powerTo.toISOString(),
aggregate: "day",
grouped: true,
},
}),
]);
this.powerSeries = powerRes.data || [];
this.energySeries = energyRes.data || [];
const res = await api.get("history/energy", {
params: {
from: requestFrom.toISOString(),
to: requestTo.toISOString(),
aggregate: this.aggregate,
grouped: false,
},
});
this.rawSeries = res.data || [];
this.displayFrom = requestFrom;
this.displayTo = requestTo;
this.displayPeriod = requestPeriod;
} catch (e) {
console.error("Failed to load energy history", e);
this.rawSeries = [];
} finally {
this.loading = false;
}
},
buildBaseQuery(): Record<string, string | undefined> {
const out: Record<string, string | undefined> = {};
for (const [k, v] of Object.entries(this.$route.query)) {
if (typeof v === "string") out[k] = v;
}
return out;
},
changePeriod(newPeriod: PERIODS) {
const query = this.buildBaseQuery();
query["period"] = newPeriod === PERIODS.DAY ? undefined : newPeriod;
const now = new Date();
if (newPeriod === PERIODS.DAY) {
query["year"] = String(this.effectiveYear);
query["month"] = String(this.effectiveMonth);
query["day"] = String(this.day ?? now.getDate());
} else if (newPeriod === PERIODS.MONTH) {
query["year"] = String(this.effectiveYear);
query["month"] = String(this.effectiveMonth);
delete query["day"];
} else {
query["year"] = String(this.effectiveYear);
delete query["month"];
delete query["day"];
}
this.$router.push({ query });
},
updateDate({ year, month, day }: { year: number; month?: number; day?: number }) {
const query = this.buildBaseQuery();
query["year"] = String(year);
if (month !== undefined) query["month"] = String(month);
else delete query["month"];
if (day !== undefined) query["day"] = String(day);
else if (this.effectivePeriod !== PERIODS.DAY) delete query["day"];
this.$router.push({ query });
},
},
});
</script>
<style scoped>
.history-tile {
background: var(--evcc-box);
padding: 1.25rem 1rem 1.75rem;
}
.empty-container {
min-height: calc(100dvh - var(--bottom-space));
}
.empty-box {
background-color: var(--evcc-box);
margin: auto;
border-radius: 2rem;
max-width: 480px;
}
.history-tile-skeleton {
min-height: 270px;
animation: history-skeleton-pulse 1.6s ease-in-out infinite;
}
@keyframes history-skeleton-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
@media (prefers-reduced-motion: reduce) {
.history-tile-skeleton {
animation: none;
opacity: 0.8;
}
}
.entity-legend {
list-style: none;
}
.entity-legend-item {
cursor: pointer;
transition: opacity 150ms;
user-select: none;
}
.entity-legend-item--dim {
opacity: 0.35;
}
.entity-legend-dot {
display: inline-block;
width: 1rem;
height: 1rem;
border-radius: 50%;
flex-shrink: 0;
}
.history-csv-link {
text-decoration: none;
}
.history-csv-link:hover,
.history-csv-link:focus {
color: var(--evcc-default-text);
text-decoration: underline;
}
.forecast-toggle .form-check-input:checked {
background-color: var(--forecast-color);
border-color: var(--forecast-color);
}
.forecast-toggle .form-check-input:focus {
border-color: var(--forecast-color);
box-shadow: 0 0 0 0.25rem color-mix(in srgb, var(--forecast-color) 25%, transparent);
}
.history-tile-title {
margin: 0 0 0.5rem;
}
@media (max-width: 575.98px) {
/* edge-to-edge on mobile: cancel the container's px-4 padding */
.history-tile {
margin-left: -1.5rem;
margin-right: -1.5rem;
border-radius: 0;
}
}
@media (min-width: 576px) {
.history-tile {
border-radius: 1rem;
padding: 1.5rem 1.5rem 2rem;
}
}
</style>

View file

@ -383,6 +383,9 @@ export default defineComponent({
logAvailableAreas: [] as string[],
};
},
head() {
return { title: this.$t("issue.title") };
},
computed: {
versionString(): string {
return `v${store.state.version || ""}`;

View file

@ -3,31 +3,25 @@
<TopHeader :title="$t('sessions.title')" :notifications="notifications" />
<div class="row">
<main class="col-12">
<div class="header-outer sticky-top">
<div class="container px-4">
<div
class="row py-3 py-sm-3 d-flex flex-column flex-sm-row gap-3 gap-lg-0 mb-lg-2"
>
<div class="col-lg-5 d-flex mb-lg-0">
<PeriodSelector
:period="period"
:periodOptions="periodOptions"
@update:period="changePeriod"
/>
</div>
<div v-if="showDateNavigator" class="col-lg-6 offset-lg-1">
<DateNavigator
:month="month"
:year="year"
:startDate="startDate"
:showMonth="showMonthNavigation"
:showYear="showYearNavigation"
@update-date="updateDate"
/>
</div>
</div>
</div>
</div>
<PeriodHeader>
<template #period>
<PeriodSelector
:period="period"
:periodOptions="periodOptions"
@update:period="changePeriod"
/>
</template>
<template v-if="showDateNavigator" #navigator>
<DateNavigator
:month="month"
:year="year"
:startDate="startDate"
:showMonth="showMonthNavigation"
:showYear="showYearNavigation"
@update-date="updateDate"
/>
</template>
</PeriodHeader>
<div class="d-flex gap-3 mb-5 justify-content-between flex-wrap pt-1">
<IconSelectGroup>
@ -209,6 +203,7 @@ import colors from "../colors";
import settings from "../settings";
import PeriodSelector from "../components/Sessions/PeriodSelector.vue";
import DateNavigator from "../components/Sessions/DateNavigator.vue";
import PeriodHeader from "../components/Sessions/PeriodHeader.vue";
import DynamicPriceIcon from "../components/MaterialIcon/DynamicPrice.vue";
import TotalIcon from "../components/MaterialIcon/Total.vue";
import { TYPES, GROUPS, PERIODS, type Session } from "../components/Sessions/types";
@ -231,6 +226,7 @@ export default defineComponent({
SolarYearChart,
PeriodSelector,
DateNavigator,
PeriodHeader,
DynamicPriceIcon,
CostHistoryChart,
CostGroupedChart,
@ -342,10 +338,12 @@ export default defineComponent({
});
},
periodOptions() {
return Object.entries(PERIODS).map(([key, value]) => ({
name: this.$t(`sessions.period.${key.toLowerCase()}`),
value,
}));
return Object.entries(PERIODS)
.filter(([, value]) => value !== PERIODS.DAY)
.map(([key, value]) => ({
name: this.$t(`sessions.period.${key.toLowerCase()}`),
value,
}));
},
typeOptions() {
const options = Object.values(TYPES).map((value) => {
@ -741,51 +739,4 @@ export default defineComponent({
<style scoped>
@import "../../css/breakpoints.css";
.header-outer {
--vertical-shift: 0rem;
left: 0;
right: 0;
top: max(0rem, env(safe-area-inset-top)) !important;
margin: 0 calc(calc(1.5rem + var(--vertical-shift)) * -1);
background-color: var(--evcc-background);
box-shadow: 0 1px 8px 0px var(--evcc-background);
}
@supports (backdrop-filter: blur(1px)) {
.header-outer {
background-color: #0000;
backdrop-filter: var(--evcc-backdrop-blur);
}
}
@media (--sm-and-up) {
.header-outer {
--vertical-shift: calc((100vw - 560px) / 2);
}
}
@media (--md-and-up) {
.header-outer {
--vertical-shift: calc((100vw - 740px) / 2);
}
}
@media (--lg-and-up) {
.header-outer {
--vertical-shift: calc((100vw - 980px) / 2);
}
}
@media (--xl-and-up) {
.header-outer {
--vertical-shift: calc((100vw - 1160px) / 2);
}
}
@media (--xxl-and-up) {
.header-outer {
--vertical-shift: calc((100vw - 1340px) / 2);
}
}
</style>

View file

@ -2,6 +2,7 @@ package keys
const (
// loadpoint settings
Name = "name" // loadpoint name (config identifier)
Title = "title" // loadpoint title
Mode = "mode" // charge mode
DefaultMode = "defaultMode" // default charge mode

View file

@ -9,13 +9,13 @@ import (
const (
// groups
Forecast = "forecast"
Battery = "battery"
Grid = "grid"
PV = "pv"
Home = "home" // meter and group (virtual measurement)
Loadpoint = "loadpoint"
Meter = "meter" // generic meter (ext/aux)
Forecast = "forecast"
)
type Collector struct {

View file

@ -1,11 +1,17 @@
package metrics
import (
"context"
"errors"
"fmt"
"io"
"math"
"sort"
"strconv"
"time"
"github.com/evcc-io/evcc/server/db"
csvutil "github.com/evcc-io/evcc/util/csv"
)
// Slot represents an aggregated energy time slot
@ -16,6 +22,11 @@ type Slot struct {
ReturnEnergy float64 `json:"returnEnergy"`
}
// roundEnergy rounds kWh to Wh precision and clamps negative noise to zero.
func roundEnergy(v float64) float64 {
return max(0, math.Round(v*1000)/1000)
}
// Series represents a named series of energy slots
type Series struct {
Name string `json:"name,omitempty"`
@ -23,6 +34,12 @@ type Series struct {
Data []Slot `json:"data"`
}
// SeriesCSV wraps a slice of Series for CSV export.
type SeriesCSV []Series
// csvGroupOrder mirrors the frontend GROUP_ORDER plus home/forecast.
var csvGroupOrder = []string{PV, Battery, Grid, Loadpoint, Meter, Home, Forecast}
var aggregateFormats = map[string]string{
"15m": "%Y-%m-%d %H:%M",
"hour": "%Y-%m-%d %H:00",
@ -95,10 +112,147 @@ func QueryEnergy(from, to time.Time, aggregate string, grouped bool) ([]Series,
s.Data = append(s.Data, Slot{
Start: time.Time(r.Start),
End: addDuration(time.Time(r.Start)),
Energy: r.Energy,
ReturnEnergy: r.ReturnEnergy,
Energy: roundEnergy(r.Energy),
ReturnEnergy: roundEnergy(r.ReturnEnergy),
})
}
return res, nil
}
// hasReturnEnergy reports whether a group's CSV export includes a returnEnergy
// column. Only the bidirectional groups (grid, battery) do; the rest emit a
// single energy column.
func hasReturnEnergy(group string) bool {
return group == Grid || group == Battery
}
// WriteCsv emits a wide-table CSV with columns
//
// time.start, time.end, <group>.<entity>.energy.Wh[, <group>.<entity>.returnEnergy.Wh], …
//
// Only grid and battery contribute a second returnEnergy column.
// Values are plain Wh integers (the DB is already rounded to milli-kWh) — no
// decimal point, no thousands separator, so they survive locale-mismatched
// spreadsheet importers unambiguously.
func (s SeriesCSV) WriteCsv(ctx context.Context, w io.Writer) error {
ww, _, err := csvutil.NewLocalizedWriter(ctx, w)
if err != nil {
return err
}
byGroup := make(map[string][]*Series)
for i := range s {
g := s[i].Group
byGroup[g] = append(byGroup[g], &s[i])
}
rank := make(map[string]int, len(csvGroupOrder))
for i, g := range csvGroupOrder {
rank[g] = i
}
groups := make([]string, 0, len(byGroup))
for g := range byGroup {
groups = append(groups, g)
}
sort.Slice(groups, func(i, j int) bool {
ri, oki := rank[groups[i]]
rj, okj := rank[groups[j]]
switch {
case oki && okj:
return ri < rj
case oki:
return true
case okj:
return false
default:
return groups[i] < groups[j]
}
})
header := []string{"time.start", "time.end"}
type col struct {
series *Series
returnEnergy bool
}
cols := []col{{}, {}}
tsSet := make(map[int64]time.Time)
endByStart := make(map[int64]time.Time)
for _, g := range groups {
entities := byGroup[g]
sort.Slice(entities, func(i, j int) bool { return entities[i].Name < entities[j].Name })
for _, e := range entities {
name := e.Name
if name == "" {
name = g
}
header = append(header, g+"."+name+".energy.Wh")
cols = append(cols, col{series: e, returnEnergy: false})
if hasReturnEnergy(g) {
header = append(header, g+"."+name+".returnEnergy.Wh")
cols = append(cols, col{series: e, returnEnergy: true})
}
for _, slot := range e.Data {
tsSet[slot.Start.UnixNano()] = slot.Start
endByStart[slot.Start.UnixNano()] = slot.End
}
}
}
if err := ww.Write(header); err != nil {
return err
}
if len(tsSet) == 0 {
ww.Flush()
return ww.Error()
}
timestamps := make([]time.Time, 0, len(tsSet))
for _, t := range tsSet {
timestamps = append(timestamps, t)
}
sort.Slice(timestamps, func(i, j int) bool { return timestamps[i].Before(timestamps[j]) })
slotIdx := make(map[*Series]map[int64]Slot)
for _, list := range byGroup {
for _, e := range list {
m := make(map[int64]Slot, len(e.Data))
for _, slot := range e.Data {
m[slot.Start.UnixNano()] = slot
}
slotIdx[e] = m
}
}
row := make([]string, len(cols))
for _, ts := range timestamps {
row[0] = ts.Local().Format("2006-01-02 15:04:05")
row[1] = endByStart[ts.UnixNano()].Local().Format("2006-01-02 15:04:05")
for i := 2; i < len(cols); i++ {
c := cols[i]
if c.series == nil {
row[i] = ""
continue
}
slot, ok := slotIdx[c.series][ts.UnixNano()]
if !ok {
row[i] = ""
continue
}
v := slot.Energy
if c.returnEnergy {
v = slot.ReturnEnergy
}
row[i] = strconv.FormatInt(int64(math.Round(v*1000)), 10)
}
if err := ww.Write(row); err != nil {
return err
}
}
ww.Flush()
return ww.Error()
}

View file

@ -0,0 +1,134 @@
package metrics
import (
"bytes"
"context"
"encoding/csv"
"strings"
"testing"
"time"
"github.com/evcc-io/evcc/util/locale"
"github.com/stretchr/testify/require"
)
func mkSlot(t time.Time, energy, returnEnergy float64) Slot {
return Slot{Start: t, End: t.Add(15 * time.Minute), Energy: energy, ReturnEnergy: returnEnergy}
}
func TestSeriesCSV_HeaderAndLayout(t *testing.T) {
t0 := time.Date(2026, 5, 14, 12, 0, 0, 0, time.UTC)
t1 := t0.Add(15 * time.Minute)
series := SeriesCSV{
{Group: PV, Name: "pv", Data: []Slot{mkSlot(t0, 2.5, 0), mkSlot(t1, 3.0, 0)}},
{Group: Battery, Name: "battery-home", Data: []Slot{mkSlot(t0, 0, 0.7800), mkSlot(t1, 0.5300, 0)}},
{Group: Battery, Name: "battery-garage", Data: []Slot{mkSlot(t0, 0, 1.2500), mkSlot(t1, 0, 0)}},
{Group: Grid, Name: "grid", Data: []Slot{mkSlot(t0, 0, 0.4123), mkSlot(t1, 0.1, 0)}},
{Group: Home, Name: "home", Data: []Slot{mkSlot(t0, 0.3661, 0), mkSlot(t1, 0.4, 0)}},
}
var buf bytes.Buffer
require.NoError(t, series.WriteCsv(context.Background(), &buf))
// UTF-8 BOM
require.True(t, bytes.HasPrefix(buf.Bytes(), []byte{0xEF, 0xBB, 0xBF}), "expected UTF-8 BOM")
r := csv.NewReader(bytes.NewReader(bytes.TrimPrefix(buf.Bytes(), []byte{0xEF, 0xBB, 0xBF})))
rows, err := r.ReadAll()
require.NoError(t, err)
require.Len(t, rows, 3, "expected header + 2 data rows")
header := rows[0]
expected := []string{
"time.start", "time.end",
"pv.pv.energy.Wh",
"battery.battery-garage.energy.Wh", "battery.battery-garage.returnEnergy.Wh",
"battery.battery-home.energy.Wh", "battery.battery-home.returnEnergy.Wh",
"grid.grid.energy.Wh", "grid.grid.returnEnergy.Wh",
"home.home.energy.Wh",
}
require.Equal(t, expected, header, "header order: GROUP_ORDER, alphabetical entities; returnEnergy only for grid/battery")
// time.end = time.start + slot length (15 min in mkSlot)
require.Equal(t, t0.Local().Format("2006-01-02 15:04:05"), rows[1][0])
require.Equal(t, t0.Add(15*time.Minute).Local().Format("2006-01-02 15:04:05"), rows[1][1])
// First data row values (t0) — plain Wh integers. Cols start at index 2.
require.Equal(t, "2500", rows[1][2]) // pv.pv energy
require.Equal(t, "0", rows[1][3]) // battery-garage energy
require.Equal(t, "1250", rows[1][4]) // battery-garage returnEnergy
require.Equal(t, "0", rows[1][5]) // battery-home energy
require.Equal(t, "780", rows[1][6]) // battery-home returnEnergy
require.Equal(t, "0", rows[1][7]) // grid energy
require.Equal(t, "412", rows[1][8]) // grid returnEnergy
require.Equal(t, "366", rows[1][9]) // home energy
}
func TestSeriesCSV_GermanLocale(t *testing.T) {
t0 := time.Date(2026, 5, 14, 12, 0, 0, 0, time.UTC)
series := SeriesCSV{
{Group: PV, Name: "pv", Data: []Slot{mkSlot(t0, 2.5, 0)}},
}
var buf bytes.Buffer
ctx := context.WithValue(context.Background(), locale.Locale, "de")
require.NoError(t, series.WriteCsv(ctx, &buf))
s := string(bytes.TrimPrefix(buf.Bytes(), []byte{0xEF, 0xBB, 0xBF}))
require.True(t, strings.Contains(s, ";"), "german CSV must use ';' separator")
// Values are plain Wh integers so they're locale-agnostic. We just check
// no decimal point / comma snuck in.
require.True(t, strings.Contains(s, "2500"), "value should be Wh integer")
require.False(t, strings.Contains(s, "2.500"), "no '.' decimal/thousands")
require.False(t, strings.Contains(s, "2,500"), "no ',' decimal/thousands")
}
func TestSeriesCSV_MissingSlotIsEmpty(t *testing.T) {
t0 := time.Date(2026, 5, 14, 12, 0, 0, 0, time.UTC)
t1 := t0.Add(15 * time.Minute)
// Two entities, second one only has data for the first timestamp → second
// timestamp must produce an empty cell rather than 0.000.
series := SeriesCSV{
{Group: PV, Name: "a", Data: []Slot{mkSlot(t0, 1, 0), mkSlot(t1, 2, 0)}},
{Group: PV, Name: "b", Data: []Slot{mkSlot(t0, 3, 0)}},
}
var buf bytes.Buffer
require.NoError(t, series.WriteCsv(context.Background(), &buf))
r := csv.NewReader(bytes.NewReader(bytes.TrimPrefix(buf.Bytes(), []byte{0xEF, 0xBB, 0xBF})))
rows, err := r.ReadAll()
require.NoError(t, err)
require.Len(t, rows, 3)
require.Equal(t, []string{"time.start", "time.end", "pv.a.energy.Wh", "pv.b.energy.Wh"}, rows[0])
// row 1: t0 has both entities (Wh integers); cols start at index 2.
require.Equal(t, "1000", rows[1][2])
require.Equal(t, "3000", rows[1][3])
// row 2: t1 has only entity a
require.Equal(t, "2000", rows[2][2])
require.Equal(t, "", rows[2][3], "missing slot must be empty, not zero")
}
func TestSeriesCSV_BatteryHasReturnEnergyColumn(t *testing.T) {
// Battery is bidirectional, so even a series with zero discharge keeps the
// returnEnergy column.
t0 := time.Date(2026, 5, 14, 12, 0, 0, 0, time.UTC)
series := SeriesCSV{
{Group: Battery, Name: "bat", Data: []Slot{mkSlot(t0, 0.5, 0)}},
}
var buf bytes.Buffer
require.NoError(t, series.WriteCsv(context.Background(), &buf))
r := csv.NewReader(bytes.NewReader(bytes.TrimPrefix(buf.Bytes(), []byte{0xEF, 0xBB, 0xBF})))
rows, err := r.ReadAll()
require.NoError(t, err)
require.Equal(t, []string{"time.start", "time.end", "battery.bat.energy.Wh", "battery.bat.returnEnergy.Wh"}, rows[0])
require.Equal(t, "500", rows[1][2])
require.Equal(t, "0", rows[1][3])
}

View file

@ -519,6 +519,7 @@ func (site *Site) collectMeters(key string, meters []config.Device[api.Meter]) [
props := deviceProperties(dev)
mm[i] = types.Measurement{
Name: dev.Config().Name,
Title: props.Title,
Icon: props.Icon,
Power: power,
@ -757,7 +758,7 @@ func (site *Site) updateGridMeter() error {
return nil
}
var mm types.Measurement
mm := types.Measurement{Name: site.Meters.GridMeterRef}
if res, err := backoff.RetryWithData(site.gridMeter.CurrentPower, modbus.Backoff()); err == nil {
mm.Power = res
@ -1087,6 +1088,8 @@ func (site *Site) Prepare(valueChan chan<- util.Param, pushChan chan<- messenger
site.prepare()
lpDevices := config.Loadpoints().Devices()
for id, lp := range site.loadpoints {
lpUIChan := make(chan util.Param)
lpPushChan := make(chan messenger.Event)
@ -1105,6 +1108,11 @@ func (site *Site) Prepare(valueChan chan<- util.Param, pushChan chan<- messenger
}
}(id)
// publish name on the loadpoint's behalf — it doesn't know its own
if id < len(lpDevices) {
site.valueChan <- util.Param{Loadpoint: &id, Key: keys.Name, Val: lpDevices[id].Config().Name}
}
lp.Prepare(site, lpUIChan, lpPushChan, site.lpUpdateChan)
}
}

View file

@ -8,10 +8,12 @@ import (
// Measurement is the device measurements struct
type Measurement struct {
Name string `json:"name,omitempty"`
Title string `json:"title,omitempty"`
Icon string `json:"icon,omitempty"`
Power float64 `json:"power"`
Energy float64 `json:"energy,omitempty"`
ReturnEnergy float64 `json:"returnEnergy,omitempty"`
Powers []float64 `json:"powers,omitempty"`
Currents []float64 `json:"currents,omitempty"`
ExcessDCPower float64 `json:"excessdcpower,omitempty"`

View file

@ -1200,6 +1200,30 @@
"description": "Reduziere Ladeleistung, um {limit} nicht zu überschreiten.",
"title": "Externe Begrenzung:"
},
"history": {
"direction": {
"battery": {
"energy": "geladen",
"returnEnergy": "entladen"
},
"grid": {
"energy": "bezogen",
"returnEnergy": "eingespeist"
}
},
"downloadCsv": "CSV herunterladen",
"empty": "Keine Verlaufsdaten für diesen Zeitraum.",
"group": {
"battery": "Batterie",
"forecast": "Vorhersage",
"grid": "Netz",
"loadpoint": "Laden & Heizen",
"meter": "Verbrauch",
"pv": "Erzeugung"
},
"otherConsumers": "Andere",
"title": "Historie"
},
"loadpoint": {
"avgPrice": "⌀ Preis",
"charged": "Geladen",
@ -1471,6 +1495,7 @@
"odometer": "Kilometerstand",
"overview": "Übersicht",
"period": {
"day": "Tag",
"month": "Monat",
"total": "Gesamt",
"year": "Jahr"

View file

@ -1200,6 +1200,30 @@
"description": "Reduced charging to not exceed {limit}.",
"title": "External limit:"
},
"history": {
"direction": {
"battery": {
"energy": "charged",
"returnEnergy": "discharged"
},
"grid": {
"energy": "imported",
"returnEnergy": "exported"
}
},
"downloadCsv": "Download CSV",
"empty": "No history data available for this time range.",
"group": {
"battery": "Battery",
"forecast": "Forecast",
"grid": "Grid",
"loadpoint": "Charging & Heating",
"meter": "Consumption",
"pv": "Production"
},
"otherConsumers": "Others",
"title": "History"
},
"loadpoint": {
"avgPrice": "⌀ Price",
"charged": "Charged",
@ -1471,6 +1495,7 @@
"odometer": "Mileage",
"overview": "Overview",
"period": {
"day": "Day",
"month": "Month",
"total": "Total",
"year": "Year"

View file

@ -1,12 +1,15 @@
package server
import (
"context"
"errors"
"net/http"
"time"
"github.com/evcc-io/evcc/core/metrics"
"github.com/evcc-io/evcc/server/db"
"github.com/evcc-io/evcc/util/locale"
"golang.org/x/text/language"
)
// energyHistoryHandler returns aggregated energy history data
@ -49,5 +52,33 @@ func energyHistoryHandler(w http.ResponseWriter, r *http.Request) {
return
}
if q.Get("format") == "csv" {
lang := q.Get("lang")
if lang == "" {
if tags, _, err := language.ParseAcceptLanguage(r.Header.Get("Accept-Language")); err == nil && len(tags) > 0 {
lang = tags[0].String()
}
}
ctx := context.WithValue(context.Background(), locale.Locale, lang)
csvResult(ctx, w, metrics.SeriesCSV(res), historyFilename(from, aggregate))
return
}
jsonWrite(w, res)
}
// historyFilename returns history-energy-YYYY-MM-DD / -YYYY-MM / -YYYY
// for day/month/year aggregates.
func historyFilename(from time.Time, aggregate string) string {
if from.IsZero() {
return "history-energy"
}
format := "2006-01-02"
switch aggregate {
case "day":
format = "2006-01"
case "month":
format = "2006"
}
return "history-energy-" + from.Local().Format(format)
}

View file

@ -106,32 +106,32 @@ func (suite *mqttSuite) TestNilInterface() {
func (suite *mqttSuite) TestMeasurement() {
topics := lo.Map([]string{
"title", "icon", "power", "energy", "powers", "currents", "excessDCPower", "capacity", "soc", "controllable",
"name", "title", "icon", "power", "energy", "returnEnergy", "powers", "currents", "excessDCPower", "capacity", "soc", "controllable",
}, func(s string, _ int) string {
return "test/" + s
})
suite.publish("test", false, types.Measurement{})
suite.Equal(topics, suite.topics, "topics")
suite.Equal([]string{"", "", "0", "", "", "", "", "", "", ""}, suite.payloads, "empty payloads")
suite.Equal([]string{"", "", "", "0", "", "", "", "", "", "", "", ""}, suite.payloads, "empty payloads")
suite.publish("test", false, types.Measurement{Energy: 1})
suite.Equal(topics, suite.topics, "topics")
suite.Equal([]string{"", "", "0", "1", "", "", "", "", "", ""}, suite.payloads, "energy payloads")
suite.Equal([]string{"", "", "", "0", "1", "", "", "", "", "", "", ""}, suite.payloads, "energy payloads")
suite.publish("test", false, types.Measurement{Controllable: new(false)})
suite.Equal(topics, suite.topics, "topics")
suite.Equal([]string{"", "", "0", "", "", "", "", "", "", "false"}, suite.payloads, "controllable payloads")
suite.Equal([]string{"", "", "", "0", "", "", "", "", "", "", "", "false"}, suite.payloads, "controllable payloads")
suite.publish("test", false, types.Measurement{Currents: []float64{1, 2, 3}})
suite.Equal(append(topics, "test/currents/1", "test/currents/2", "test/currents/3"), suite.topics, "currents topics")
suite.Equal([]string{"", "", "0", "", "", "3", "", "", "", "", "1", "2", "3"}, suite.payloads, "currents payloads")
suite.Equal([]string{"", "", "", "0", "", "", "", "3", "", "", "", "", "1", "2", "3"}, suite.payloads, "currents payloads")
}
func (suite *mqttSuite) TestBatteryState() {
topics := lo.Map([]string{
"power", "energy", "capacity", "soc",
"devices", "devices/1/title", "devices/1/icon", "devices/1/power", "devices/1/energy", "devices/1/powers", "devices/1/currents", "devices/1/excessDCPower", "devices/1/capacity", "devices/1/soc", "devices/1/controllable",
"devices", "devices/1/name", "devices/1/title", "devices/1/icon", "devices/1/power", "devices/1/energy", "devices/1/returnEnergy", "devices/1/powers", "devices/1/currents", "devices/1/excessDCPower", "devices/1/capacity", "devices/1/soc", "devices/1/controllable",
"forecast",
}, func(s string, _ int) string {
return "test/" + s
@ -147,5 +147,5 @@ func (suite *mqttSuite) TestBatteryState() {
})
suite.Equal(topics, suite.topics, "topics")
suite.Equal([]string{"2", "", "", "20", "1", "", "", "1", "", "", "", "", "", "10", "", ""}, suite.payloads, "payloads")
suite.Equal([]string{"2", "", "", "20", "1", "", "", "", "1", "", "", "", "", "", "", "10", "", ""}, suite.payloads, "payloads")
}

View file

@ -90,15 +90,16 @@ func writeRow(ww *csv.Writer, mp *message.Printer, structVal any) error {
return ww.Write(row)
}
// WriteStructSlice writes a slice of structs to CSV format with localized headers
func WriteStructSlice(ctx context.Context, w io.Writer, slice any, cfg Config) error {
// NewLocalizedWriter writes a UTF-8 BOM and returns a locale-configured
// csv.Writer (German uses ';') and matching number printer.
func NewLocalizedWriter(ctx context.Context, w io.Writer) (*csv.Writer, *message.Printer, error) {
if _, err := w.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
return err
return nil, nil, err
}
lang := locale.Language
if language, ok := ctx.Value(locale.Locale).(string); ok && language != "" {
lang = language
if v, ok := ctx.Value(locale.Locale).(string); ok && v != "" {
lang = v
}
if lang == "" {
lang = "en"
@ -106,15 +107,24 @@ func WriteStructSlice(ctx context.Context, w io.Writer, slice any, cfg Config) e
tag, err := language.Parse(lang)
if err != nil {
return err
return nil, nil, err
}
ww := csv.NewWriter(w)
if b, _ := tag.Base(); b.String() == language.German.String() {
ww.Comma = ';'
}
return ww, message.NewPrinter(tag), nil
}
// WriteStructSlice writes a slice of structs to CSV format with localized headers
func WriteStructSlice(ctx context.Context, w io.Writer, slice any, cfg Config) error {
ww, mp, err := NewLocalizedWriter(ctx, w)
if err != nil {
return err
}
sliceVal := reflect.ValueOf(slice)
if sliceVal.Kind() == reflect.Pointer {
sliceVal = sliceVal.Elem()
@ -134,7 +144,6 @@ func WriteStructSlice(ctx context.Context, w io.Writer, slice any, cfg Config) e
return err
}
mp := message.NewPrinter(tag)
for i := 0; i < sliceVal.Len(); i++ {
row := sliceVal.Index(i).Interface()
if err := writeRow(ww, mp, row); err != nil {

View file

@ -28,15 +28,19 @@ export default defineConfig({
assetsInlineLimit: 1024,
chunkSizeWarningLimit: 800, // legacy build increases file size
},
server: {
port: 7071,
proxy: {
"/api": "http://localhost:7070",
"/i18n": "http://localhost:7070",
"/providerauth": "http://localhost:7070",
"/ws": { target: "ws://localhost:7070", ws: true },
},
},
server: (() => {
const frontend = Number(process.env.VITE_PORT) || 7071;
const backend = Number(process.env.VITE_BACKEND_PORT) || 7070;
return {
port: frontend,
proxy: {
"/api": `http://localhost:${backend}`,
"/i18n": `http://localhost:${backend}`,
"/providerauth": `http://localhost:${backend}`,
"/ws": { target: `ws://localhost:${backend}`, ws: true },
},
};
})(),
plugins: [
legacy({
modernPolyfills: ["es.promise.all-settled"],