From ba8596be961d6495dde98cb692e19a27d5583ab3 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 16 Aug 2026 19:35:44 +0200 Subject: [PATCH] Optimizer chart: day boundaries with weekday at 00:00 (#32894) --- assets/js/components/Optimize/ChargeChart.vue | 2 +- assets/js/components/Optimize/chart.test.ts | 35 +++++++++++++++++++ assets/js/components/Optimize/chart.ts | 22 +++++++++--- 3 files changed, 54 insertions(+), 5 deletions(-) create mode 100644 assets/js/components/Optimize/chart.test.ts diff --git a/assets/js/components/Optimize/ChargeChart.vue b/assets/js/components/Optimize/ChargeChart.vue index 1b89cb910..ad03867d9 100644 --- a/assets/js/components/Optimize/ChargeChart.vue +++ b/assets/js/components/Optimize/ChargeChart.vue @@ -146,7 +146,7 @@ export default defineComponent({ ...tooltipStyle(colors.text || ""), formatter: this.tooltipFormatter, }, - xAxis: slotXAxis(this.times), + xAxis: slotXAxis(this.times, this.weekdayShort), yAxis: forecastYAxis({ min: undefined, position: "right", diff --git a/assets/js/components/Optimize/chart.test.ts b/assets/js/components/Optimize/chart.test.ts new file mode 100644 index 000000000..cca07af43 --- /dev/null +++ b/assets/js/components/Optimize/chart.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "vite-plus/test"; +import { isMidnight, slotXAxis } from "./chart"; + +// 15min slots starting at the given local hour +function times(startHour: number, count: number): number[] { + const start = new Date(2025, 0, 1, startHour).getTime(); + return Array.from({ length: count }, (_, i) => start + i * 15 * 60 * 1000); +} + +describe("slotXAxis", () => { + const weekdayShort = () => "Thu"; + // 20:00 to 08:00, midnight at index 16 + const slots = times(20, 48); + const axis = slotXAxis(slots, weekdayShort); + const label = (i: number) => axis.axisLabel.formatter(String(slots[i])); + + test("labels full hours only", () => { + expect(label(0)).toBe("20"); // 20:00 + expect(label(1)).toBe(""); // 20:15 + expect(label(4)).toBe(""); // 21:00, not a step hour + expect(label(32)).toBe("4"); // 04:00 + }); + + test("shows the weekday at midnight", () => { + expect(label(16)).toBe("0\nThu"); + }); + + test("draws a split line at day boundaries only", () => { + expect(isMidnight(slots[16])).toBe(true); + expect(axis.splitLine.interval(16)).toBe(true); + expect(axis.splitLine.interval(15)).toBe(false); + // no line at the axis start, even if the first slot is midnight + expect(slotXAxis(times(0, 4), weekdayShort).splitLine.interval(0)).toBe(false); + }); +}); diff --git a/assets/js/components/Optimize/chart.ts b/assets/js/components/Optimize/chart.ts index 9ddf6f2ff..2aa026c4b 100644 --- a/assets/js/components/Optimize/chart.ts +++ b/assets/js/components/Optimize/chart.ts @@ -1,4 +1,5 @@ import { xAxisLabelStyle } from "../Forecast/echarts"; +import colors from "@/colors"; import type { BatteryDetail } from "@/types/evcc"; // loadpoint part of a vehicle entry title: "Carport (blue e-Golf)" → "Carport" @@ -19,22 +20,35 @@ export function slotTimes(timestamp: string, dt: number[]): number[] { return res; } -// category x axis over slot times, labels at full hours every 4h (6h on mobile) -export function slotXAxis(times: number[]) { +export function isMidnight(time?: number): boolean { + if (time === undefined) return false; + const d = new Date(time); + return d.getHours() === 0 && d.getMinutes() === 0; +} + +// category x axis over slot times, labels at full hours every 4h (6h on mobile), +// day boundaries as split line with the weekday shown at 00:00 +export function slotXAxis(times: number[], weekdayShort: (d: Date) => string) { const step = window.innerWidth < 576 ? 6 : 4; return { type: "category", data: times.map(String), axisLine: { show: false }, axisTick: { show: false }, - splitLine: { show: false }, + splitLine: { + show: true, + // split lines sit at the left edge of their slot + interval: (index: number) => index > 0 && isMidnight(times[index]), + lineStyle: { color: colors.muted || "", type: "solid" }, + }, axisLabel: { ...xAxisLabelStyle(), interval: 0, formatter: (value: string) => { const d = new Date(Number(value)); if (d.getMinutes() !== 0 || d.getHours() % step !== 0) return ""; - return String(d.getHours()); + const label = String(d.getHours()); + return d.getHours() === 0 ? `${label}\n${weekdayShort(d)}` : label; }, }, };