diff --git a/assets/js/colors.ts b/assets/js/colors.ts index fe35b6f26..53a52b8a4 100644 --- a/assets/js/colors.ts +++ b/assets/js/colors.ts @@ -29,6 +29,7 @@ const colors: { light: string | null; selfPalette: string[]; palette: string[]; + batteryPalette: string[]; } = reactive({ text: null, muted: null, @@ -69,10 +70,11 @@ const colors: { "#6D28D9", // violet "#334155", // gray ], + batteryPalette: ["#0BA631", "#7FC41B", "#0A6E63", "#34D399", "#0E9E8F"], }); // normalize 6-digit hex to 8-digit, then replace alpha -const setAlpha = (color: string | null, alpha: string): string | undefined => { +export const setAlpha = (color: string | null, alpha: string): string | undefined => { if (!color) return undefined; const c = color.trim().toLowerCase(); // #rrggbb → append alpha, #rrggbbaa → replace alpha @@ -113,6 +115,12 @@ export function resolveColors(ids: string[], overrides: DeviceColors = {}): Devi return result; } +// dedicated battery palette, assigned by index (battery page + history battery group) +export function batteryColor(index: number): string { + const p = colors.batteryPalette; + return p[index % p.length] || ""; +} + export const dimColor = (color: string | null) => setAlpha(color, "20"); export const lighterColor = (color: string | null) => setAlpha(color, "aa"); diff --git a/assets/js/components/Battery/BatteryConfigCard.vue b/assets/js/components/Battery/BatteryConfigCard.vue new file mode 100644 index 000000000..89d973fc2 --- /dev/null +++ b/assets/js/components/Battery/BatteryConfigCard.vue @@ -0,0 +1,242 @@ + + + diff --git a/assets/js/components/Battery/BatteryExperimental.vue b/assets/js/components/Battery/BatteryExperimental.vue new file mode 100644 index 000000000..ffc687738 --- /dev/null +++ b/assets/js/components/Battery/BatteryExperimental.vue @@ -0,0 +1,173 @@ + + + diff --git a/assets/js/components/Battery/BatteryHistoryCard.stories.ts b/assets/js/components/Battery/BatteryHistoryCard.stories.ts new file mode 100644 index 000000000..d3c34647c --- /dev/null +++ b/assets/js/components/Battery/BatteryHistoryCard.stories.ts @@ -0,0 +1,108 @@ +import BatteryHistoryCard from "./BatteryHistoryCard.vue"; +import type { SocPoint, BatterySeries } from "./types"; +import type { Meta, StoryFn } from "@storybook/vue3"; + +export default { + title: "Battery/BatteryHistoryCard", + component: BatteryHistoryCard, + parameters: { + layout: "fullscreen", + backgrounds: { + default: "black", + values: [{ name: "black", value: "#000" }], + }, + }, +} as Meta; + +const HOUR = 3600 * 1000; +const STEP = 15 * 60 * 1000; // 15min, matches the history/energy "15m" aggregate +const NOW = new Date("2026-07-01T12:30:00"); + +const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v)); +const smoothstep = (a: number, b: number, x: number) => { + const t = clamp((x - a) / (b - a), 0, 1); + return t * t * (3 - 2 * t); +}; + +// relative daily soc profile (hour -> 0..~1.1): flat low overnight, charge ramp late morning, +// overshoot midday so it plateaus at full, steep discharge in the evening. Not a sine. +const PROFILE: [number, number][] = [ + [0, 0.2], + [5, 0.05], + [8, 0.08], + [11, 0.5], + [14, 1.12], + [17, 1.05], + [19, 0.6], + [21, 0.32], + [24, 0.2], +]; + +function dayShape(hour: number): number { + for (let i = 1; i < PROFILE.length; i++) { + const [h0, v0] = PROFILE[i - 1]!; + const [h1, v1] = PROFILE[i]!; + if (hour <= h1) return v0 + (v1 - v0) * smoothstep(h0, h1, hour); + } + return PROFILE.at(-1)![1]; +} + +// per-battery soc timeline at 15min resolution, same shape as the /history/energy data. +// each battery shifts the daily rhythm and its floor/ceiling; a slow continuous wave adds +// day-to-day variation. clamping the overshoot creates realistic full/empty plateaus. +function generateTimeline(from: number, to: number, seed: number): SocPoint[] { + const offset = seed * 1.6; // shift the daily rhythm per battery + const floor = 8 + seed * 5; + const ceil = 100 - seed * 8; + const pts: SocPoint[] = []; + for (let t = Math.ceil(from / STEP) * STEP; t <= to; t += STEP) { + const d = new Date(t); + const hour = (((d.getHours() + d.getMinutes() / 60 - offset) % 24) + 24) % 24; + const drift = 6 * Math.sin(t / (37 * HOUR) + seed); + const soc = floor + (ceil - floor) * clamp(dayShape(hour), 0, 1) + drift; + pts.push({ t, soc: clamp(soc, 5, 100) }); + } + return pts; +} + +const TITLES = ["Sungrow", "Anker", "Fronius", "BYD"]; +const CAPACITIES = [13.5, 7.5, 10, 5]; + +function buildBatteries(count: number, withForecast: boolean, now: number): BatterySeries[] { + return Array.from({ length: count }, (_, i) => { + const history = generateTimeline(now - 60 * HOUR, now, i); + const title = TITLES[i] ?? `Battery ${i + 1}`; + return { + id: title, + title, + capacity: CAPACITIES[i] ?? 8, + currentSoc: history.at(-1)?.soc ?? 50, + history, + forecast: withForecast ? generateTimeline(now, now + 36 * HOUR, i) : [], + }; + }); +} + +function scenario(count: number, withForecast: boolean): StoryFn { + return () => ({ + components: { BatteryHistoryCard }, + setup() { + return { batteries: buildBatteries(count, withForecast, NOW.getTime()), now: NOW }; + }, + template: `
+ +
`, + }); +} + +export const OneBattery = scenario(1, false); +export const OneBatteryForecast = scenario(1, true); + +export const TwoBatteries = scenario(2, false); +export const TwoBatteriesForecast = scenario(2, true); + +export const ThreeBatteries = scenario(3, false); +export const ThreeBatteriesForecast = scenario(3, true); + +export const FourBatteries = scenario(4, false); +export const FourBatteriesForecast = scenario(4, true); diff --git a/assets/js/components/Battery/BatteryHistoryCard.vue b/assets/js/components/Battery/BatteryHistoryCard.vue new file mode 100644 index 000000000..c420d6980 --- /dev/null +++ b/assets/js/components/Battery/BatteryHistoryCard.vue @@ -0,0 +1,165 @@ + + + diff --git a/assets/js/components/Battery/BatteryHistoryChart.vue b/assets/js/components/Battery/BatteryHistoryChart.vue new file mode 100644 index 000000000..deb6d5e93 --- /dev/null +++ b/assets/js/components/Battery/BatteryHistoryChart.vue @@ -0,0 +1,495 @@ + + + + + diff --git a/assets/js/components/Battery/BatteryStatusCard.stories.ts b/assets/js/components/Battery/BatteryStatusCard.stories.ts new file mode 100644 index 000000000..b1118e8a2 --- /dev/null +++ b/assets/js/components/Battery/BatteryStatusCard.stories.ts @@ -0,0 +1,88 @@ +import BatteryStatusCard from "./BatteryStatusCard.vue"; +import type { Meta, StoryFn } from "@storybook/vue3"; + +export default { + title: "Battery/BatteryStatusCard", + component: BatteryStatusCard, + parameters: { + layout: "centered", + }, + argTypes: { + title: { control: "text" }, + soc: { control: { type: "range", min: 0, max: 100 } }, + power: { control: "number", description: "W, + charging / - discharging" }, + capacity: { control: "number", description: "kWh, 0 = unspecified" }, + color: { control: "color" }, + }, +} as Meta; + +// narrow: how the card looks as a column in a multi-battery grid +const Narrow: StoryFn = (args) => ({ + components: { BatteryStatusCard }, + setup() { + return { args }; + }, + template: '
', +}); + +const base = { title: "Sungrow", capacity: 13.5, color: "#0BA631" }; + +export const Charging = Narrow.bind({}); +Charging.args = { ...base, soc: 76, power: 800 }; + +export const Discharging = Narrow.bind({}); +Discharging.args = { + ...base, + title: "Anker", + capacity: 7.5, + color: "#7FC41B", + soc: 40, + power: -1200, +}; + +export const Idle = Narrow.bind({}); +Idle.args = { ...base, title: "Battery", capacity: 13.4, soc: 98, power: 0 }; + +export const Empty = Narrow.bind({}); +Empty.args = { ...base, title: "Battery", capacity: 13.4, soc: 4, power: -600 }; + +export const NoCapacity = Narrow.bind({}); +NoCapacity.args = { ...base, title: "Battery", capacity: 0, soc: 55, power: 300 }; + +// optimizer states, stacked below the values (as on the combined card) +export const OptimizerNormal = Narrow.bind({}); +OptimizerNormal.args = { ...base, soc: 62, power: 900, suggestion: { action: "normal" } }; + +export const OptimizerHold = Narrow.bind({}); +OptimizerHold.args = { ...base, soc: 88, power: 0, suggestion: { action: "hold" } }; + +export const OptimizerCharge = Narrow.bind({}); +OptimizerCharge.args = { + ...base, + soc: 48, + power: 2500, + suggestion: { action: "charge" }, +}; + +export const OptimizerHoldCharge = Narrow.bind({}); +OptimizerHoldCharge.args = { ...base, soc: 70, power: 0, suggestion: { action: "holdcharge" } }; + +export const WithForecast = Narrow.bind({}); +WithForecast.args = { + ...base, + soc: 62, + power: 900, + forecast: { + highest: { soc: 100, time: "2026-07-01T16:30:00+02:00" }, + lowest: { soc: 12, time: "2026-07-02T06:00:00+02:00" }, + }, +}; + +export const SuggestionAndForecast = Narrow.bind({}); +SuggestionAndForecast.args = { + ...base, + soc: 48, + power: 2500, + suggestion: { action: "charge" }, + forecast: { highest: { soc: 100, time: "2026-07-01T18:00:00+02:00", limit: true } }, +}; diff --git a/assets/js/components/Battery/BatteryStatusCard.vue b/assets/js/components/Battery/BatteryStatusCard.vue new file mode 100644 index 000000000..2275172eb --- /dev/null +++ b/assets/js/components/Battery/BatteryStatusCard.vue @@ -0,0 +1,104 @@ + + + + + diff --git a/assets/js/components/Battery/BatteryStatusCards.stories.ts b/assets/js/components/Battery/BatteryStatusCards.stories.ts new file mode 100644 index 000000000..5deb8a4e3 --- /dev/null +++ b/assets/js/components/Battery/BatteryStatusCards.stories.ts @@ -0,0 +1,82 @@ +import BatteryStatusCards from "./BatteryStatusCards.vue"; +import type { Battery, BatteryMeter } from "@/types/evcc"; +import type { Meta, StoryFn } from "@storybook/vue3"; + +export default { + title: "Battery/BatteryStatusCards", + component: BatteryStatusCards, + parameters: { + layout: "fullscreen", + }, +} as Meta; + +const Template: StoryFn = (args) => ({ + components: { BatteryStatusCards }, + setup() { + return { args }; + }, + template: '
', +}); + +function device(overrides: Partial): BatteryMeter { + return { power: 0, soc: 50, controllable: false, capacity: 10, ...overrides }; +} + +function battery(devices: BatteryMeter[], forecast?: Battery["forecast"]): Battery { + return { + power: devices.reduce((s, d) => s + d.power, 0), + capacity: devices.reduce((s, d) => s + d.capacity, 0), + soc: 50, + devices, + forecast, + }; +} + +export const Single = Template.bind({}); +Single.args = { + battery: battery([device({ title: "Sungrow", soc: 76, power: 800, capacity: 13.5 })]), +}; + +export const Multiple = Template.bind({}); +Multiple.args = { + battery: { + ...battery([ + device({ title: "Sungrow", soc: 76, power: 800, capacity: 13.5, controllable: true }), + device({ title: "Anker", soc: 40, power: -1200, capacity: 7.5 }), + ]), + soc: 63, + }, +}; + +// single battery: optimizer gets its own card, showing suggestion + high + low +export const WithForecast = Template.bind({}); +WithForecast.args = { + battery: battery([device({ title: "Battery", soc: 62, power: 900, capacity: 10 })], { + highest: { soc: 100, time: "2026-07-01T16:30:00+02:00" }, + lowest: { soc: 12, time: "2026-07-02T06:00:00+02:00" }, + }), + suggestion: { action: "charge" }, +}; + +// forecast is a site-aggregate value, so it lands on the combined card +export const MultipleWithForecast = Template.bind({}); +MultipleWithForecast.args = { + battery: { + ...battery( + [ + device({ title: "Sungrow", soc: 76, power: 800, capacity: 13.5, controllable: true }), + device({ title: "Anker", soc: 40, power: -1200, capacity: 7.5 }), + ], + { + highest: { soc: 100, time: "2026-07-01T16:30:00+02:00" }, + lowest: { soc: 15, time: "2026-07-02T05:00:00+02:00" }, + } + ), + soc: 63, + }, +}; + +export const SocOnly = Template.bind({}); +SocOnly.args = { + battery: battery([device({ title: "Battery", soc: 55, power: 300, capacity: 0 })]), +}; diff --git a/assets/js/components/Battery/BatteryStatusCards.vue b/assets/js/components/Battery/BatteryStatusCards.vue new file mode 100644 index 000000000..20a427e4c --- /dev/null +++ b/assets/js/components/Battery/BatteryStatusCards.vue @@ -0,0 +1,86 @@ + + + + + diff --git a/assets/js/components/Battery/InlineSocSelect.vue b/assets/js/components/Battery/InlineSocSelect.vue new file mode 100644 index 000000000..96f86081a --- /dev/null +++ b/assets/js/components/Battery/InlineSocSelect.vue @@ -0,0 +1,34 @@ + + + diff --git a/assets/js/components/Battery/OptimizerInfo.vue b/assets/js/components/Battery/OptimizerInfo.vue new file mode 100644 index 000000000..552e581f2 --- /dev/null +++ b/assets/js/components/Battery/OptimizerInfo.vue @@ -0,0 +1,67 @@ + + + diff --git a/assets/js/components/Battery/SocGauge.vue b/assets/js/components/Battery/SocGauge.vue new file mode 100644 index 000000000..48a43006d --- /dev/null +++ b/assets/js/components/Battery/SocGauge.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/assets/js/components/Battery/WindowNav.vue b/assets/js/components/Battery/WindowNav.vue new file mode 100644 index 000000000..e25b23661 --- /dev/null +++ b/assets/js/components/Battery/WindowNav.vue @@ -0,0 +1,35 @@ + + + diff --git a/assets/js/components/Battery/history.test.ts b/assets/js/components/Battery/history.test.ts new file mode 100644 index 000000000..07ec65001 --- /dev/null +++ b/assets/js/components/Battery/history.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from "vitest"; +import { historyToSeries, forecastToSeries, buildChartBatteries } from "./history"; +import type { BatteryMeter, EvOpt } from "@/types/evcc"; +import type { BatteryHistorySeries } from "./types"; + +const t = (iso: string) => new Date(iso).getTime(); + +describe("historyToSeries", () => { + it("keys by title, drops slots without socTemp, maps start to epoch", () => { + const raw: BatteryHistorySeries[] = [ + { + title: "Anker", + group: "battery", + data: [ + { start: "2026-01-01T00:00:00Z", socTemp: 50 }, + { start: "2026-01-01T00:15:00Z", socTemp: null }, + { start: "2026-01-01T00:30:00Z" }, + { start: "2026-01-01T00:45:00Z", socTemp: 55 }, + ], + }, + ]; + const res = historyToSeries(raw); + expect(res).toHaveLength(1); + expect(res[0]!.key).toBe("Anker"); + expect(res[0]!.points).toEqual([ + { t: t("2026-01-01T00:00:00Z"), soc: 50 }, + { t: t("2026-01-01T00:45:00Z"), soc: 55 }, + ]); + }); + + it("falls back to group when title is missing", () => { + const res = historyToSeries([{ group: "battery", data: [] }]); + expect(res[0]!.key).toBe("battery"); + }); +}); + +describe("forecastToSeries", () => { + const evopt = { + res: { + batteries: [ + { state_of_charge: [4000, 5000, 6000] }, // Wh + { state_of_charge: [1000, 2000, 3000] }, + ], + }, + details: { + timestamp: ["2026-01-01T00:00:00Z", "2026-01-01T01:00:00Z", "2026-01-01T02:00:00Z"], + batteryDetails: [ + { type: "battery", name: "bat1", title: "Anker", capacity: 10 }, + { type: "vehicle", name: "car", title: "EV", capacity: 60 }, + ], + }, + } as unknown as EvOpt; + + it("keys by device name and converts Wh to percent of capacity", () => { + const res = forecastToSeries(evopt, t("2026-01-01T00:00:00Z")); + expect(res).toHaveLength(1); // vehicle skipped + expect(res[0]!.key).toBe("bat1"); + // 10 kWh -> 5000 Wh = 50 % + expect(res[0]!.points).toEqual([ + { t: t("2026-01-01T00:00:00Z"), soc: 40 }, + { t: t("2026-01-01T01:00:00Z"), soc: 50 }, + { t: t("2026-01-01T02:00:00Z"), soc: 60 }, + ]); + }); + + it("drops points before now", () => { + const res = forecastToSeries(evopt, t("2026-01-01T01:00:00Z")); + expect(res[0]!.points.map((p) => p.soc)).toEqual([50, 60]); + }); + + it("returns empty for missing optimizer data", () => { + expect(forecastToSeries(undefined, 0)).toEqual([]); + }); +}); + +describe("buildChartBatteries", () => { + const devices = [ + { name: "bat1", title: "Anker", soc: 60, capacity: 10, controllable: false }, + ] as BatteryMeter[]; + + it("matches history by title and forecast by device name", () => { + const history = [{ key: "Anker", points: [{ t: 1, soc: 42 }] }]; + const forecast = [{ key: "bat1", points: [{ t: 2, soc: 80 }] }]; + const res = buildChartBatteries(devices, history, forecast); + expect(res).toHaveLength(1); + expect(res[0]).toMatchObject({ + id: "Anker", + title: "Anker", + capacity: 10, + currentSoc: 60, + history: [{ t: 1, soc: 42 }], + forecast: [{ t: 2, soc: 80 }], + }); + }); + + it("yields empty timelines when nothing matches", () => { + const res = buildChartBatteries(devices, [], []); + expect(res[0]!.history).toEqual([]); + expect(res[0]!.forecast).toEqual([]); + }); +}); diff --git a/assets/js/components/Battery/history.ts b/assets/js/components/Battery/history.ts new file mode 100644 index 000000000..af55d3570 --- /dev/null +++ b/assets/js/components/Battery/history.ts @@ -0,0 +1,55 @@ +// Pure transforms that turn the raw /history/energy response and the optimizer result into +// the per-battery soc timelines the chart consumes. Kept framework-free for unit testing. + +import type { BatteryMeter, EvOpt } from "@/types/evcc"; +import type { SocPoint, BatterySeries, BatteryHistorySeries } from "./types"; + +// A soc timeline tagged with the key used to match it back to a battery device. +export interface KeyedSeries { + key: string; + points: SocPoint[]; +} + +// History is grouped per title (no device name), so key it by title, falling back to the group. +export function historyToSeries(raw: BatteryHistorySeries[]): KeyedSeries[] { + return raw.map((s) => ({ + key: s.title || s.group, + points: s.data + .filter((d) => d.socTemp != null) + .map((d) => ({ t: new Date(d.start).getTime(), soc: d.socTemp as number })), + })); +} + +// Optimizer soc per battery, keyed by the device name; only points at/after now, converted +// from Wh to percent using the battery capacity (kWh). +export function forecastToSeries(evopt: EvOpt | undefined, nowMs: number): KeyedSeries[] { + if (!evopt?.res?.batteries || !evopt.details?.timestamp) return []; + const ts = evopt.details.timestamp.map((t) => new Date(t).getTime()); + return evopt.res.batteries.flatMap((bat, i): KeyedSeries[] => { + const det = evopt.details.batteryDetails?.[i]; + if (!det || det.type !== "battery" || !det.capacity) return []; + const points: SocPoint[] = (bat.state_of_charge || []) + .map((wh, j) => ({ t: ts[j]!, soc: (wh / (det.capacity * 1000)) * 100 })) + .filter((p) => p.t >= nowMs && !Number.isNaN(p.soc)); + return [{ key: det.name, points }]; + }); +} + +// Merge per-device meta with its matched history (by title) and forecast (by device name). +export function buildChartBatteries( + devices: BatteryMeter[], + history: KeyedSeries[], + forecast: KeyedSeries[] +): BatterySeries[] { + return devices.map((d) => { + const title = d.title || d.name || ""; + return { + id: title, + title, + capacity: d.capacity || 0, + currentSoc: d.soc, + history: history.find((s) => s.key === title)?.points ?? [], + forecast: forecast.find((s) => s.key === d.name)?.points ?? [], + }; + }); +} diff --git a/assets/js/components/Battery/types.ts b/assets/js/components/Battery/types.ts new file mode 100644 index 000000000..f9d01afb5 --- /dev/null +++ b/assets/js/components/Battery/types.ts @@ -0,0 +1,42 @@ +// Shared view-model types for the experimental battery page. + +import type { BatteryForecast } from "@/types/evcc"; + +export interface SocPoint { + t: number; // epoch ms + soc: number; // 0..100 +} + +export interface BatterySeries { + id: string; + title: string; + capacity: number; // kWh + currentSoc: number; // 0..100 + history: SocPoint[]; // ascending t, <= now + forecast: SocPoint[]; // ascending t, >= now (may be empty) +} + +export interface BatterySuggestion { + action: "normal" | "hold" | "charge" | "holdcharge"; +} + +export interface BatteryStatusCardModel { + id: string; + title: string; + soc: number; + power: number; + capacity: number; + color: string; + suggestion: BatterySuggestion | null; + forecast: BatteryForecast | null; +} + +export interface BatteryHistorySlot { + start: string; + socTemp?: number | null; +} +export interface BatteryHistorySeries { + title?: string; + group: string; + data: BatteryHistorySlot[]; +} diff --git a/assets/js/components/Config/OcppModal.vue b/assets/js/components/Config/OcppModal.vue index 9ac301e1e..458763aa6 100644 --- a/assets/js/components/Config/OcppModal.vue +++ b/assets/js/components/Config/OcppModal.vue @@ -126,7 +126,7 @@ export default defineComponent({ entries(): StationEntry[] { const byId = new Map(); const published = new Set(); - for (const station of this.ocpp.status.stations) { + for (const station of this.ocpp.status?.stations ?? []) { byId.set(station.id, { id: station.id, status: station.status, diff --git a/assets/js/components/Energyflow/BatteryIcon.stories.ts b/assets/js/components/Energyflow/BatteryIcon.stories.ts index 4cb3aecd2..709cf8c59 100644 --- a/assets/js/components/Energyflow/BatteryIcon.stories.ts +++ b/assets/js/components/Energyflow/BatteryIcon.stories.ts @@ -9,17 +9,13 @@ export default { }, } as Meta; -const Template: StoryFn = (args) => { - const story = () => ({ - components: { BatteryIcon }, - setup() { - return { args }; - }, - template: '', - }); - story.args = args; - return story; -}; +const Template: StoryFn = (args) => ({ + components: { BatteryIcon }, + setup() { + return { args }; + }, + template: '', +}); export const Empty = Template.bind({}); Empty.args = { soc: 0 }; diff --git a/assets/js/components/Forecast/echarts.ts b/assets/js/components/Forecast/echarts.ts index 5095a9dc9..902453f46 100644 --- a/assets/js/components/Forecast/echarts.ts +++ b/assets/js/components/Forecast/echarts.ts @@ -7,6 +7,9 @@ import { GridComponent, TooltipComponent, MarkPointComponent, + MarkLineComponent, + MarkAreaComponent, + GraphicComponent, AxisPointerComponent, } from "echarts/components"; import { SVGRenderer } from "echarts/renderers"; @@ -18,6 +21,9 @@ echarts.use([ TooltipComponent, AxisPointerComponent, MarkPointComponent, + MarkLineComponent, + MarkAreaComponent, + GraphicComponent, SVGRenderer, ]); diff --git a/assets/js/components/Helper/Card.vue b/assets/js/components/Helper/Card.vue new file mode 100644 index 000000000..1a3f28db6 --- /dev/null +++ b/assets/js/components/Helper/Card.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/assets/js/components/Helper/CustomSelect.vue b/assets/js/components/Helper/CustomSelect.vue index 3758ff750..32e7b2b58 100644 --- a/assets/js/components/Helper/CustomSelect.vue +++ b/assets/js/components/Helper/CustomSelect.vue @@ -1,5 +1,10 @@ diff --git a/assets/js/mixins/formatter.ts b/assets/js/mixins/formatter.ts index 82c1577c8..ad1fece43 100644 --- a/assets/js/mixins/formatter.ts +++ b/assets/js/mixins/formatter.ts @@ -241,6 +241,19 @@ export default defineComponent({ return `${weekday} ${hour}`.trim(); }, + // relative day plus time, e.g. "heute 16:30", "morgen 5:00", "Freitag 12:15" + fmtDayTime(date: Date) { + const time = this.fmtHourMinute(date); + const startOfDay = (d: Date) => new Date(d).setHours(0, 0, 0, 0); + const days = Math.round((startOfDay(date) - startOfDay(new Date())) / 86400000); + if (days === 0 || days === 1) { + const day = new Intl.RelativeTimeFormat(this.$i18n?.locale, { + numeric: "auto", + }).format(days, "day"); + return `${day} ${time}`; + } + return `${this.weekdayLong(date)} ${time}`; + }, fmtHourMinute(date: Date) { return new Intl.DateTimeFormat(this.$i18n?.locale, { hour: "numeric", diff --git a/assets/js/settings.ts b/assets/js/settings.ts index 2b96b3574..ab4df7177 100644 --- a/assets/js/settings.ts +++ b/assets/js/settings.ts @@ -19,6 +19,7 @@ const SAVINGS_REGION = "savings_region"; const SAVINGS_INDICATOR = "savings_indicator"; const SESSIONS_GROUP = "sessions_group"; const SESSIONS_TYPE = "sessions_type"; +const BATTERY_UNIT = "battery_unit"; const SETTINGS_SOLAR_ADJUSTED = "settings_solar_adjusted"; const SETTINGS_PRICE_ZOOM = "settings_price_zoom"; const SETTINGS_HIDE_FEEDIN = "settings_hide_feedin"; @@ -124,6 +125,7 @@ export interface Settings { savingsIndicator: string; sessionsGroup: string; sessionsType: string; + batteryUnit: string; solarAdjusted: boolean; priceZoom: boolean; hideFeedin: boolean; @@ -153,6 +155,7 @@ const settings: Settings = reactive({ savingsIndicator: read(SAVINGS_INDICATOR), sessionsGroup: read(SESSIONS_GROUP), sessionsType: read(SESSIONS_TYPE), + batteryUnit: read(BATTERY_UNIT), solarAdjusted: readBool(SETTINGS_SOLAR_ADJUSTED), priceZoom: readBool(SETTINGS_PRICE_ZOOM), hideFeedin: readBool(SETTINGS_HIDE_FEEDIN), @@ -181,6 +184,7 @@ watch(() => settings.savingsRegion, save(SAVINGS_REGION)); watch(() => settings.savingsIndicator, save(SAVINGS_INDICATOR)); watch(() => settings.sessionsGroup, save(SESSIONS_GROUP)); watch(() => settings.sessionsType, save(SESSIONS_TYPE)); +watch(() => settings.batteryUnit, save(BATTERY_UNIT)); watch(() => settings.solarAdjusted, saveBool(SETTINGS_SOLAR_ADJUSTED)); watch(() => settings.priceZoom, saveBool(SETTINGS_PRICE_ZOOM)); watch(() => settings.hideFeedin, saveBool(SETTINGS_HIDE_FEEDIN)); diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index b79235706..df72ba367 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -118,6 +118,7 @@ export interface State { batteryGridChargeLimit?: number | null; smartCostAvailable?: boolean; smartCostType?: SMART_COST_TYPE; + historyUpdated?: string; // ISO timestamp, bumped each 15min metrics persist siteTitle?: string; deviceColors?: DeviceColorEntry[]; vehicles: Record; diff --git a/assets/js/utils/ocpp.ts b/assets/js/utils/ocpp.ts index 79915c015..31801b4a5 100644 --- a/assets/js/utils/ocpp.ts +++ b/assets/js/utils/ocpp.ts @@ -2,7 +2,7 @@ import type { Ocpp } from "@/types/evcc"; export function getOcppUrl(ocpp: Ocpp): string { // User specified url, e.g., for reverse proxy setups - if (ocpp.status.externalUrl) { + if (ocpp.status?.externalUrl) { return ocpp.status.externalUrl; } return `ws://${window.location.hostname}:${ocpp.config.port}/`; diff --git a/assets/js/views/Battery.vue b/assets/js/views/Battery.vue index 074898f4f..ac289a532 100644 --- a/assets/js/views/Battery.vue +++ b/assets/js/views/Battery.vue @@ -3,11 +3,18 @@
-