Repository: evcc-io/evcc
Pull request #18709 (2025-02-09 23:41:16)

Forecast UI: first step
follow-up to https://github.com/evcc-io/evcc/pull/18269 and https://github.com/evcc-io/evcc/pull/18692

- 🌞 show todays remaining pv production in energy flow details
- 📅 todays and tomorrows forecast in tooltip
- 🔮 new forecast modal (solar, grid, co2)
- 🍔 added "forecast" menu item

TODOs

- [x] handle optionality (missing solar, grid, co2)
- [x] handle missing values (https://github.com/evcc-io/evcc/pull/18802)
- [x] improve forecast icon
- [x] code cleanup, fix time-dependent computed props
- [x] mobile testing

<img width="926" alt="energyflow" src="https://github.com/user-attachments/assets/2c6f7fdf-748b-42bc-b0b1-da6045668127" />


solar forecast

<img width="911" alt="en solar" src="https://github.com/user-attachments/assets/1b0cce7e-6ce5-4b31-a8db-3b29ae0d9687" />


grid price

<img width="899" alt="en price" src="https://github.com/user-attachments/assets/e17ad941-2842-4dc6-b0f1-2cf53b6cb395" />


grid co2

<img width="913" alt="en co2" src="https://github.com/user-attachments/assets/e69574f7-d9f7-4755-8e1b-5f808e79a96c" />


new menu item

<img width="334" alt="menu" src="https://github.com/user-attachments/assets/d0c8ad39-f36f-4478-a7ba-a6afb4f904e1" />


de, mobile, solar

<img width="406" alt="de solar" src="https://github.com/user-attachments/assets/1e668174-b5fc-4037-87f2-3f67ab8291f5" />

de, mobile, price
<img width="403" alt="de price" src="https://github.com/user-attachments/assets/dcc2be14-6658-45bd-8835-9f69fbdb8fcb" />


de, mobile, price, hover

<img width="402" alt="de price hover" src="https://github.com/user-attachments/assets/508e222a-0d3a-42bf-8f83-a7d8cceaae1d" />


de, mobile, co2

<img width="403" alt="de co2" src="https://github.com/user-attachments/assets/6de5b1a0-7181-4815-8fe6-0d44d0b6967a" />


VolkerK62: schön wäre (in einem weiteren Schritt) so eine Forecast-Kurve analog zu Preis/CO2 im Loadpoint
naltatis: @VolkerK62 Work in Progress. Das kommt alles 😁
StefanSchoof: > show todays remaining pv

Pure from the UI I find it hard to see, that the value is only the remaining. 

Maybe in the Tooltip, something like

```
forecast
10 kWh today (8,8 kWh remaining)
5,6 kWh tomorrow
```
naltatis: 👷 sneak peak - still work in progress

<img width="983" alt="Bildschirmfoto 2025-02-11 um 23 55 56" src="https://github.com/user-attachments/assets/79ffeb8a-4899-411a-90a2-02dcea4089c7" />

premultiply: Sehr, sehr schick 🫠

Ich frage mich nur gerade ob es nicht "korrekter" Wäre das Label der Energiemenge so breit zu ziehen wie dieser Zeitraum ist.
So stellt es eher vermeintlich einen Spitzenwert dar.
andig: Das grün im Chart ist wirklich schick... müsste in unserer Logik für PV aber eher gelb sein?
andig: Unter dem Chart müssten es kWh sein:
<img width="815" alt="Screenshot 2025-02-13 at 09 07 37" src="https://github.com/user-attachments/assets/06c1bce0-11b3-4ee0-9932-35bb54a3dd6a" />

andig: Forecast.Solar erzeugt keine Zwischenwerte nach Sonnenuntergang. Können wir fehlende Slots als 0 visualisieren statt null?
<img width="817" alt="Screenshot 2025-02-13 at 09 10 16" src="https://github.com/user-attachments/assets/9092e43a-d846-4ea9-bcaf-3d5cc5f40374" />

naltatis: > Forecast.Solar erzeugt keine Zwischenwerte nach Sonnenuntergang. Können wir fehlende Slots als 0 visualisieren statt null?

Ja sollten wir. Hatte bislang nur mit Solcast Daten getestet. Danke für den Hinweis.
naltatis: @andig Einheiten sind gefixt
@premultiply die Idee die Tagessummen (Energy) von den Momentwerten abzuheben find ich gut. Ich hab das jetzt mal mit etwas breitem Label (siehe unten) umgesetzt. Dann wird optisch auch gleich klar, dass das was anderes ist.

Offene Frage: Ich hadere noch mit dem "remaining solar energy" Wert in der Main UI der ja auch ein Einsprung ist. Welcher Wert ist hier der sinnvollste? "Restproduktion des Tages" oder "Erwartete Produktion der nächsten 24h"? Letzterer ist einfacher zu kommunizieren und auch universeller. Inhaltliche Argumente finde ich für beide, würde aber nur eine anzeigen wollen 😁

**new icon an tooltip**
<img width="360" alt="Bildschirmfoto 2025-02-13 um 09 31 02" src="https://github.com/user-attachments/assets/410cef98-c823-4f99-bf33-ed39075e88d8" />

**daily aggregate (energy): wider label**
<img width="948" alt="day energy" src="https://github.com/user-attachments/assets/2d778bb6-cae9-440a-b3ab-53ec14fc6aa3" />

**slot information (power) on hover**
<img width="974" alt="slot" src="https://github.com/user-attachments/assets/7ce74ab0-96a0-409a-80ec-d40fdb5f82ec" />

premultiply: Nice! 😎

Ich würde oben spontan die "Geschätzte Restproduktion des Tages" erwarten.
Der gemeine Mensch denkt wohl eher in Tagen definiert durch Sonne an/aus ("nur noch einmal schlafen") als in Stunden. Zumindest mir geht das so. Neuer Tag, neues Glück 😀
q2doc: @naltatis, sieht toll aus!!!

Frage, Anregung - in Solectrus (nutze ich zur Visualisierung) kann man auch mehr als einen Forecast abfragen. Meine Solaranlage hat neben der primären Süd auch eine Ost/West-Ausrichtung. Wäre eine analoge Integration auch in EVCC möglich?
HolgerMiara: Moin @andig, Deine Visualisierungen sind super-nice, I LIKE!! :revolving_hearts: :+1: 

Nur als Inspiration hier ein [Link ](https://github.com/user-attachments/assets/a3372350-05da-4c5e-b88e-530f45726019) zur Socast Integration von Home Assistant, in der die Vorhersage, die 10% Fraktile und die aktuelle Produktion angezeigt wird. 
Je nach Geschmack vielleicht etwas überladen für einige, aber ich finde es bei Wettervorhersagen immer schön, die wenn auch die Vorhersageungenauigkeit visualisiert wird. 

![image](https://github.com/user-attachments/assets/274c642e-c6c4-4bd7-ade7-b113cb36d0db)


naltatis: @HolgerMiara Danke für die Anregung. Die Unschärfe mit zu visualisieren (sofern wir die bekommen) finde ich in der Tat eine sehr gute Idee. Kombination mit der aktuellen Produktion wird kommen. Heute erfassen wir ja noch keine historischen Produktions/Verbrauchstaten abseits der Ladevorgänge.
Lako-Unbekannt: @HolgerMiara kann es sein, dass du den falschen Link gepostet hast?
HolgerMiara: > @HolgerMiara kann es sein, dass du den falschen Link gepostet hast?

:see_no_evil: Vielen Dank!! Peinlich... Jetzt korrigiert. 

Patch:
diff --git a/assets/css/app.css b/assets/css/app.css
index 3d1f6e98..a139c00c 100644
--- a/assets/css/app.css
+++ b/assets/css/app.css
@@ -47,6 +47,8 @@
 	--evcc-pv: var(--evcc-dark-green);
 	--evcc-battery: var(--evcc-darker-green);
 	--evcc-export: var(--evcc-yellow);
+	--evcc-price: #ff912fff;
+	--evcc-co2: #00916eff;
 	--evcc-background: var(--bs-gray-bright);
 	--evcc-box: var(--bs-white);
 	--evcc-box-border: var(--bs-gray-brighter);
@@ -165,6 +167,14 @@ h5 {
 	color: var(--bs-primary) !important;
 }
 
+.text-price {
+	color: var(--evcc-price) !important;
+}
+
+.text-co2 {
+	color: var(--evcc-co2) !important;
+}
+
 a {
 	color: var(--bs-primary);
 }
diff --git a/assets/js/colors.js b/assets/js/colors.js
index 2e536a66..5bc721b5 100644
--- a/assets/js/colors.js
+++ b/assets/js/colors.js
@@ -15,9 +15,10 @@ const colors = reactive({
   grid: null,
   co2PerKWh: null,
   pricePerKWh: null,
-  price: "#FF912FFF",
-  co2: "#00916EFF",
+  price: null,
+  co2: null,
   background: null,
+  light: null,
   selfPalette: ["#0FDE41FF", "#FFBD2FFF", "#FD6158FF", "#03C1EFFF", "#0F662DFF", "#FF922EFF"],
   palette: [
     "#03C1EFFF",
@@ -35,6 +36,18 @@ const colors = reactive({
   ],
 });
 
+export const dimColor = (color) => {
+  return color.toLowerCase().replace(/ff$/, "20");
+};
+
+export const lighterColor = (color) => {
+  return color.toLowerCase().replace(/ff$/, "aa");
+};
+
+export const fullColor = (color) => {
+  return color.toLowerCase().replace(/20$/, "ff");
+};
+
 function updateCssColors() {
   const style = window.getComputedStyle(document.documentElement);
   colors.text = style.getPropertyValue("--evcc-default-text");
@@ -42,22 +55,19 @@ function updateCssColors() {
   colors.border = style.getPropertyValue("--bs-border-color-translucent");
   colors.self = style.getPropertyValue("--evcc-self");
   colors.grid = style.getPropertyValue("--evcc-grid");
+  colors.price = style.getPropertyValue("--evcc-price");
+  colors.co2 = style.getPropertyValue("--evcc-co2");
   colors.background = style.getPropertyValue("--evcc-background");
   colors.pricePerKWh = style.getPropertyValue("--bs-gray-medium");
   colors.co2PerKWh = style.getPropertyValue("--bs-gray-medium");
+  colors.light = style.getPropertyValue("--bs-gray-light");
 }
 
 // update colors on theme change
 const darkModeMatcher = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)");
 darkModeMatcher?.addEventListener("change", updateCssColors);
+// initialize colors
 updateCssColors();
-
-export const dimColor = (color) => {
-  return color.toLowerCase().replace(/ff$/, "20");
-};
-
-export const fullColor = (color) => {
-  return color.toLowerCase().replace(/20$/, "ff");
-};
+window.requestAnimationFrame(updateCssColors);
 
 export default colors;
diff --git a/assets/js/components/Energyflow/Energyflow.vue b/assets/js/components/Energyflow/Energyflow.vue
index 35316e4f..c221f8b4 100644
--- a/assets/js/components/Energyflow/Energyflow.vue
+++ b/assets/js/components/Energyflow/Energyflow.vue
@@ -83,8 +83,15 @@
 							icon="sun"
 							:power="pvProduction"
 							:powerTooltip="pvTooltip"
+							:details="solarForecast"
+							:detailsFmt="forecastFmt"
+							:detailsTooltip="forecastTooltip(solarForecast)"
+							:detailsInactive="solarForecast === 0"
+							:detailsIcon="solarForecast !== undefined ? 'forecast' : undefined"
+							:detailsClickable="solarForecast !== undefined"
 							:powerUnit="powerUnit"
 							data-testid="energyflow-entry-production"
+							@details-clicked="openForecastModal"
 						/>
 						<EnergyflowEntry
 							v-if="batteryConfigured"
@@ -229,7 +236,7 @@ import AnimatedNumber from "../AnimatedNumber.vue";
 import settings from "../../settings";
 import { CO2_TYPE } from "../../units";
 import collector from "../../mixins/collector";
-
+import { energyByDay } from "../../utils/forecast";
 export default {
 	name: "Energyflow",
 	components: {
@@ -266,6 +273,7 @@ export default {
 		prioritySoc: { type: Number },
 		bufferSoc: { type: Number },
 		bufferStartSoc: { type: Number },
+		forecast: { type: Object, default: () => ({}) },
 	},
 	data: () => {
 		return { detailsOpen: false, detailsCompleteHeight: null, ready: false };
@@ -381,6 +389,11 @@ export default {
 			}
 			return this.fmtPricePerKWh(this.batteryGridChargeLimit, this.currency, true);
 		},
+		solarForecast() {
+			const slots = this.forecast.solar || [];
+			if (slots.length === 0) return undefined;
+			return energyByDay(slots, 0);
+		},
 	},
 	watch: {
 		pvConfigured() {
@@ -418,6 +431,12 @@ export default {
 			}
 			return result;
 		},
+		forecastTooltip(value) {
+			if (value !== null) {
+				return [this.$t("main.energyflow.forecastTooltip")];
+			}
+			return [];
+		},
 		detailsValue(price, co2) {
 			if (this.co2Available) {
 				return co2;
@@ -430,6 +449,12 @@ export default {
 			}
 			return this.fmtPricePerKWh(value, this.currency, true);
 		},
+		forecastFmt(value) {
+			if (value === null) {
+				return "";
+			}
+			return `${this.fmtWh(value, POWER_UNIT.KW)}`;
+		},
 		kw: function (watt) {
 			return this.fmtW(watt, this.powerUnit);
 		},
@@ -447,6 +472,10 @@ export default {
 			);
 			modal.show();
 		},
+		openForecastModal() {
+			const modal = Modal.getOrCreateInstance(document.getElementById("forecastModal"));
+			modal.show();
+		},
 		dischargePower(power) {
 			return Math.abs(Math.max(0, power));
 		},
diff --git a/assets/js/components/Energyflow/EnergyflowEntry.vue b/assets/js/components/Energyflow/EnergyflowEntry.vue
index 5ec1410a..040c1548 100644
--- a/assets/js/components/Energyflow/EnergyflowEntry.vue
+++ b/assets/js/components/Energyflow/EnergyflowEntry.vue
@@ -13,12 +13,19 @@
 				<div
 					ref="details"
 					class="fw-normal"
-					:class="{ 'text-decoration-underline': detailsClickable }"
+					:class="{
+						'text-decoration-underline': detailsClickable,
+						'evcc-gray': detailsInactive,
+					}"
 					data-testid="energyflow-entry-details"
 					data-bs-toggle="tooltip"
 					:tabindex="detailsClickable ? 0 : undefined"
 					@click="detailsClicked"
 				>
+					<ForecastIcon
+						v-if="detailsIcon === 'forecast'"
+						class="ms-2 me-1 d-inline-block"
+					/>
 					<AnimatedNumber v-if="!isNaN(details)" :to="details" :format="detailsFmt" />
 				</div>
 				<div ref="power" class="power" data-bs-toggle="tooltip" @click="powerClicked">
@@ -41,10 +48,11 @@ import BatteryIcon from "./BatteryIcon.vue";
 import formatter from "../../mixins/formatter";
 import AnimatedNumber from "../AnimatedNumber.vue";
 import VehicleIcon from "../VehicleIcon";
+import ForecastIcon from "../MaterialIcon/Forecast.vue";
 
 export default {
 	name: "EnergyflowEntry",
-	components: { BatteryIcon, AnimatedNumber, VehicleIcon },
+	components: { BatteryIcon, AnimatedNumber, VehicleIcon, ForecastIcon },
 	mixins: [formatter],
 	props: {
 		name: { type: String },
@@ -54,9 +62,11 @@ export default {
 		powerTooltip: { type: Array },
 		powerUnit: { type: String },
 		details: { type: Number },
+		detailsIcon: { type: String },
 		detailsFmt: { type: Function },
 		detailsTooltip: { type: Array },
 		detailsClickable: { type: Boolean },
+		detailsInactive: { type: Boolean },
 	},
 	emits: ["details-clicked"],
 	data() {
@@ -107,9 +117,6 @@ export default {
 			);
 		},
 		updateDetailsTooltip() {
-			if (this.detailsClickable) {
-				return;
-			}
 			this.detailsTooltipInstance = this.updateTooltip(
 				this.detailsTooltipInstance,
 				this.detailsTooltip,
@@ -143,6 +150,8 @@ export default {
 			if (this.detailsClickable) {
 				this.$emit("details-clicked");
 			}
+			// hide tooltip, chrome needs a timeout
+			setTimeout(() => this.detailsTooltipInstance?.hide(), 10);
 		},
 	},
 };
diff --git a/assets/js/components/ForecastActiveSlot.vue b/assets/js/components/ForecastActiveSlot.vue
new file mode 100644
index 00000000..f0c368ac
--- /dev/null
+++ b/assets/js/components/ForecastActiveSlot.vue
@@ -0,0 +1,35 @@
+<template>
+	<div v-if="activeSlot" class="text-end">
+		<span class="text-nowrap">{{ day }} {{ start }}</span
+		>{{ " " }}<span class="text-nowrap">– {{ end }}</span>
+	</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from "vue";
+import type { PropType } from "vue";
+import { type PriceSlot } from "../utils/forecast";
+import formatter from "../mixins/formatter";
+
+export default defineComponent({
+	name: "ForecastActiveSlot",
+	mixins: [formatter],
+	props: {
+		activeSlot: { type: Object as PropType<PriceSlot | null> },
+	},
+	computed: {
+		day() {
+			const startDate = new Date(this.activeSlot!.start);
+			return this.weekdayShort(startDate);
+		},
+		start() {
+			const startDate = new Date(this.activeSlot!.start);
+			return this.hourShort(startDate);
+		},
+		end() {
+			const endDate = new Date(this.activeSlot!.end);
+			return this.hourShort(endDate);
+		},
+	},
+});
+</script>
diff --git a/assets/js/components/ForecastChart.vue b/assets/js/components/ForecastChart.vue
new file mode 100644
index 00000000..49bb507f
--- /dev/null
+++ b/assets/js/components/ForecastChart.vue
@@ -0,0 +1,417 @@
+<template>
+	<div>
+		<div
+			class="overflow-x-auto overflow-x-md-hidden chart-container border-1"
+			@mouseleave="onMouseLeave"
+		>
+			<div style="position: relative; height: 220px" class="chart user-select-none">
+				<Bar ref="chart" :data="chartData" :options="options" />
+			</div>
+		</div>
+	</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, type PropType } from "vue";
+import { Bar } from "vue-chartjs";
+import {
+	BarController,
+	BarElement,
+	LineController,
+	LineElement,
+	LinearScale,
+	TimeSeriesScale,
+	Legend,
+	Tooltip,
+	PointElement,
+	Filler,
+} from "chart.js";
+import ChartDataLabels from "chartjs-plugin-datalabels";
+import "chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm";
+import { registerChartComponents, commonOptions } from "./Sessions/chartConfig";
+import formatter, { POWER_UNIT } from "../mixins/formatter";
+import colors, { lighterColor } from "../colors";
+import {
+	energyByDay,
+	highestSlotIndexByDay,
+	type PriceSlot,
+	ForecastType,
+} from "../utils/forecast";
+
+registerChartComponents([
+	BarController,
+	BarElement,
+	LineController,
+	LineElement,
+	Filler,
+	LinearScale,
+	TimeSeriesScale,
+	Legend,
+	Tooltip,
+	PointElement,
+	ChartDataLabels,
+]);
+
+export default defineComponent({
+	name: "ForecastChart",
+	components: { Bar },
+	mixins: [formatter],
+	props: {
+		grid: { type: Array as PropType<PriceSlot[]> },
+		solar: { type: Array as PropType<PriceSlot[]> },
+		co2: { type: Array as PropType<PriceSlot[]> },
+		currency: { type: String as PropType<string> },
+		selected: { type: String as PropType<ForecastType> },
+	},
+	emits: ["selected"],
+	data(): {
+		selectedIndex: number | null;
+		ignoreEvents: boolean;
+		ignoreEventsTimeout: ReturnType<typeof setTimeout> | null;
+	} {
+		return {
+			selectedIndex: null,
+			ignoreEvents: false,
+			ignoreEventsTimeout: null,
+		};
+	},
+	computed: {
+		startDate() {
+			const now = new Date();
+			const slots = this.grid || this.co2 || this.solar || [];
+			const currentSlot = slots.find(({ start, end }) => {
+				return new Date(start) <= now && new Date(end) > now;
+			});
+			if (currentSlot) {
+				return new Date(currentSlot.start);
+			}
+			return now;
+		},
+		solarSlots() {
+			return this.filterSlots(this.solar);
+		},
+		gridSlots() {
+			return this.filterSlots(this.grid);
+		},
+		co2Slots() {
+			return this.filterSlots(this.co2);
+		},
+		currentSlots() {
+			switch (this.selected) {
+				case ForecastType.Price:
+					return this.gridSlots;
+				case ForecastType.Solar:
+					return this.solarSlots;
+				case ForecastType.Co2:
+					return this.co2Slots;
+				default:
+					return [];
+			}
+		},
+		selectedSlot() {
+			return this.selectedIndex !== null ? this.currentSlots[this.selectedIndex] : null;
+		},
+		maxPriceIndex() {
+			return this.maxIndex(this.gridSlots);
+		},
+		minPriceIndex() {
+			return this.minIndex(this.gridSlots);
+		},
+		maxCo2Index() {
+			return this.maxIndex(this.co2Slots);
+		},
+		minCo2Index() {
+			return this.minIndex(this.co2Slots);
+		},
+		maxSolarIndex() {
+			return this.maxIndex(this.solarSlots);
+		},
+		solarHighlights() {
+			return [0, 1, 2].map((day) => {
+				const energy = energyByDay(this.solarSlots, day);
+				const index = highestSlotIndexByDay(this.solarSlots, day);
+				return { index, energy };
+			});
+		},
+		chartData() {
+			const datasets: unknown[] = [];
+			if (this.solarSlots.length > 0) {
+				const active = this.selected === ForecastType.Solar;
+				const color = active ? colors.self : colors.border;
+				datasets.push({
+					label: ForecastType.Solar,
+					type: "line",
+					data: this.solarSlots.map((slot, index) => ({
+						y: slot.price,
+						x: new Date(slot.start),
+						highlight:
+							active &&
+							(this.selectedIndex !== null
+								? this.selectedIndex === index
+								: this.solarHighlights.find(({ index: i }) => i === index)?.energy),
+					})),
+					yAxisID: "yForecast",
+					backgroundColor: lighterColor(color),
+					borderColor: color,
+					fill: "origin",
+					tension: 0.5,
+					pointRadius: 0,
+					pointHoverRadius: active ? 4 : 0,
+					spanGaps: true,
+					order: active ? 0 : 1,
+				});
+			}
+			if (this.gridSlots.length > 0) {
+				const active = this.selected === ForecastType.Price;
+				const color = active ? colors.price : colors.border;
+				datasets.push({
+					label: ForecastType.Price,
+					data: this.gridSlots.map((slot, index) => ({
+						y: slot.price,
+						x: new Date(slot.start),
+						highlight:
+							active &&
+							(this.selectedIndex !== null
+								? this.selectedIndex === index
+								: index === this.maxPriceIndex || index === this.minPriceIndex),
+					})),
+					yAxisID: "yPrice",
+					borderRadius: 8,
+					backgroundColor: color,
+					borderColor: color,
+					order: active ? 0 : 1,
+				});
+			}
+			if (this.co2Slots.length > 0) {
+				const active = this.selected === ForecastType.Co2;
+				const color = active ? colors.co2 : colors.border;
+				datasets.push({
+					label: ForecastType.Co2,
+					type: "line",
+					data: this.co2Slots.map((slot, index) => {
+						const dataActive =
+							active &&
+							(this.selectedIndex !== null
+								? this.selectedIndex === index
+								: index === this.maxCo2Index || index === this.minCo2Index);
+						return {
+							y: slot.price,
+							x: new Date(slot.start),
+							highlight: dataActive,
+							active: dataActive,
+						};
+					}),
+					yAxisID: "yCo2",
+					backgroundColor: color,
+					borderColor: color,
+					tension: 0.25,
+					pointRadius: 0,
+					pointHoverRadius: active ? 4 : 0,
+					spanGaps: true,
+					order: active ? 0 : 1,
+				});
+			}
+
+			return {
+				datasets,
+			};
+		},
+		options() {
+			// eslint-disable-next-line @typescript-eslint/no-this-alias
+			const vThis = this;
+			return {
+				...commonOptions,
+				locale: this.$i18n?.locale,
+				layout: { padding: { top: 32 } },
+				color: colors.text,
+				borderSkipped: false,
+				animation: {
+					duration: 500, // --evcc-transition-medium
+					colors: true,
+					numbers: false,
+				},
+				interaction: {
+					mode: "index",
+					axis: "x",
+					intersect: false,
+				},
+				categoryPercentage: 0.7,
+				events: ["mousemove", "click", "touchstart", "touchend"],
+				onHover: function (event, active) {
+					if (["touchend", "click"].includes(event.type)) {
+						vThis.selectIndex(null, true);
+						return;
+					}
+					const element = active.find(({ datasetIndex }) => {
+						const { label } = event.chart.getDatasetMeta(datasetIndex);
+						return label === vThis.selected;
+					});
+					vThis.selectIndex(element ? element.index : null);
+				},
+				plugins: {
+					...commonOptions.plugins,
+					datalabels: {
+						backgroundColor: function (context) {
+							return context.dataset.borderColor;
+						},
+						align: function (context) {
+							// rotate label position to avoid horizontal clipping
+							const step = 20;
+							const adjust = {
+								0: step * 2,
+								1: step * 1,
+								46: step * -1,
+								47: step * -2,
+							};
+							return -90 + (adjust[context.dataIndex] || 0);
+						},
+						anchor: "end",
+						offset: 8,
+						padding: function (context) {
+							const data = context.dataset.data[context.dataIndex];
+							const x = typeof data.highlight === "number" ? 32 : 8;
+							return {
+								x,
+								y: 4,
+							};
+						},
+						borderRadius: 4,
+						color: colors.background,
+						font: { weight: "bold" },
+						formatter: function (data, ctx) {
+							if (data.highlight) {
+								switch (ctx.dataset.label) {
+									case ForecastType.Price:
+										return vThis.fmtPricePerKWh(
+											data.y,
+											vThis.currency,
+											true,
+											true
+										);
+									case ForecastType.Co2:
+										return vThis.fmtGrams(data.y);
+									case ForecastType.Solar:
+										if (data.highlight === true) {
+											return vThis.fmtW(data.y, POWER_UNIT.AUTO);
+										} else {
+											return vThis.fmtWh(data.highlight, POWER_UNIT.AUTO);
+										}
+									default:
+										return null;
+								}
+							}
+							return null;
+						},
+					},
+					tooltip: null,
+				},
+				scales: {
+					x: {
+						type: "timeseries",
+						display: true,
+						time: { unit: "day" },
+						border: { display: false },
+						grid: {
+							display: true,
+							color: colors.border,
+							offset: false,
+							lineWidth: function (context) {
+								if (context.type !== "tick") {
+									return 0;
+								}
+								const label = context.tick?.label;
+								return Array.isArray(label) ? 1 : 0;
+							},
+						},
+						ticks: {
+							color: colors.muted,
+							autoSkip: false,
+							maxRotation: 0,
+							minRotation: 0,
+							source: "data",
+							align: "center",
+							callback: function (value) {
+								const date = new Date(value);
+								const hour = date.getHours();
+								const hourFmt = vThis.hourShort(date);
+								if (hour === 0) {
+									return [hourFmt, vThis.weekdayShort(date)];
+								}
+								if (hour % 6 === 0) {
+									return hourFmt;
+								}
+								return "";
+							},
+						},
+					},
+					yForecast: { display: false, min: 0, max: this.yMax(this.solarSlots) },
+					yCo2: { display: false, min: 0, max: this.yMax(this.co2Slots) },
+					yPrice: { display: false, min: 0, max: this.yMax(this.gridSlots) },
+				},
+			};
+		},
+	},
+	watch: {
+		selectedSlot(slot) {
+			this.$emit("selected", slot);
+		},
+	},
+	methods: {
+		filterSlots(slots: PriceSlot[] = []) {
+			return slots.filter((slot) => new Date(slot.end) > this.startDate).slice(0, 48);
+		},
+		onMouseLeave() {
+			this.selectIndex(null, true);
+		},
+		selectIndex(index: number | null, timeout = false) {
+			if (this.ignoreEvents) return;
+			this.selectedIndex = index;
+
+			// reset hover state (points, highlights)
+			if (this.selectedIndex === null) {
+				this.$nextTick(() => {
+					// @ts-expect-error unknown chart type
+					this.$refs.chart?.chart?.setActiveElements([]);
+				});
+			}
+
+			// ignore events after selection reset because chart.js triggers delayed mousemove events
+			if (timeout) {
+				this.ignoreEvents = true;
+				this.ignoreEventsTimeout = setTimeout(() => {
+					this.ignoreEvents = false;
+				}, 100);
+			}
+		},
+		yMax(slots: PriceSlot[] = []): number | undefined {
+			const value = this.maxValue(slots);
+			return value ? value * 1.15 : undefined;
+		},
+		maxIndex(slots: PriceSlot[] = []) {
+			return slots.reduce((max, slot, index) => {
+				return slot.price > slots[max].price ? index : max;
+			}, 0);
+		},
+		minIndex(slots: PriceSlot[] = []) {
+			return slots.reduce((min, slot, index) => {
+				return slot.price < slots[min].price ? index : min;
+			}, 0);
+		},
+		maxValue(slots: PriceSlot[] = []) {
+			return slots[this.maxIndex(slots)]?.price || null;
+		},
+	},
+});
+</script>
+
+<style scoped>
+.chart {
+	width: 780px;
+}
+
+@media (min-width: 992px) {
+	.chart {
+		width: 100%;
+	}
+}
+</style>
diff --git a/assets/js/components/ForecastDetails.vue b/assets/js/components/ForecastDetails.vue
new file mode 100644
index 00000000..bfe77e4d
--- /dev/null
+++ b/assets/js/components/ForecastDetails.vue
@@ -0,0 +1,190 @@
+<template>
+	<div v-if="isSolar && solarEnergy" class="row">
+		<div class="col-6 col-sm-4 mb-3 d-flex flex-column">
+			<div class="label">{{ label("today") }}</div>
+			<div class="value d-flex flex-column flex-lg-row gap-lg-2 align-items-lg-baseline">
+				<div class="text-primary text-nowrap">{{ solarEnergy.today.energy }}</div>
+				<div class="extraValue text-nowrap">{{ label("remaining") }}</div>
+			</div>
+		</div>
+		<div class="col-6 col-sm-4 mb-3 d-flex flex-column align-items-end align-items-sm-center">
+			<div class="label">{{ label("tomorrow") }}</div>
+			<div
+				class="value d-flex flex-column flex-lg-row gap-lg-2 align-items-end align-items-sm-center align-items-lg-baseline"
+			>
+				<div class="text-primary text-nowrap">{{ solarEnergy.tomorrow.energy }}</div>
+				<div v-if="solarEnergy.tomorrow.incomplete" class="extraValue text-nowrap">
+					{{ label("partly") }}
+				</div>
+			</div>
+		</div>
+		<div class="col-6 col-sm-4 mb-3 d-flex flex-column align-items-start align-items-sm-end">
+			<div class="label">{{ label("dayAfterTomorrow") }}</div>
+			<div
+				class="value d-flex flex-column flex-lg-row gap-lg-2 align-items-start align-items-sm-end align-items-lg-baseline"
+			>
+				<div class="text-primary text-nowrap">
+					{{ solarEnergy.dayAfterTomorrow.energy }}
+				</div>
+				<div v-if="solarEnergy.dayAfterTomorrow.incomplete" class="extraValue text-nowrap">
+					{{ label("partly") }}
+				</div>
+			</div>
+		</div>
+	</div>
+	<div v-else class="row">
+		<div class="col-12 col-sm-6 col-lg-4 mb-3 d-flex flex-column">
+			<div class="label">{{ label("range") }}</div>
+			<div class="value text-price text-nowrap" :class="highlightColor">
+				{{ priceRange }}
+			</div>
+		</div>
+		<div
+			class="col-12 col-sm-6 col-lg-4 mb-3 d-flex flex-column align-items-sm-end align-items-lg-center"
+		>
+			<div class="label">{{ label("average") }}</div>
+			<div class="value text-price text-nowrap" :class="highlightColor">
+				{{ averagePrice }}
+			</div>
+		</div>
+		<div
+			class="col-12 col-sm-6 col-lg-4 mb-3 d-flex flex-column align-items-sm-start align-items-lg-end"
+		>
+			<div class="label">{{ label("lowestHour") }}</div>
+			<div class="value text-price text-nowrap" :class="highlightColor">
+				{{ lowestPriceHour }}
+			</div>
+		</div>
+	</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from "vue";
+import {
+	energyByDay,
+	dayStringByOffset,
+	filterSlotsByDate,
+	ForecastType,
+	type PriceSlot,
+} from "../utils/forecast";
+import formatter, { POWER_UNIT } from "../mixins/formatter";
+
+const LOCALES_WITHOUT_DAY_AFTER_TOMORROW = ["en", "tr"];
+
+export interface Energy {
+	energy: string;
+	incomplete: boolean;
+}
+
+export interface EnergyByDay {
+	today: Energy;
+	tomorrow: Energy;
+	dayAfterTomorrow: Energy;
+}
+
+export default defineComponent({
+	name: "ForecastDetails",
+	mixins: [formatter],
+	props: {
+		type: { type: String as () => ForecastType, required: true },
+		slots: { type: Array as () => PriceSlot[], default: () => [] },
+		currency: { type: String },
+	},
+	computed: {
+		isSolar() {
+			return this.type === ForecastType.Solar;
+		},
+		upcomingSlots() {
+			const now = new Date();
+			return this.slots.filter((slot) => new Date(slot.end) > now).slice(0, 48);
+		},
+		solarEnergy(): EnergyByDay | undefined {
+			if (!this.isSolar) return;
+
+			const days = ["today", "tomorrow", "dayAfterTomorrow"];
+			return days.reduce((acc, day, index) => {
+				const energy = energyByDay(this.slots, index);
+				const date = new Date();
+				date.setDate(date.getDate() + index);
+				const dayString = dayStringByOffset(index);
+				const count = filterSlotsByDate(this.slots, dayString).length;
+				const empty = count === 0;
+				const complete = count === 24;
+				const energyFmt = empty ? "-" : this.fmtWh(energy, POWER_UNIT.AUTO);
+				acc[day] = {
+					energy: energyFmt,
+					incomplete: energy && !complete && !empty,
+				};
+				return acc;
+			}, {} as EnergyByDay);
+		},
+		averagePrice() {
+			if (this.isSolar) return "";
+			const slots = this.upcomingSlots;
+			const price = slots.reduce((acc, slot) => acc + slot.price, 0) / slots.length;
+			return this.fmtValue(price, true);
+		},
+		priceRange() {
+			if (this.isSolar) return "";
+			const slots = this.upcomingSlots;
+			const min = Math.min(...slots.map((slot) => slot.price));
+			const max = Math.max(...slots.map((slot) => slot.price));
+			return `${this.fmtValue(min, false)} – ${this.fmtValue(max, true)}`;
+		},
+		lowestPriceHour() {
+			if (this.isSolar) return "";
+			const slots = this.upcomingSlots;
+			const min = Math.min(...slots.map((slot) => slot.price));
+			const slot = slots.find((slot) => slot.price === min);
+			if (!slot) return "";
+			const start = new Date(slot.start);
+			const end = new Date(slot.end);
+			return `${this.weekdayShort(start)} ${this.hourShort(start)} – ${this.hourShort(end)}`;
+		},
+		highlightColor() {
+			const colors = {
+				[ForecastType.Price]: "text-price",
+				[ForecastType.Co2]: "text-co2",
+			};
+			return colors[this.type];
+		},
+	},
+	methods: {
+		label(key: string) {
+			// special case "day after tomorrow"
+			if (
+				key === "dayAfterTomorrow" &&
+				LOCALES_WITHOUT_DAY_AFTER_TOMORROW.includes(this.$i18n.locale)
+			) {
+				const date = new Date();
+				date.setDate(date.getDate() + 2);
+				return this.fmtDayMonth(date);
+			}
+
+			return this.$t(`forecast.${this.type}.${key}`);
+		},
+		fmtValue(value: number, withUnit = true) {
+			if (this.type === ForecastType.Price) {
+				return this.fmtPricePerKWh(value, this.currency, false, withUnit);
+			}
+			return withUnit ? this.fmtCo2Medium(value) : this.fmtNumber(value, 0);
+		},
+	},
+});
+</script>
+
+<style scoped>
+.value {
+	font-size: 18px;
+	font-weight: bold;
+}
+.extraValue {
+	color: var(--evcc-gray);
+	font-size: 14px;
+	font-weight: normal;
+}
+.label {
+	color: var(--evcc-gray);
+	text-transform: uppercase;
+}
+</style>
diff --git a/assets/js/components/ForecastModal.vue b/assets/js/components/ForecastModal.vue
new file mode 100644
index 00000000..8bca85ee
--- /dev/null
+++ b/assets/js/components/ForecastModal.vue
@@ -0,0 +1,135 @@
+<template>
+	<GenericModal
+		id="forecastModal"
+		:title="$t('forecast.modalTitle')"
+		size="xl"
+		data-testid="forecast-modal"
+		@open="modalVisible"
+		@closed="modalInvisible"
+	>
+		<div v-if="isModalVisible" class="d-flex justify-content-between align-items-center gap-2">
+			<ForecastTypeSelect v-model="selectedType" :forecast="forecast" />
+			<ForecastActiveSlot :activeSlot="selectedSlot" />
+		</div>
+
+		<ForecastChart
+			class="my-3"
+			:grid="forecast.grid"
+			:solar="forecast.solar"
+			:co2="forecast.co2"
+			:currency="currency"
+			:selected="selectedType"
+			@selected="updateSlot"
+		/>
+
+		<ForecastDetails :type="selectedType" :slots="selectedSlots" :currency="currency" />
+	</GenericModal>
+</template>
+
+<script lang="ts">
+import "@h2d2/shopicons/es/regular/eco1";
+import "@h2d2/shopicons/es/regular/sun";
+import { defineComponent } from "vue";
+import type { PropType } from "vue";
+import GenericModal from "./GenericModal.vue";
+import ForecastChart from "./ForecastChart.vue";
+import ForecastTypeSelect from "./ForecastTypeSelect.vue";
+import ForecastDetails from "./ForecastDetails.vue";
+import ForecastActiveSlot from "./ForecastActiveSlot.vue";
+import { type PriceSlot, ForecastType } from "../utils/forecast";
+import formatter from "../mixins/formatter";
+
+interface Forecast {
+	grid?: PriceSlot[];
+	solar?: PriceSlot[];
+	co2?: PriceSlot[];
+}
+
+export default defineComponent({
+	name: "ForecastModal",
+	components: {
+		GenericModal,
+		ForecastChart,
+		ForecastTypeSelect,
+		ForecastDetails,
+		ForecastActiveSlot,
+	},
+	mixins: [formatter],
+	props: {
+		forecast: { type: Object as PropType<Forecast>, default: () => ({}) },
+		currency: { type: String },
+	},
+	data: function (): {
+		isModalVisible: boolean;
+		selectedType: ForecastType;
+		selectedSlot: PriceSlot | null;
+	} {
+		return {
+			isModalVisible: false,
+			selectedType: ForecastType.Solar,
+			selectedSlot: null,
+		};
+	},
+	computed: {
+		selectedSlots() {
+			const slots = {
+				[ForecastType.Solar]: this.forecast.solar,
+				[ForecastType.Price]: this.forecast.grid,
+				[ForecastType.Co2]: this.forecast.co2,
+			};
+			return slots[this.selectedType] || [];
+		},
+	},
+	watch: {
+		isModalVisible: function (newVal) {
+			if (newVal) {
+				this.updateSelectedType();
+			}
+		},
+	},
+	mounted() {
+		this.updateSelectedType();
+	},
+	methods: {
+		modalVisible() {
+			this.isModalVisible = true;
+		},
+		modalInvisible() {
+			this.isModalVisible = false;
+		},
+		updateSlot(slot: PriceSlot | null) {
+			this.selectedSlot = slot;
+		},
+		updateSelectedType() {
+			const availableTypes = {
+				[ForecastType.Solar]: !!this.forecast.solar,
+				[ForecastType.Price]: !!this.forecast.grid,
+				[ForecastType.Co2]: !!this.forecast.co2,
+			};
+
+			// selection has data, do nothing
+			if (availableTypes[this.selectedType]) return;
+			// fallback to first available type
+			this.selectedType =
+				Object.values(ForecastType).find((type) => availableTypes[type]) ||
+				Object.values(ForecastType)[0];
+		},
+	},
+});
+</script>
+
+<style scoped>
+.value {
+	font-size: 18px;
+	font-weight: bold;
+}
+.extraValue {
+	color: var(--evcc-gray);
+	font-size: 14px;
+	font-weight: normal;
+}
+.label {
+	color: var(--evcc-gray);
+	text-transform: uppercase;
+}
+</style>
diff --git a/assets/js/components/ForecastTypeSelect.vue b/assets/js/components/ForecastTypeSelect.vue
new file mode 100644
index 00000000..19f50622
--- /dev/null
+++ b/assets/js/components/ForecastTypeSelect.vue
@@ -0,0 +1,57 @@
+<template>
+	<IconSelectGroup>
+		<IconSelectItem
+			v-for="type in types"
+			:key="type"
+			:active="selectedType === type"
+			:label="$t(`forecast.type.${type}`)"
+			:disabled="!availableTypes[type]"
+			hideLabelOnMobile
+			@click="$emit('update:modelValue', type)"
+		>
+			<component :is="typeIcons[type]"></component>
+		</IconSelectItem>
+	</IconSelectGroup>
+</template>
+
+<script lang="ts">
+import "@h2d2/shopicons/es/regular/eco1";
+import "@h2d2/shopicons/es/regular/sun";
+import { defineComponent } from "vue";
+import IconSelectItem from "./IconSelectItem.vue";
+import IconSelectGroup from "./IconSelectGroup.vue";
+import DynamicPriceIcon from "./MaterialIcon/DynamicPrice.vue";
+import { ForecastType } from "../utils/forecast";
+
+export default defineComponent({
+	name: "ForecastTypeSelect",
+	components: { IconSelectItem, IconSelectGroup },
+	props: {
+		modelValue: { type: String as () => ForecastType, required: true },
+		forecast: { type: Object, required: true },
+	},
+	emits: ["update:modelValue"],
+	computed: {
+		selectedType() {
+			return this.modelValue;
+		},
+		types() {
+			return Object.values(ForecastType);
+		},
+		availableTypes() {
+			return {
+				[ForecastType.Solar]: !!this.forecast.solar,
+				[ForecastType.Price]: !!this.forecast.grid,
+				[ForecastType.Co2]: !!this.forecast.co2,
+			};
+		},
+		typeIcons() {
+			return {
+				[ForecastType.Solar]: "shopicon-regular-sun",
+				[ForecastType.Price]: DynamicPriceIcon,
+				[ForecastType.Co2]: "shopicon-regular-eco1",
+			};
+		},
+	},
+});
+</script>
diff --git a/assets/js/components/IconSelectItem.vue b/assets/js/components/IconSelectItem.vue
index 0a95e12a..6e9526e7 100644
--- a/assets/js/components/IconSelectItem.vue
+++ b/assets/js/components/IconSelectItem.vue
@@ -3,12 +3,16 @@
 		<button
 			type="button"
 			class="btn gap-2 btn-sm"
-			:class="{ active, withLabel: !!label }"
+			:class="{ active, withLabel: !!label, hideLabelOnMobile }"
 			:disabled="disabled"
 			@click="$emit('click', value)"
 		>
 			<slot></slot>
-			<span v-if="label" class="text-nowrap text-truncate">
+			<span
+				v-if="label"
+				class="text-nowrap text-truncate"
+				:class="{ 'd-none d-md-inline': hideLabelOnMobile }"
+			>
 				{{ label }}
 			</span>
 		</button>
@@ -23,6 +27,7 @@ export default {
 		active: Boolean,
 		label: String,
 		disabled: Boolean,
+		hideLabelOnMobile: Boolean,
 	},
 	emits: ["click"],
 };
@@ -51,4 +56,10 @@ export default {
 	width: auto;
 	padding: 0 1rem;
 }
+@media (max-width: 768px) {
+	.btn.hideLabelOnMobile {
+		width: 32px;
+		padding: 0;
+	}
+}
 </style>
diff --git a/assets/js/components/MaterialIcon/Forecast.vue b/assets/js/components/MaterialIcon/Forecast.vue
new file mode 100644
index 00000000..8870e8c9
--- /dev/null
+++ b/assets/js/components/MaterialIcon/Forecast.vue
@@ -0,0 +1,17 @@
+<template>
+	<svg :style="svgStyle" viewBox="0 0 48 48">
+		<path
+			fill="currentColor"
+			d="M5.78,31c1.791,-3.381 3.675,-6.042 5.643,-9.059c1.422,-2.18 2.889,-4.118 4.384,-5.67l2.881,2.774c-1.338,1.39 -2.642,3.13 -3.914,5.081c-1.523,2.335 -3,4.445 -4.418,6.874l-4.576,-0Zm32.023,0c-0.661,-1.3 -1.346,-2.567 -2.051,-3.781l3.458,-2.011c1.067,1.836 2.089,3.787 3.055,5.792l-4.462,0Zm-16.172,-14.32l-1.915,-3.512c1.409,-0.768 2.842,-1.169 4.286,-1.168c1.35,0.001 2.702,0.328 4.04,0.954l-1.696,3.623c-0.78,-0.365 -1.562,-0.576 -2.348,-0.577c-0.8,-0.001 -1.586,0.254 -2.367,0.68Zm7.78,2.072l2.729,-2.924c1.591,1.484 3.149,3.369 4.639,5.53l-3.292,2.271c-1.313,-1.902 -2.675,-3.57 -4.076,-4.877Z"
+		/>
+	</svg>
+</template>
+
+<script>
+import icon from "../../mixins/icon";
+
+export default {
+	name: "Forecast",
+	mixins: [icon],
+};
+</script>
diff --git a/assets/js/components/Sessions/chartConfig.js b/assets/js/components/Sessions/chartConfig.js
index a69cbc8b..6bde1c0d 100644
--- a/assets/js/components/Sessions/chartConfig.js
+++ b/assets/js/components/Sessions/chartConfig.js
@@ -40,6 +40,7 @@ export const commonOptions = {
   maintainAspectRatio: false,
   plugins: {
     legend: { display: false },
+    datalabels: { display: false },
     tooltip: {
       backgroundColor: "#000000cc",
       boxPadding: 5,
diff --git a/assets/js/components/Site.vue b/assets/js/components/Site.vue
index 160a2560..164365c8 100644
--- a/assets/js/components/Site.vue
+++ b/assets/js/components/Site.vue
@@ -131,6 +131,7 @@ export default {
 		sponsor: { type: Object, default: () => ({}) },
 		smartCostType: String,
 		fatal: Object,
+		forecast: Object,
 	},
 	computed: {
 		batteryConfigured: function () {
diff --git a/assets/js/components/TopNavigation.vue b/assets/js/components/TopNavigation.vue
index d17d33ec..1678d205 100644
--- a/assets/js/components/TopNavigation.vue
+++ b/assets/js/components/TopNavigation.vue
@@ -48,6 +48,16 @@
 					{{ $t("batterySettings.modalTitle") }}
 				</button>
 			</li>
+			<li v-if="forecastAvailable">
+				<button
+					type="button"
+					class="dropdown-item"
+					data-testid="topnavigation-forecast"
+					@click="openForecastModal"
+				>
+					{{ $t("forecast.modalTitle") }}
+				</button>
+			</li>
 			<li>
 				<router-link class="dropdown-item" to="/config" active-class="active">
 					<span
@@ -142,6 +152,7 @@ export default {
 				return {};
 			},
 		},
+		forecast: Object,
 		battery: Array,
 		fatal: Object,
 	},
@@ -181,6 +192,10 @@ export default {
 		batteryModalAvailable() {
 			return this.batteryConfigured;
 		},
+		forecastAvailable() {
+			const { grid, solar, co2 } = this.forecast || {};
+			return grid || solar || co2;
+		},
 		showLogout() {
 			return isLoggedIn();
 		},
@@ -219,6 +234,10 @@ export default {
 			);
 			modal.show();
 		},
+		openForecastModal() {
+			const modal = Modal.getOrCreateInstance(document.getElementById("forecastModal"));
+			modal.show();
+		},
 		openNativeSettings() {
 			sendToApp({ type: "settings" });
 		},
diff --git a/assets/js/mixins/formatter.js b/assets/js/mixins/formatter.js
index 1d487e07..0153a154 100644
--- a/assets/js/mixins/formatter.js
+++ b/assets/js/mixins/formatter.js
@@ -187,6 +187,14 @@ export default {
         minute: "numeric",
       }).format(date);
     },
+    hourShort: function (date) {
+      const locale = this.$i18n?.locale;
+      // special: use shorter german format
+      if (locale === "de") return date.getHours();
+      return new Intl.DateTimeFormat(locale, {
+        hour: "numeric",
+      }).format(date);
+    },
     weekdayShort: function (date) {
       return new Intl.DateTimeFormat(this.$i18n?.locale, {
         weekday: "short",
diff --git a/assets/js/router.js b/assets/js/router.js
index a2f02be2..2e5202d3 100644
--- a/assets/js/router.js
+++ b/assets/js/router.js
@@ -61,6 +61,11 @@ export default function setupRouter(i18n) {
           };
         },
       },
+      {
+        path: "/energy",
+        component: () => import("./views/Energy.vue"),
+        props: true,
+      },
       {
         path: "/log",
         component: () => import("./views/Log.vue"),
diff --git a/assets/js/utils/forecast.ts b/assets/js/utils/forecast.ts
new file mode 100644
index 00000000..2ded3dbb
--- /dev/null
+++ b/assets/js/utils/forecast.ts
@@ -0,0 +1,70 @@
+export interface PriceSlot {
+	start: string;
+	end: string;
+	price: number;
+}
+
+export enum ForecastType {
+	Solar = "solar",
+	Price = "price",
+	Co2 = "co2",
+}
+
+export function aggregateEnergy(slots: PriceSlot[], ignorePast: boolean = false): number {
+	const now = new Date();
+	return slots.reduce((acc: number, { start, end, price: power }: PriceSlot) => {
+		let startDate = new Date(start);
+		const endDate = new Date(end);
+		if (ignorePast) {
+			if (endDate < now) {
+				return acc; // ignore past slots
+			}
+			if (startDate < now) {
+				startDate = now; // count this slot from now on
+			}
+		}
+		const hours = (endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60); // convert ms to hours
+		const energy = power * hours; // Wh
+		return acc + energy;
+	}, 0);
+}
+
+// return the date in local YYYY-MM-DD format
+function toDayString(date: Date): string {
+	const year = date.getFullYear();
+	const month = String(date.getMonth() + 1).padStart(2, "0");
+	const day = String(date.getDate()).padStart(2, "0");
+	return `${year}-${month}-${day}`;
+}
+
+// return only slots that are on a given date, ignores slots that are in the past
+export function filterSlotsByDate(slots: PriceSlot[], dayString: string): PriceSlot[] {
+	const now = new Date();
+	return slots.filter(({ start, end }) => {
+		const isPast = new Date(end) < now;
+		const dateMatches = toDayString(new Date(start)) === dayString;
+		return !isPast && dateMatches;
+	});
+}
+
+// return the energy for a given day (0 = today, 1 = tomorrow, etc.)
+export function energyByDay(slots: PriceSlot[], day: number = 0): number {
+	const dayString = dayStringByOffset(day);
+	const daySlots = filterSlotsByDate(slots, dayString);
+	return aggregateEnergy(daySlots, true);
+}
+
+export function dayStringByOffset(day: number): string {
+	const date = new Date();
+	date.setDate(date.getDate() + day);
+	return toDayString(date);
+}
+
+// return the highest slot for a given day (0 = today, 1 = tomorrow, etc.)
+export function highestSlotIndexByDay(slots: PriceSlot[], day: number = 0): number {
+	const dayString = dayStringByOffset(day);
+	const daySlots = filterSlotsByDate(slots, dayString);
+	const sortedSlots = daySlots.sort((a, b) => b.price - a.price);
+	const highestSlot = sortedSlots[0] || {};
+	return slots.findIndex((slot) => slot.start === highestSlot.start);
+}
diff --git a/assets/js/views/App.vue b/assets/js/views/App.vue
index 2bed7068..a9656f66 100644
--- a/assets/js/views/App.vue
+++ b/assets/js/views/App.vue
@@ -4,6 +4,7 @@
 
 		<GlobalSettingsModal v-bind="globalSettingsProps" />
 		<BatterySettingsModal v-if="batteryModalAvailabe" v-bind="batterySettingsProps" />
+		<ForecastModal v-bind="forecastModalProps" />
 		<HelpModal />
 		<PasswordModal />
 		<LoginModal />
@@ -15,6 +16,7 @@
 import store from "../store";
 import GlobalSettingsModal from "../components/GlobalSettingsModal.vue";
 import BatterySettingsModal from "../components/BatterySettingsModal.vue";
+import ForecastModal from "../components/ForecastModal.vue";
 import OfflineIndicator from "../components/OfflineIndicator.vue";
 import PasswordModal from "../components/PasswordModal.vue";
 import LoginModal from "../components/LoginModal.vue";
@@ -37,6 +39,7 @@ export default {
 		GlobalSettingsModal,
 		HelpModal,
 		BatterySettingsModal,
+		ForecastModal,
 		PasswordModal,
 		LoginModal,
 		OfflineIndicator,
@@ -69,6 +72,9 @@ export default {
 		offlineIndicatorProps() {
 			return this.collectProps(OfflineIndicator, store.state);
 		},
+		forecastModalProps() {
+			return this.collectProps(ForecastModal, store.state);
+		},
 	},
 	watch: {
 		version: function (now, prev) {
diff --git a/assets/js/views/Config.vue b/assets/js/views/Config.vue
index d3bb90ad..5e6b620d 100644
--- a/assets/js/views/Config.vue
+++ b/assets/js/views/Config.vue
@@ -445,6 +445,8 @@ export default {
 			site: { grid: "", pv: [], battery: [], title: "" },
 			deviceValueTimeout: undefined,
 			deviceValues: {},
+			isComponentMounted: true,
+			isPageVisible: true,
 		};
 	},
 	computed: {
@@ -546,12 +548,25 @@ export default {
 		},
 	},
 	mounted() {
+		this.isComponentMounted = true;
+		document.addEventListener("visibilitychange", this.handleVisibilityChange);
+		this.isPageVisible = document.visibilityState === "visible";
 		this.loadAll();
 	},
 	unmounted() {
+		this.isComponentMounted = false;
+		document.removeEventListener("visibilitychange", this.handleVisibilityChange);
 		clearTimeout(this.deviceValueTimeout);
 	},
 	methods: {
+		handleVisibilityChange() {
+			this.isPageVisible = document.visibilityState === "visible";
+			if (this.isPageVisible) {
+				this.updateValues();
+			} else {
+				clearTimeout(this.deviceValueTimeout);
+			}
+		},
 		async loadAll() {
 			await this.loadVehicles();
 			await this.loadMeters();
@@ -792,14 +807,17 @@ export default {
 				};
 				for (const type in devices) {
 					for (const device of devices[type]) {
-						await this.updateDeviceValue(type, device.name);
+						if (this.isComponentMounted && this.isPageVisible) {
+							await this.updateDeviceValue(type, device.name);
+						}
 					}
 				}
 			}
-			// ensure that component is still mounted
-			if (!this.$el) return;
-			const interval = (store.state?.interval || 30) * 1000;
-			this.deviceValueTimeout = setTimeout(this.updateValues, interval);
+
+			if (this.isComponentMounted && this.isPageVisible) {
+				const interval = (store.state?.interval || 30) * 1000;
+				this.deviceValueTimeout = setTimeout(this.updateValues, interval);
+			}
 		},
 		deviceTags(type, id) {
 			return this.deviceValues[type]?.[id] || {};
diff --git a/assets/js/views/Energy.vue b/assets/js/views/Energy.vue
new file mode 100644
index 00000000..74e99a15
--- /dev/null
+++ b/assets/js/views/Energy.vue
@@ -0,0 +1,111 @@
+<template>
+	<div class="container px-4 safe-area-inset">
+		<TopHeader title="Energy Overview" />
+		<div class="alert alert-light mb-3">This page is work in progress.</div>
+		<div class="row">
+			<main class="col-12">
+				<div v-if="forecastAvailable">
+					<h3 class="fw-normal my-4">
+						{{ $t("forecast.modalTitle") }}
+					</h3>
+					<div class="d-flex justify-content-start">
+						<IconSelectGroup>
+							<IconSelectItem
+								v-for="type in types"
+								:key="type"
+								:active="selectedType === type"
+								:label="$t(`forecast.type.${type}`)"
+								hideLabelOnMobile
+								@click="updateType(type)"
+							>
+								<component :is="typeIcons[type]"></component>
+							</IconSelectItem>
+						</IconSelectGroup>
+					</div>
+
+					<ForecastChart
+						class="my-3"
+						:grid="forecast.grid"
+						:solar="forecast.solar"
+						:co2="forecast.co2"
+						:currency="currency"
+						:selected="selectedType"
+					/>
+				</div>
+				<div v-else>
+					<p>nothing to see here</p>
+				</div>
+			</main>
+		</div>
+	</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from "vue";
+import TopHeader from "../components/TopHeader.vue";
+import ForecastChart from "../components/ForecastChart.vue";
+import IconSelectItem from "../components/IconSelectItem.vue";
+import IconSelectGroup from "../components/IconSelectGroup.vue";
+import DynamicPriceIcon from "../components/MaterialIcon/DynamicPrice.vue";
+import { type PriceSlot } from "../utils/forecast";
+import formatter from "../mixins/formatter";
+import store from "../store";
+
+interface Forecast {
+	grid?: PriceSlot[];
+	solar?: PriceSlot[];
+	co2?: PriceSlot[];
+}
+
+export const TYPES = {
+	SOLAR: "solar",
+	PRICE: "price",
+	CO2: "co2",
+};
+
+export default defineComponent({
+	name: "Energy",
+	components: {
+		TopHeader,
+		IconSelectGroup,
+		IconSelectItem,
+		ForecastChart,
+	},
+	mixins: [formatter],
+	data() {
+		return {
+			selectedType: TYPES.PRICE,
+			types: Object.values(TYPES),
+		};
+	},
+	head() {
+		return { title: `${this.$t("sessions.title")} | evcc` };
+	},
+	computed: {
+		typeIcons() {
+			return {
+				[TYPES.SOLAR]: "shopicon-regular-sun",
+				[TYPES.PRICE]: DynamicPriceIcon,
+				[TYPES.CO2]: "shopicon-regular-eco1",
+			};
+		},
+		forecastAvailable() {
+			const { grid, solar, co2 } = this.forecast;
+			return grid || solar || co2;
+		},
+		forecast(): Forecast {
+			return store.state?.forecast || {};
+		},
+		currency() {
+			return store.state?.currency;
+		},
+	},
+	methods: {
+		updateType(type: string) {
+			this.selectedType = type;
+		},
+	},
+});
+</script>
+
+<style scoped></style>
diff --git a/i18n/de.toml b/i18n/de.toml
index 85355e2c..4c57833c 100644
--- a/i18n/de.toml
+++ b/i18n/de.toml
@@ -434,6 +434,31 @@ modalUpdateNow = "Jetzt installieren"
 modalUpdateStarted = "Starte die neue Version von evcc ..."
 modalUpdateStatusStart = "Installation gestartet:"
 
+[forecast]
+modalTitle = "Vorhersage"
+
+[forecast.co2]
+average = "Durchschnitt"
+lowestHour = "Sauberste Stunde"
+range = "Bereich"
+
+[forecast.price]
+average = "Durchschnitt"
+lowestHour = "Günstigste Stunde"
+range = "Bereich"
+
+[forecast.solar]
+dayAfterTomorrow = "Übermorgen"
+partly = "teilweise"
+remaining = "verbleibend"
+today = "Heute"
+tomorrow = "Morgen"
+
+[forecast.type]
+co2 = "CO₂"
+price = "Preis"
+solar = "Solar"
+
 [header]
 about = "Über"
 blog = "Blog"
@@ -524,6 +549,7 @@ batteryGridChargeActive = "Netzladen aktiv"
 batteryGridChargeLimit = "Netzladen wenn"
 batteryHold = "Batterie (gesperrt)"
 batteryTooltip = "{energy} von {total} ({soc})"
+forecastTooltip = "Vorhersage: erwartete Sonnenenergie heute"
 gridImport = "Netzbezug"
 homePower = "Verbrauch"
 loadpoints = "Ladepunkt | Ladepunkt | {count} Ladepunkte"
diff --git a/i18n/en.toml b/i18n/en.toml
index c25e3279..052f24b3 100644
--- a/i18n/en.toml
+++ b/i18n/en.toml
@@ -433,6 +433,31 @@ modalUpdateNow = "Install now"
 modalUpdateStarted = "Starting the new version of evcc…"
 modalUpdateStatusStart = "Installation started:"
 
+[forecast]
+modalTitle = "Forecast"
+
+[forecast.co2]
+average = "Average"
+lowestHour = "Cleanest hour"
+range = "Range"
+
+[forecast.price]
+average = "Average"
+lowestHour = "Cheapest hour"
+range = "Range"
+
+[forecast.solar]
+dayAfterTomorrow = "Day after tomorrow"
+partly = "partly"
+remaining = "remaining"
+today = "Today"
+tomorrow = "Tomorrow"
+
+[forecast.type]
+co2 = "CO₂"
+price = "Price"
+solar = "Solar"
+
 [header]
 about = "About"
 blog = "Blog"
@@ -523,6 +548,7 @@ batteryGridChargeActive = "grid charging active"
 batteryGridChargeLimit = "grid charging when"
 batteryHold = "Battery (locked)"
 batteryTooltip = "{energy} of {total} ({soc})"
+forecastTooltip = "forecast: remaining solar production today"
 gridImport = "Grid use"
 homePower = "Consumption"
 loadpoints = "Charger| Charger | {count} chargers"
diff --git a/package-lock.json b/package-lock.json
index 6bd4fada..5f6b4eb0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -27,7 +27,10 @@
         "bootstrap": "^5.2.2",
         "canvas-confetti": "^1.4.0",
         "chart.js": "^4.4.4",
+        "chartjs-adapter-dayjs-4": "^1.0.4",
+        "chartjs-plugin-datalabels": "^2.2.0",
         "countup.js": "^2.3.2",
+        "dayjs": "^1.11.13",
         "eslint": "^9.13.0",
         "eslint-config-prettier": "^10.0.1",
         "eslint-plugin-import": "^2.25.3",
@@ -4371,6 +4374,28 @@
         "pnpm": ">=8"
       }
     },
+    "node_modules/chartjs-adapter-dayjs-4": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/chartjs-adapter-dayjs-4/-/chartjs-adapter-dayjs-4-1.0.4.tgz",
+      "integrity": "sha512-yy9BAYW4aNzPVrCWZetbILegTRb7HokhgospPoC3b5iZ5qdlqNmXts2KdSp6AqnjkPAp/YWyHDxLvIvwt5x81w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      },
+      "peerDependencies": {
+        "chart.js": ">=4.0.1",
+        "dayjs": "^1.9.7"
+      }
+    },
+    "node_modules/chartjs-plugin-datalabels": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/chartjs-plugin-datalabels/-/chartjs-plugin-datalabels-2.2.0.tgz",
+      "integrity": "sha512-14ZU30lH7n89oq+A4bWaJPnAG8a7ZTk7dKf48YAzMvJjQtjrgg5Dpk9f+LbjCF6bpx3RAGTeL13IXpKQYyRvlw==",
+      "license": "MIT",
+      "peerDependencies": {
+        "chart.js": ">=3.0.0"
+      }
+    },
     "node_modules/check-error": {
       "version": "2.1.1",
       "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz",
@@ -4718,6 +4743,12 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/dayjs": {
+      "version": "1.11.13",
+      "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
+      "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==",
+      "license": "MIT"
+    },
     "node_modules/debug": {
       "version": "4.4.0",
       "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
diff --git a/package.json b/package.json
index c2ddf0d6..f3a325ab 100644
--- a/package.json
+++ b/package.json
@@ -34,7 +34,10 @@
     "bootstrap": "^5.2.2",
     "canvas-confetti": "^1.4.0",
     "chart.js": "^4.4.4",
+    "chartjs-adapter-dayjs-4": "^1.0.4",
+    "chartjs-plugin-datalabels": "^2.2.0",
     "countup.js": "^2.3.2",
+    "dayjs": "^1.11.13",
     "eslint": "^9.13.0",
     "eslint-config-prettier": "^10.0.1",
     "eslint-plugin-import": "^2.25.3",
diff --git a/packaging/docker/bin/entrypoint.sh b/packaging/docker/bin/entrypoint.sh
index 6ea4936b..29a6f627 100755
--- a/packaging/docker/bin/entrypoint.sh
+++ b/packaging/docker/bin/entrypoint.sh
@@ -6,10 +6,31 @@ HASSIO_OPTIONSFILE=/data/options.json
 
 if [ -f ${HASSIO_OPTIONSFILE} ]; then
 	CONFIG=$(grep -o '"config_file": "[^"]*' ${HASSIO_OPTIONSFILE} | grep -o '[^"]*$')
-	echo "Using config file: ${CONFIG}"
-
 	SQLITE_FILE=$(grep -o '"sqlite_file": "[^"]*' ${HASSIO_OPTIONSFILE} | grep -o '[^"]*$')
 
+	# Config File Migration
+	# If there is no config file found in '/config' we copy it from '/homeassistant' and rename the old config file to .migrated
+	if [ ! -f "${CONFIG}" ]; then
+		CONFIG_OLD=$(echo "${CONFIG}" | sed 's#^/config#/homeassistant#')
+		if [ -f "${CONFIG_OLD}" ]; then
+			mkdir -p "$(dirname "${CONFIG}")" && cp "${CONFIG_OLD}" "${CONFIG}"
+			mv "${CONFIG_OLD}" "${CONFIG_OLD}.migrated"
+			echo "Moving old config file '${CONFIG_OLD}' to new location '${CONFIG}', appending '.migrated' to old config file! Old file can safely be deleted by user."
+		fi
+	fi
+
+	# Database File Migration (optional, in case it is in /config)
+	# Only in case the user put her DB into the '/config' folder instead of default '/data' we will migrate it aswell
+	if [ "${SQLITE_FILE#/config}" != "${SQLITE_FILE}" ] && [ ! -f "${SQLITE_FILE}" ]; then
+		SQLITE_FILE_OLD=$(echo "${SQLITE_FILE}" | sed 's#^/config#/homeassistant#')
+		if [ -f "${SQLITE_FILE_OLD}" ]; then
+			mkdir -p "$(dirname "${SQLITE_FILE}")" && cp "${SQLITE_FILE_OLD}" "${SQLITE_FILE}"
+			mv "${SQLITE_FILE_OLD}" "${SQLITE_FILE_OLD}.migrated"
+			echo "Moving old db file '${SQLITE_FILE_OLD}' to new location '${SQLITE_FILE}', appending '.migrated' to old db file! Old file can safely be deleted by user."
+		fi
+	fi
+
+	echo "Using config file: ${CONFIG}"
 	if [ ! -f "${CONFIG}" ]; then
 		echo "Config not found. Please create a config under ${CONFIG}."
 		echo "For details see evcc documentation at https://github.com/evcc-io/evcc#readme."
diff --git a/templates/definition/meter/saj-h1.yaml b/templates/definition/meter/saj-h1.yaml
new file mode 100644
index 00000000..1dbd027e
--- /dev/null
+++ b/templates/definition/meter/saj-h1.yaml
@@ -0,0 +1,168 @@
+template: saj-h1
+products:
+  - brand: SAJ
+    description:
+      generic: H1 Series Hybrid Solar Inverter
+params:
+  - name: usage
+    choice: ["grid", "pv", "battery"]
+  - name: modbus
+    choice: ["tcpip"]
+  - name: timeout
+    default: 5s
+  - name: capacity
+    advanced: true
+render: |
+  type: custom
+  {{- if eq .usage "grid" }}
+  power:
+    source: modbus
+    {{- include "modbus" . | indent 2 }}
+    timeout: {{ .timeout }}
+    register:
+      address: 0x40A1 # SmartMeterTotalGridPowerWatt (undocumented)
+      type: holding
+      decode: int16
+  energy:
+    source: modbus
+    {{- include "modbus" . | indent 2 }}
+    timeout: {{ .timeout }}
+    register:
+      address: 0x40FD # Total_FeedInEnergy
+      type: holding
+      decode: uint32
+    scale: 0.01
+  {{- end }}
+  {{- if eq .usage "pv" }}
+  power:
+    source: modbus
+    {{- include "modbus" . | indent 2 }}
+    timeout: {{ .timeout }}
+    register:
+      address: 0x40A5 # TotalPVPower
+      type: holding
+      decode: int16
+  energy:
+    source: modbus
+    {{- include "modbus" . | indent 2 }}
+    timeout: {{ .timeout }}
+    register:
+      address: 0x40C5 # Total PVEnergy
+      type: holding
+      decode: uint32
+    scale: 0.01
+  {{- end }}
+  {{- if eq .usage "battery" }}
+  power:
+    source: modbus
+    {{- include "modbus" . | indent 2 }}
+    timeout: {{ .timeout }}
+    register:
+      address: 0x40A6 # TotalBatteryPower
+      type: holding
+      decode: int16
+  energy:
+    source: modbus
+    {{- include "modbus" . | indent 2 }}
+    timeout: {{ .timeout }}
+    register:
+      address: 0x40D5 # Total BatDisEnergy
+      type: holding
+      decode: uint32
+    scale: 0.01
+  soc:
+    source: modbus
+    {{- include "modbus" . | indent 2 }}
+    timeout: {{ .timeout }}
+    register:
+      address: 0x406F # BatEnergyPercent
+      type: holding
+      decode: uint16
+    scale: 0.01
+  batterymode:
+    source: switch
+    switch:
+    - case: 1 # normal
+      set:
+        source: sequence
+        set:
+        - source: const
+          value: 0 # self-use mode
+          set:
+            source: modbus
+            {{- include "modbus" . | indent 10 }}
+            timeout: {{ .timeout }}
+            register:
+              address: 0x3247 # AppMode
+              type: writeholding
+              decode: uint16
+    - case: 2 # hold
+      set:
+        source: sequence
+        set:
+        - source: const
+          value: 3 # passive mode
+          set:
+            source: modbus
+            {{- include "modbus" . | indent 10 }}
+            timeout: {{ .timeout }}
+            register:
+              address: 0x3247 # AppMode
+              type: writeholding
+              decode: uint16
+    - case: 3 # charge
+      set:
+        source: sequence
+        set:
+        - source: const
+          value: 1 # time-of-use mode
+          set:
+            source: modbus
+            {{- include "modbus" . | indent 10 }}
+            timeout: {{ .timeout }}
+            register:
+              address: 0x3247 # AppMode
+              type: writeholding
+              decode: uint16
+        - source: const
+          value: 1 # enable
+          set:
+            source: modbus
+            {{- include "modbus" . | indent 10 }}
+            timeout: {{ .timeout }}
+            register:
+              address: 0x3604 # Charge time enable control
+              type: writeholding
+              decode: uint16
+        - source: const
+          value: 0 # start time (00:00)
+          set:
+            source: modbus
+            {{- include "modbus" . | indent 10 }}
+            timeout: {{ .timeout }}
+            register:
+              address: 0x3606 # Battery first charging time (start)
+              type: writeholding
+              decode: uint16
+        - source: const
+          value: 0x173B # end time (23:59)
+          set:
+            source: modbus
+            {{- include "modbus" . | indent 10 }}
+            timeout: {{ .timeout }}
+            register:
+              address: 0x3607 # Battery first charging time (end)
+              type: writeholding
+              decode: uint16
+        - source: const
+          value: 0x7F64 # every day (0x7f) at 100% (0x64)
+          set:
+            source: modbus
+            {{- include "modbus" . | indent 10 }}
+            timeout: {{ .timeout }}
+            register:
+              address: 0x3608 # Battery first charging time (power)
+              type: writeholding
+              decode: uint16
+  capacity: {{ .capacity }} # kWh
+  {{- end }}
diff --git a/templates/definition/tariff/forecast-solar.yaml b/templates/definition/tariff/forecast-solar.yaml
index 79dea0a3..6a3dbf60 100644
--- a/templates/definition/tariff/forecast-solar.yaml
+++ b/templates/definition/tariff/forecast-solar.yaml
@@ -40,10 +40,19 @@ params:
     required: true
   - name: kwp
     description:
-      en: Maximum power (kWp)
-      de: Maximalleistung (kWp)
+      en: Maximum generator power [kWp]
+      de: Maximale Generatorleistung [kWp]
     example: 9.8
     required: true
+  - name: horizon
+    description:
+      en: Horizon
+      de: Horizont
+    help:
+      en: Simulates terrain shadows, [more information](https://doc.forecast.solar/horizon)
+      de: Simuliert Verschattung durch Gelände, [mehr Informationen](https://doc.forecast.solar/horizon)
+    example: 0,0,15,30,45,60,60,60,45,30,15,0
+    advanced: true
   - name: apikey
     description:
       en: API key
@@ -54,9 +63,9 @@ render: |
   tariff: solar
   forecast:
     source: http
-    uri: https://api.forecast.solar/{{ if .apikey }}{{ .apikey }}/{{ end }}estimate/{{ .lat }}/{{ .lon }}/{{ .dec }}/{{ .az }}/{{ .kwp }}?time=utc&full=1&resolution=60
+    uri: https://api.forecast.solar/{{ if .apikey }}{{ .apikey }}/{{ end }}estimate/{{ .lat }}/{{ .lon }}/{{ .dec }}/{{ .az }}/{{ .kwp }}?time=utc&full=1&resolution=60{{ if .horizon }}&horizon={{ .horizon }}{{ end }}
     jq: |
-      [ .result.watt_hours_period | to_entries.[] | {
+      [ .result.watt | to_entries.[] | {
         "start": (.key | strptime("%FT%T%z") | strftime("%FT%TZ")),
         "end":   (.key | strptime("%FT%T%z") | mktime+3600 | strftime("%FT%TZ")),
         "price": .value


Test patch:
diff --git a/assets/js/utils/forecast.test.ts b/assets/js/utils/forecast.test.ts
new file mode 100644
index 00000000..4930712a
--- /dev/null
+++ b/assets/js/utils/forecast.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, test, vi } from "vitest";
+import { aggregateEnergy, type PriceSlot } from "./forecast";
+
+const slots: PriceSlot[] = [
+	{ start: "2050-03-20T10:00:00+01:00", end: "2050-03-20T10:30:00+01:00", price: 2000 }, // 1kWh
+	{ start: "2050-03-20T10:30:00+01:00", end: "2050-03-20T11:00:00+01:00", price: 4000 }, // 2kWh
+	{ start: "2050-03-20T11:00:00+01:00", end: "2050-03-20T11:30:00+01:00", price: 3000 }, // 1.5kWh
+	{ start: "2050-03-20T11:30:00+01:00", end: "2050-03-20T12:00:00+01:00", price: 2000 }, // 1kWh
+	{ start: "2050-03-20T12:00:00+01:00", end: "2050-03-20T12:30:00+01:00", price: 0 }, // 0kWh
+	{ start: "2050-03-20T12:30:00+01:00", end: "2050-03-20T13:00:00+01:00", price: 0 }, // 0kWh
+];
+
+describe("aggregateEnergy", () => {
+	test("aggregates energy from power values for 30min slots", () => {
+		const result = aggregateEnergy(slots);
+		expect(result).toBeCloseTo(5500);
+	});
+	test("ignore slots in the past", () => {
+		// will ignore the first two slots
+		vi.setSystemTime("2050-03-20T11:00:00+01:00");
+		const result = aggregateEnergy(slots, true);
+		expect(result).toBeCloseTo(2500);
+	});
+	test("correctly split in-between slots", () => {
+		// start in the middle of the 4th slot
+		vi.setSystemTime("2050-03-20T11:45:00+01:00");
+		const result = aggregateEnergy(slots, true);
+		expect(result).toBeCloseTo(500);
+	});
+});