From 7b5103df44deda0e215c9805d7d706aa293a05d8 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Wed, 25 Mar 2026 20:22:31 +0100 Subject: [PATCH] UI: New Navigation, Battery & Forecast Page (#27753) --- assets/css/app.css | 13 +- assets/css/breakpoints.css | 7 + assets/js/components/AboutModal.stories.ts | 106 ++++ assets/js/components/AboutModal.vue | 249 +++++++++ .../Battery/BatterySettingsModal.vue | 500 +---------------- .../Battery/BatteryUsageSettings.vue | 511 ++++++++++++++++++ assets/js/components/BottomTabs/Bar.vue | 116 ++++ assets/js/components/BottomTabs/Item.vue | 130 +++++ assets/js/components/BottomTabs/MoreItem.vue | 86 +++ assets/js/components/BottomTabs/MoreMenu.vue | 226 ++++++++ assets/js/components/Footer/Footer.vue | 2 +- .../js/components/Footer/OfflineIndicator.vue | 35 +- .../js/components/Footer/Version.stories.ts | 30 +- assets/js/components/Footer/Version.vue | 115 +--- assets/js/components/Forecast/Co2Chart.vue | 128 +++++ assets/js/components/Forecast/PriceChart.vue | 174 ++++++ assets/js/components/Forecast/SolarChart.vue | 139 +++++ assets/js/components/Forecast/chartMixin.ts | 67 +++ assets/js/components/Forecast/echarts.ts | 202 +++++++ assets/js/components/Helper/GenericModal.vue | 4 +- .../js/components/Loadpoints/Loadpoints.vue | 6 +- .../components/MaterialIcon/ForecastGraph.vue | 18 + assets/js/components/MaterialIcon/More.vue | 18 + .../js/components/MaterialIcon/Sessions.vue | 18 + assets/js/components/Savings/Savings.vue | 309 +++++++---- assets/js/components/Savings/types.d.ts | 1 - assets/js/components/Site/Site.vue | 24 +- .../js/components/Tariff/SmartTariffBase.vue | 2 +- assets/js/components/Tariff/TariffChart.vue | 6 +- assets/js/components/Top/Header.vue | 34 +- .../js/components/Top/TopNavigationArea.vue | 27 +- assets/js/router.ts | 29 +- assets/js/settings.ts | 8 + assets/js/types/evcc.ts | 20 + assets/js/utils/haptic.ts | 3 + assets/js/utils/version.ts | 23 + assets/js/views/App.vue | 26 +- assets/js/views/Battery.vue | 80 +++ assets/js/views/Config.vue | 2 +- assets/js/views/Energy.vue | 106 ---- assets/js/views/Forecast.vue | 323 +++++++++++ i18n/de.json | 42 +- i18n/en.json | 51 +- package-lock.json | 32 ++ package.json | 3 +- server/updater/github.go | 2 +- tests/bottom-nav.spec.ts | 104 ++++ tests/statistics.spec.ts | 52 +- tests/utils.ts | 7 +- 49 files changed, 3327 insertions(+), 889 deletions(-) create mode 100644 assets/js/components/AboutModal.stories.ts create mode 100644 assets/js/components/AboutModal.vue create mode 100644 assets/js/components/Battery/BatteryUsageSettings.vue create mode 100644 assets/js/components/BottomTabs/Bar.vue create mode 100644 assets/js/components/BottomTabs/Item.vue create mode 100644 assets/js/components/BottomTabs/MoreItem.vue create mode 100644 assets/js/components/BottomTabs/MoreMenu.vue create mode 100644 assets/js/components/Forecast/Co2Chart.vue create mode 100644 assets/js/components/Forecast/PriceChart.vue create mode 100644 assets/js/components/Forecast/SolarChart.vue create mode 100644 assets/js/components/Forecast/chartMixin.ts create mode 100644 assets/js/components/Forecast/echarts.ts create mode 100644 assets/js/components/MaterialIcon/ForecastGraph.vue create mode 100644 assets/js/components/MaterialIcon/More.vue create mode 100644 assets/js/components/MaterialIcon/Sessions.vue create mode 100644 assets/js/utils/haptic.ts create mode 100644 assets/js/utils/version.ts create mode 100644 assets/js/views/Battery.vue delete mode 100644 assets/js/views/Energy.vue create mode 100644 assets/js/views/Forecast.vue create mode 100644 tests/bottom-nav.spec.ts diff --git a/assets/css/app.css b/assets/css/app.css index 1507ccb13..142a5ab4a 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -91,6 +91,9 @@ --safe-area-inset-bottom: env(safe-area-inset-bottom, 0px); --safe-area-inset-left: env(safe-area-inset-left, 0px); --safe-area-inset-right: env(safe-area-inset-right, 0px); + + --tab-bar-height: 3rem; + --tab-bar-background: var(--evcc-background); } :root.dark { @@ -108,6 +111,7 @@ --bs-border-color-translucent: rgba(255, 255, 255, 0.175); --bs-code-color: var(--evcc-dark-green); --evcc-backdrop: #000000aa; /* black + transparent */ + --tab-bar-background: var(--evcc-box); color-scheme: dark; } @@ -362,7 +366,7 @@ a:hover { background-color: var(--bs-gray-dark) !important; } -@media screen and (min-width: 400px) and (max-width: 574px) { +@media screen and (--xs-to-sm) { .d-xs-none { display: none !important; } @@ -478,6 +482,13 @@ small { filter: invert(1); } +.circle-badge { + display: inline-block; + width: 12px; + height: 12px; + border-radius: 50%; +} + .dropdown-menu { border: none; border-radius: 10px; diff --git a/assets/css/breakpoints.css b/assets/css/breakpoints.css index 5081a9385..80ba53869 100644 --- a/assets/css/breakpoints.css +++ b/assets/css/breakpoints.css @@ -1,9 +1,16 @@ /* Bootstrap Breakpoints as Custom Media Queries */ +@custom-media --xs-and-up (min-width: 400px); @custom-media --sm-and-up (min-width: 576px); @custom-media --md-and-up (min-width: 768px); @custom-media --lg-and-up (min-width: 992px); @custom-media --xl-and-up (min-width: 1200px); @custom-media --xxl-and-up (min-width: 1400px); +/* Range Breakpoints */ +@custom-media --xs-to-sm (min-width: 400px) and (max-width: 575.98px); +@custom-media --sm-to-md (min-width: 576px) and (max-width: 767.98px); +@custom-media --sm-to-lg (min-width: 576px) and (max-width: 991.98px); +@custom-media --md-to-lg (min-width: 768px) and (max-width: 991.98px); + @custom-media --light-mode (prefers-color-scheme: light); @custom-media --dark-mode (prefers-color-scheme: dark); diff --git a/assets/js/components/AboutModal.stories.ts b/assets/js/components/AboutModal.stories.ts new file mode 100644 index 000000000..3a9eac6ee --- /dev/null +++ b/assets/js/components/AboutModal.stories.ts @@ -0,0 +1,106 @@ +import AboutModal from "./AboutModal.vue"; +import type { Meta, StoryFn } from "@storybook/vue3"; + +const releaseNotes = ` +

v0.303.1

+

Changelog

+

Other Changes ☀️

+ +

Bug Fixes 🐞

+ +

v0.303.0

+

Changelog

+

Breaking Changes 🚨

+ +

New Features 💫

+ +

Bug Fixes 🐞

+ +`; + +export default { + title: "AboutModal", + component: AboutModal, + argTypes: { + installed: { control: "text" }, + commit: { control: "text" }, + availableVersion: { control: "text" }, + releaseNotes: { control: "text" }, + hasUpdater: { control: "boolean" }, + uploadMessage: { control: "text" }, + uploadProgress: { control: "number" }, + }, +} as Meta; + +const Template: StoryFn = (args) => ({ + components: { AboutModal }, + setup() { + return { args }; + }, + template: '', + mounted() { + const el = document.getElementById("aboutModal"); + if (el) { + import("bootstrap/js/dist/modal").then(({ default: Modal }) => { + Modal.getOrCreateInstance(el).show(); + }); + } + }, +}); + +export const Stable = Template.bind({}); +Stable.args = { + installed: "0.303.1", + availableVersion: "0.303.1", +}; + +export const StableUpdateAvailable = Template.bind({}); +StableUpdateAvailable.args = { + installed: "0.303.0", + availableVersion: "0.303.1", + releaseNotes, +}; + +export const StableUpdateWithUpdater = Template.bind({}); +StableUpdateWithUpdater.args = { + installed: "0.303.0", + availableVersion: "0.303.1", + releaseNotes, + hasUpdater: true, +}; + +export const Nightly = Template.bind({}); +Nightly.args = { + installed: "0.303.1", + commit: "5ce7be4a9f3b2c1d", + availableVersion: "0.303.1", +}; + +export const DevBuild = Template.bind({}); +DevBuild.args = { + installed: "0.0.0", +}; diff --git a/assets/js/components/AboutModal.vue b/assets/js/components/AboutModal.vue new file mode 100644 index 000000000..f981aab49 --- /dev/null +++ b/assets/js/components/AboutModal.vue @@ -0,0 +1,249 @@ + + + + + diff --git a/assets/js/components/Battery/BatterySettingsModal.vue b/assets/js/components/Battery/BatterySettingsModal.vue index 181110c52..e62206fa5 100644 --- a/assets/js/components/Battery/BatterySettingsModal.vue +++ b/assets/js/components/Battery/BatterySettingsModal.vue @@ -30,217 +30,14 @@ -
-

- {{ $t("batterySettings.batteryLevel") }}: - {{ fmtSoc(batterySoc) }} - - {{ line }} - -

-
-
- - - {{ fmtSoc(selectedBufferSoc) }} - - - - - - {{ fmtSoc(selectedPrioritySoc) }} - - -
-
-
- -
-
- -
-
- -
-
-
-
-
-
-
-
-
-

- - - {{ $t("batterySettings.legendTopName") }} - - - - - - {{ $t("batterySettings.legendTopAutostart") }} - - - {{ selectedBufferStartName }} - - - - -

-

- - - {{ $t("batterySettings.legendMiddleName") }} - - - - -

-

- - - {{ $t("batterySettings.legendBottomName") }} - - - - -

-
- -
- -
-
-
-
+ - - diff --git a/assets/js/components/Battery/BatteryUsageSettings.vue b/assets/js/components/Battery/BatteryUsageSettings.vue new file mode 100644 index 000000000..234aed40e --- /dev/null +++ b/assets/js/components/Battery/BatteryUsageSettings.vue @@ -0,0 +1,511 @@ + + + + + diff --git a/assets/js/components/BottomTabs/Bar.vue b/assets/js/components/BottomTabs/Bar.vue new file mode 100644 index 000000000..b7aba7cb8 --- /dev/null +++ b/assets/js/components/BottomTabs/Bar.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/assets/js/components/BottomTabs/Item.vue b/assets/js/components/BottomTabs/Item.vue new file mode 100644 index 000000000..71053ab1c --- /dev/null +++ b/assets/js/components/BottomTabs/Item.vue @@ -0,0 +1,130 @@ + + + + + diff --git a/assets/js/components/BottomTabs/MoreItem.vue b/assets/js/components/BottomTabs/MoreItem.vue new file mode 100644 index 000000000..2f93dac30 --- /dev/null +++ b/assets/js/components/BottomTabs/MoreItem.vue @@ -0,0 +1,86 @@ + + + diff --git a/assets/js/components/BottomTabs/MoreMenu.vue b/assets/js/components/BottomTabs/MoreMenu.vue new file mode 100644 index 000000000..ecb26c80d --- /dev/null +++ b/assets/js/components/BottomTabs/MoreMenu.vue @@ -0,0 +1,226 @@ + + + + + diff --git a/assets/js/components/Footer/Footer.vue b/assets/js/components/Footer/Footer.vue index 4011e49c5..aecfd2f56 100644 --- a/assets/js/components/Footer/Footer.vue +++ b/assets/js/components/Footer/Footer.vue @@ -1,7 +1,7 @@ + + diff --git a/assets/js/components/Forecast/PriceChart.vue b/assets/js/components/Forecast/PriceChart.vue new file mode 100644 index 000000000..71e54fb0d --- /dev/null +++ b/assets/js/components/Forecast/PriceChart.vue @@ -0,0 +1,174 @@ + + + + + diff --git a/assets/js/components/Forecast/SolarChart.vue b/assets/js/components/Forecast/SolarChart.vue new file mode 100644 index 000000000..0248fe551 --- /dev/null +++ b/assets/js/components/Forecast/SolarChart.vue @@ -0,0 +1,139 @@ + + + + + diff --git a/assets/js/components/Forecast/chartMixin.ts b/assets/js/components/Forecast/chartMixin.ts new file mode 100644 index 000000000..4df452935 --- /dev/null +++ b/assets/js/components/Forecast/chartMixin.ts @@ -0,0 +1,67 @@ +import { defineComponent, markRaw } from "vue"; +import { echarts } from "./echarts"; + +// chartOption is provided by each consuming component's computed +type WithChartOption = { chartOption: Record }; + +export default defineComponent({ + props: { + chartWidth: { type: Number, required: true }, + endDate: { type: Date, required: true }, + scrollLeft: { type: Number, default: 0 }, + }, + emits: ["scroll"], + data(): { + chart: echarts.ECharts | null; + startDate: Date; + tooltipVisible: boolean; + } { + return { + chart: null, + tooltipVisible: false, + startDate: new Date(), + }; + }, + watch: { + chartOption: { + handler() { + this.chart?.setOption((this as unknown as WithChartOption).chartOption); + }, + deep: true, + }, + scrollLeft(val: number) { + const el = this.$refs["scrollEl"] as HTMLElement; + if (el && Math.abs(el.scrollLeft - val) > 1) { + el.scrollLeft = val; + } + }, + }, + mounted() { + this.updateStartDate(); + this.initChart(); + }, + beforeUnmount() { + this.chart?.dispose(); + }, + methods: { + updateStartDate() { + const now = new Date(); + now.setMinutes(0, 0, 0); + this.startDate = now; + }, + onScroll(e: Event) { + this.$emit("scroll", (e.target as HTMLElement).scrollLeft); + }, + initChart() { + const el = this.$refs["chartEl"] as HTMLElement; + this.chart = markRaw(echarts.init(el)); + this.chart.setOption((this as unknown as WithChartOption).chartOption); + this.chart.on("showTip", () => { + this.tooltipVisible = true; + }); + this.chart.on("hideTip", () => { + this.tooltipVisible = false; + }); + }, + }, +}); diff --git a/assets/js/components/Forecast/echarts.ts b/assets/js/components/Forecast/echarts.ts new file mode 100644 index 000000000..d8f61a122 --- /dev/null +++ b/assets/js/components/Forecast/echarts.ts @@ -0,0 +1,202 @@ +import * as echarts from "echarts/core"; +import colors from "@/colors"; +import type { ForecastSlot } from "./types"; +import { BarChart, LineChart } from "echarts/charts"; +import { + GridComponent, + TooltipComponent, + MarkPointComponent, + AxisPointerComponent, +} from "echarts/components"; +import { SVGRenderer } from "echarts/renderers"; + +echarts.use([ + BarChart, + LineChart, + GridComponent, + TooltipComponent, + AxisPointerComponent, + MarkPointComponent, + SVGRenderer, +]); + +export const FONT_FAMILY = "Montserrat, sans-serif"; + +export function markPointLabel( + color: string, + data: { coord: [string, number]; value: string; label?: { offset?: [number, number] } }[], + startDate?: Date, + endDate?: Date +) { + // threshold: points within the first 5% of the time range get shifted right + const threshold = + startDate && endDate + ? startDate.getTime() + (endDate.getTime() - startDate.getTime()) * 0.05 + : 0; + + const adjustedData = data.map((d) => { + const ts = new Date(d.coord[0]).getTime(); + if (threshold && ts < threshold) { + const baseOffset = d.label?.offset ?? [0, -2]; + return { + ...d, + label: { + ...d.label, + offset: [baseOffset[0] + 30, baseOffset[1]] as [number, number], + }, + }; + } + return d; + }); + + return { + animation: true, + clip: false, + symbol: "rect", + symbolSize: 1, + label: { + show: true, + position: "top", + fontFamily: FONT_FAMILY, + fontSize: 14, + fontWeight: "bold" as const, + color: colors.background, + backgroundColor: color, + borderRadius: 4, + padding: [5, 10], + offset: [0, -2], + formatter: (p: { data: { value: string } }) => p.data.value, + }, + data: adjustedData, + }; +} + +export function tooltipStyle( + color: string, + getChart?: () => { convertToPixel: echarts.ECharts["convertToPixel"] } | null +) { + return { + backgroundColor: color, + borderColor: color, + borderWidth: 0, + padding: [5, 10], + extraCssText: "box-shadow: none; border-radius: 4px; text-align: center;", + position( + point: [number, number], + params: { value: [string, number] }[] | { value: [string, number] }, + el: HTMLElement + ): [number, number] { + const w = el?.offsetWidth || 0; + const h = el?.offsetHeight || 0; + const margin = 8; + const arr = Array.isArray(params) ? params : [params]; + const p = arr[0]; + const chart = getChart?.(); + if (chart && p?.value) { + const pixelY = chart.convertToPixel({ seriesIndex: 0 }, p.value)?.[1]; + if (pixelY != null) { + return [point[0] - w / 2, pixelY - h - margin]; + } + } + return [point[0] - w / 2, point[1] - h - margin]; + }, + textStyle: { + fontFamily: FONT_FAMILY, + fontSize: 14, + fontWeight: "bold" as const, + color: colors.background, + }, + }; +} + +export function forecastGrid() { + return { top: 36, right: 16, bottom: 4, left: 48, borderWidth: 0 }; +} + +export function forecastXAxes(startDate: Date, endDate: Date, weekdayShort: (d: Date) => string) { + return [ + { + type: "time", + min: startDate, + max: endDate, + minInterval: 3600 * 1000, + maxInterval: 3600 * 1000, + axisLabel: { + color: colors.muted, + fontSize: 14, + lineHeight: Math.round(14 * 1.1), + margin: 4, + formatter: (value: number) => { + const date = new Date(value); + const h = date.getHours(); + if (h % 2 !== 0) return ""; + if (h === 0) return `${h}\n${weekdayShort(date)}`; + return `${h}`; + }, + }, + splitLine: { show: false }, + axisLine: { show: false }, + axisTick: { show: false }, + }, + { + type: "time", + position: "bottom", + min: startDate, + max: endDate, + minInterval: 24 * 3600 * 1000, + maxInterval: 24 * 3600 * 1000, + axisLabel: { show: false }, + axisLine: { show: false }, + axisTick: { show: false }, + splitLine: { + show: true, + showMinLine: false, + showMaxLine: false, + lineStyle: { color: colors.border || "", type: "dashed" }, + }, + }, + ]; +} + +export function forecastYAxis(overrides: Record = {}) { + const { axisLabel, ...rest } = overrides; + return { + type: "value", + min: 0, + axisLine: { show: false }, + axisTick: { show: false }, + splitLine: { + showMinLine: false, + showMaxLine: false, + lineStyle: { color: colors.border || "" }, + }, + axisLabel: { + fontSize: 14, + ...(axisLabel as Record), + }, + ...rest, + }; +} + +export function clampStart(ts: string, startDate: Date): string { + return new Date(ts) < startDate ? startDate.toISOString() : ts; +} + +export function filterForecastSlots( + slots: ForecastSlot[], + startDate: Date, + endDate: Date +): ForecastSlot[] { + if (!Array.isArray(slots)) return []; + return slots.filter((s) => new Date(s.end) >= startDate && new Date(s.start) <= endDate); +} + +export function minSlotIndex(slots: ForecastSlot[]): number { + return slots.reduce((min, s, i) => (s.value < (slots[min]?.value || Infinity) ? i : min), 0); +} + +export function maxSlotIndex(slots: ForecastSlot[]): number { + return slots.reduce((max, s, i) => (s.value > (slots[max]?.value || 0) ? i : max), 0); +} + +export { echarts }; diff --git a/assets/js/components/Helper/GenericModal.vue b/assets/js/components/Helper/GenericModal.vue index 2f55ecb32..381fc5319 100644 --- a/assets/js/components/Helper/GenericModal.vue +++ b/assets/js/components/Helper/GenericModal.vue @@ -13,9 +13,9 @@ >