History UI: visualization and export (#29846)
This commit is contained in:
parent
a74982acc5
commit
431b606230
29 changed files with 1997 additions and 554 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -6,6 +6,7 @@ __debug_bin*
|
|||
*.db
|
||||
*.gz
|
||||
*.py
|
||||
testdata/**/*
|
||||
*.log
|
||||
*.json
|
||||
!i18n/*.json
|
||||
|
|
|
|||
|
|
@ -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
|
||||
],
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
661
assets/js/components/History/GroupChart.vue
Normal file
661
assets/js/components/History/GroupChart.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
16
assets/js/components/History/groups.ts
Normal file
16
assets/js/components/History/groups.ts
Normal 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() || "";
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
73
assets/js/components/Sessions/PeriodHeader.vue
Normal file
73
assets/js/components/Sessions/PeriodHeader.vue
Normal 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>
|
||||
|
|
@ -44,6 +44,7 @@ export enum GROUPS {
|
|||
}
|
||||
|
||||
export enum PERIODS {
|
||||
DAY = "day",
|
||||
MONTH = "month",
|
||||
YEAR = "year",
|
||||
TOTAL = "total",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 || ""}`;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
134
core/metrics/db_history_test.go
Normal file
134
core/metrics/db_history_test.go
Normal 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])
|
||||
}
|
||||
10
core/site.go
10
core/site.go
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
25
i18n/de.json
25
i18n/de.json
|
|
@ -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"
|
||||
|
|
|
|||
25
i18n/en.json
25
i18n/en.json
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue