Add initial forecast UI (#18709)

This commit is contained in:
Michael Geers 2025-02-16 16:14:42 +01:00 • committed by GitHub
parent 9606299c92
commit 629a969c9f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1275 additions and 18 deletions

View file

@ -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);
}

View file

@ -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;

View file

@ -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));
},

View file

@ -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);
},
},
};

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -40,6 +40,7 @@ export const commonOptions = {
maintainAspectRatio: false,
plugins: {
legend: { display: false },
datalabels: { display: false },
tooltip: {
backgroundColor: "#000000cc",
boxPadding: 5,

View file

@ -131,6 +131,7 @@ export default {
sponsor: { type: Object, default: () => ({}) },
smartCostType: String,
fatal: Object,
forecast: Object,
},
computed: {
batteryConfigured: function () {

View file

@ -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" });
},

View file

@ -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",

View file

@ -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"),

View file

@ -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);
});
});

View file

@ -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);
}

View file

@ -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) {

111
assets/js/views/Energy.vue Normal file
View file

@ -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>

View file

@ -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"

View file

@ -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"

31
package-lock.json generated
View file

@ -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",

View file

@ -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",