diff --git a/assets/js/components/Forecast/echarts.ts b/assets/js/components/Forecast/echarts.ts
index 92b91398e..5095a9dc9 100644
--- a/assets/js/components/Forecast/echarts.ts
+++ b/assets/js/components/Forecast/echarts.ts
@@ -121,11 +121,16 @@ export function tooltipTable(head: string, rows: TooltipRow[], headers?: string[
const hasName = rows.some((r) => r.name != null);
const valueCols = Math.max(1, ...rows.map((r) => r.values.length));
const colCount = (hasName ? 1 : 0) + valueCols;
- // lone value centers under the date; multiple columns line up right
- const valCls = hasName || valueCols > 1 ? "fw-normal text-end ps-3" : "fw-normal text-center";
+ // No name col + two value cols: first col left-aligned, second right-aligned.
+ // Otherwise: lone value centers, multiple/named columns right-align.
+ const valClsFn = (i: number): string => {
+ if (!hasName && valueCols > 1 && i === 0) return "fw-normal text-start";
+ if (hasName || valueCols > 1) return "fw-normal text-end ps-3";
+ return "fw-normal text-center";
+ };
const headerRow = headers?.length
? `
${hasName ? " | " : ""}${headers
- .map((h) => `${h} | `)
+ .map((h, i) => `${h} | `)
.join("")}
`
: "";
const body = rows
@@ -133,7 +138,7 @@ export function tooltipTable(head: string, rows: TooltipRow[], headers?: string[
const nameTd = hasName
? `${escapeHtml(r.name ?? "")} | `
: "";
- const valTds = r.values.map((v) => `${v} | `).join("");
+ const valTds = r.values.map((v, i) => `${v} | `).join("");
return `${nameTd}${valTds}
`;
})
.join("");
diff --git a/assets/js/components/History/GroupChart.vue b/assets/js/components/History/GroupChart.vue
index bdeb94d6f..1bc84d3c1 100644
--- a/assets/js/components/History/GroupChart.vue
+++ b/assets/js/components/History/GroupChart.vue
@@ -54,6 +54,9 @@ export function stepAlpha(i: number, n: number): number {
// Symmetric axis regardless of whether the period contains both directions.
const BIDIRECTIONAL_GROUPS: ReadonlySet = new Set(["grid", "battery"]);
+// Multiple entities stack into one bar; grid and meter render side-by-side.
+const STACKED_GROUPS: ReadonlySet = new Set(["loadpoint", "consumer", "pv", "battery"]);
+
// Round up to a nice number (5-tick symmetric axis: -L, -L/2, 0, L/2, L).
function niceCeil(v: number): number {
if (v <= 0) return 0;
@@ -104,19 +107,33 @@ export default defineComponent({
valueFactor(): number {
return this.period === PERIODS.DAY ? 4 : 1;
},
+ stackEntities(): boolean {
+ return STACKED_GROUPS.has(this.group);
+ },
// Peak of stacked per-slot sums, incl. overlay when shown so its line isn't
// clipped. Bidirectional: pos/neg separately.
axisPeak(): number {
const factor = this.valueFactor;
+ // Stacked groups: sum entities per slot. Unstacked (grid, meter): max per entity.
const peak = (series: HistorySeries[], pick: (slot: HistorySlot) => number) => {
- const sums = new Map();
- for (const s of series) {
- for (const slot of s.data) {
- sums.set(slot.start, (sums.get(slot.start) || 0) + pick(slot) * factor);
+ if (this.stackEntities) {
+ const sums = new Map();
+ for (const s of series) {
+ for (const slot of s.data) {
+ sums.set(slot.start, (sums.get(slot.start) || 0) + pick(slot) * factor);
+ }
}
+ let max = 0;
+ for (const v of sums.values()) if (v > max) max = v;
+ return max;
}
let max = 0;
- for (const v of sums.values()) if (v > max) max = v;
+ for (const s of series) {
+ for (const slot of s.data) {
+ const v = pick(slot) * factor;
+ if (v > max) max = v;
+ }
+ }
return max;
};
if (this.isBidirectional) {
@@ -269,13 +286,6 @@ export default defineComponent({
// 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 === "consumer" ||
- 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.
@@ -327,13 +337,10 @@ export default defineComponent({
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.
- // Stable identity for focus comparison: paletteIndex when set
- // (filtered groups), otherwise plain array index.
+ const stackName = this.stackEntities ? `group-${this.group}` : `entity-${i}`;
+ // Rounded cap goes on the visible top/bottom per slot: non-stacked bars
+ // always cap; stacked groups cap the topmost non-zero entity so an empty
+ // top entity doesn't drop the rounding; a focused entity is solo.
const stableIdx = s.paletteIndex ?? i;
const energyData: (
| number
@@ -342,7 +349,7 @@ export default defineComponent({
)[] = energyValues.map((v, idx) => {
if (v == null) return v;
const isTop =
- !stackEntities ||
+ !this.stackEntities ||
topEnergyPerSlot[idx] === i ||
this.focusedEntity === stableIdx;
if (!isTop) return v;
@@ -355,7 +362,7 @@ export default defineComponent({
)[] = returnEnergyValues.map((v, idx) => {
if (v == null) return v;
const isBottom =
- !stackEntities ||
+ !this.stackEntities ||
topReturnEnergyPerSlot[idx] === i ||
this.focusedEntity === stableIdx;
if (!isBottom) return v;
@@ -416,7 +423,8 @@ export default defineComponent({
: (t: number) => this.fmtMonth(new Date(t), true);
},
// Column headers for bidirectional tooltips (grid: imported/exported,
- // battery: charged/discharged). Null when the group has no direction labels.
+ // battery: charged/discharged, meter: energy/reverse). Null when the group
+ // has no direction labels.
directionHeaders(): string[] | null {
if (!this.isBidirectional) return null;
const energyKey = `main.history.direction.${this.group}.energy`;
diff --git a/assets/js/views/History.vue b/assets/js/views/History.vue
index 172889d58..712728878 100644
--- a/assets/js/views/History.vue
+++ b/assets/js/views/History.vue
@@ -59,7 +59,7 @@
{{ $t(`main.history.group.${group}`) }}
-
+
{{ groupTotalLabel(group) }}
@@ -447,6 +447,8 @@ export default defineComponent({
return focused !== null && focused !== i;
},
groupTotalLabel(group: string): string {
+ // Additional meters can be import, export, or consumption, so no meaningful sum.
+ if (group === "meter") return "";
// Consumption total comes from `home` (overall consumption),
// not the sum of explicit meter entities.
const list =
diff --git a/i18n/de.json b/i18n/de.json
index 1d62d0407..2bab9c2d9 100644
--- a/i18n/de.json
+++ b/i18n/de.json
@@ -1266,6 +1266,10 @@
"grid": {
"energy": "bezogen",
"returnEnergy": "eingespeist"
+ },
+ "meter": {
+ "energy": "Energie",
+ "returnEnergy": "Energie (rückwärts)"
}
},
"downloadCsv": "CSV herunterladen",
diff --git a/i18n/en.json b/i18n/en.json
index df34c3032..58f6fe75b 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -1265,6 +1265,10 @@
"grid": {
"energy": "imported",
"returnEnergy": "exported"
+ },
+ "meter": {
+ "energy": "Energy",
+ "returnEnergy": "Energy (reverse)"
}
},
"downloadCsv": "Download CSV",
diff --git a/tests/config-pv.spec.ts b/tests/config-pv.spec.ts
index 7215d5262..5c6fc8608 100644
--- a/tests/config-pv.spec.ts
+++ b/tests/config-pv.spec.ts
@@ -23,13 +23,15 @@ test.describe("pv meter", async () => {
await page.getByRole("button", { name: "Add solar or battery" }).click();
const meterModal = page.getByTestId("meter-modal");
+ await expectModalVisible(meterModal);
await meterModal.getByRole("button", { name: "Add solar meter" }).click();
await meterModal.getByLabel("Title").fill("PV North");
await meterModal.getByLabel("Manufacturer").selectOption("Demo meter");
- await meterModal.getByLabel("Power optional").fill("5000");
await page.getByRole("button", { name: "Show advanced settings" }).click();
await expect(meterModal.getByLabel("Minimum charge")).not.toBeVisible(); // battery usage only
await expect(meterModal.getByLabel("Maximum AC power of the hybrid inverter")).toBeVisible(); // pv usage only
+ await meterModal.getByLabel("Power optional").fill("5000");
+ await expect(meterModal.getByLabel("Power optional")).toHaveValue("5000");
await expect(meterModal.getByRole("button", { name: "Validate & save" })).toBeVisible();
await meterModal.getByRole("link", { name: "validate" }).click();
await expect(meterModal.getByTestId("device-tag-power")).toContainText("5.0 kW");
diff --git a/tests/energy-history.spec.ts b/tests/energy-history.spec.ts
index 53dd209ce..07fe356ee 100644
--- a/tests/energy-history.spec.ts
+++ b/tests/energy-history.spec.ts
@@ -227,8 +227,8 @@ test.describe("additional meters", () => {
const additional = section(page, "meter");
await expect(additional).toBeVisible();
- // Total is the entity sum, not home-derived.
- await expect(additional.getByRole("heading")).toContainText("1.2 kWh");
+ // No section total: additional meters can be import, export, or consumption.
+ await expect(additional.getByRole("heading")).not.toContainText("kWh");
// Explicit entity legend, no virtual "Others" (unlike the consumer group).
await expect(additional.getByRole("button", { name: "Submeter 1.2 kWh" })).toBeVisible();