Repository: evcc-io/evcc
Pull request #5883 (2023-01-22 21:26:43)

Sessions: export csv by month
Durch Klick auf "CSV" wird eine CSV Datei mit den Einträgen des Monats bereitgestellt.

<img width="1323" alt="image" src="https://user-images.githubusercontent.com/8715591/213941340-15b1da84-a5da-44d5-8919-a4bbd5d99c0f.png">


andig: Ich hätte noch einen alternativen Vorschlag. Wie wäre es denn, Monate und Fahrzeuge als "Filter" zu enablen und einen Export Button anzubieten, der jeweils den aktuellen Filterstand exportiert? Damit könnte man z.B. nur das Dienstfahrzeug für den Arbeitgeber exportieren?
CTeipen: Das ist abgesehen vom Fahrzeug exakt die erste Implementierung die ich umgesetzt hatte. Nach Absprache mit @naltatis habe ich dann das Feature klein gehalten und eine Filterung auf später mit mehr Konzeption verlegt.

Patch:
diff --git a/.github/workflows/default.yml b/.github/workflows/default.yml
index 1bdf59ed..2c720da9 100644
--- a/.github/workflows/default.yml
+++ b/.github/workflows/default.yml
@@ -125,8 +125,8 @@ jobs:
         run: make ui
 
       - name: Cache dist
-        uses: actions/cache@v3
+        uses: actions/cache/save@v3
         id: cache-dist
         with:
           path: dist
-          key: ${{ runner.os }}-dist
+          key: ${{ runner.os }}-${{ github.sha }}-dist
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index 025170b5..2d359c64 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -44,11 +44,11 @@ jobs:
           fetch-depth: 0
 
       - name: Get dist from cache
-        uses: actions/cache@v3
+        uses: actions/cache/restore@v3
         id: cache-dist
         with:
           path: dist
-          key: ${{ runner.os }}-dist
+          key: ${{ runner.os }}-${{ github.sha }}-dist
 
       - name: Login
         uses: docker/login-action@v2
@@ -92,11 +92,11 @@ jobs:
         run: make patch-asn1-sudo
 
       - name: Get dist from cache
-        uses: actions/cache@v3
+        uses: actions/cache/restore@v3
         id: cache-dist
         with:
           path: dist
-          key: ${{ runner.os }}-dist
+          key: ${{ runner.os }}-${{ github.sha }}-dist
 
       - name: Create nightly build
         uses: goreleaser/goreleaser-action@v4
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 1b6c5db5..7f038d87 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -71,11 +71,11 @@ jobs:
         run: make patch-asn1-sudo
 
       - name: Get dist from cache
-        uses: actions/cache@v3
+        uses: actions/cache/restore@v3
         id: cache-dist
         with:
           path: dist
-          key: ${{ runner.os }}-dist
+          key: ${{ runner.os }}-${{ github.sha }}-dist
 
       # gokrazy image
       # - name: Prepare Image
diff --git a/Dockerfile b/Dockerfile
index 8c55da08..b3780f92 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,5 @@
 # # STEP 1 build ui
-# FROM --platform=$BUILDPLATFORM node:16-alpine as node
+# FROM --platform=$BUILDPLATFORM node:18-alpine as node
 
 # RUN apk update && apk add --no-cache make alpine-sdk
 
diff --git a/assets/css/app.css b/assets/css/app.css
index 031cc49e..5d830fb3 100644
--- a/assets/css/app.css
+++ b/assets/css/app.css
@@ -183,6 +183,14 @@ a:hover {
 	border-color: var(--evcc-accent3) !important;
 }
 
+.btn-xs {
+	--bs-btn-padding-y: 0;
+	--bs-btn-padding-x: 0.5rem;
+	--bs-btn-font-size: 0.7rem;
+	--bs-btn-border-radius: 0.25rem;
+	--bs-btn-line-height: 2;
+}
+
 .text-evcc {
 	color: var(--evcc-dark-green);
 }
@@ -347,3 +355,8 @@ small {
 .dark .form-switch .form-check-input:checked {
 	background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%2328293e'/%3e%3c/svg%3e");
 }
+/* fix desktop safari formatting */
+input::-webkit-datetime-edit {
+	display: block;
+	padding: 0;
+}
diff --git a/assets/js/components/Energyflow/Energyflow.vue b/assets/js/components/Energyflow/Energyflow.vue
index a7fc58ed..7a263659 100644
--- a/assets/js/components/Energyflow/Energyflow.vue
+++ b/assets/js/components/Energyflow/Energyflow.vue
@@ -128,6 +128,7 @@ import Visualization from "./Visualization.vue";
 import EnergyflowEntry from "./EnergyflowEntry.vue";
 import formatter from "../../mixins/formatter";
 import AnimatedNumber from "../AnimatedNumber.vue";
+import settings from "../../settings";
 
 export default {
 	name: "Energyflow",
@@ -189,6 +190,10 @@ export default {
 	},
 	mounted() {
 		window.addEventListener("resize", this.updateHeight);
+		// height must be calculated in case of initially open details
+		if (settings.energyflowDetails) {
+			setTimeout(this.toggleDetails, 50);
+		}
 	},
 	unmounted() {
 		window.removeEventListener("resize", this.updateHeight);
@@ -200,6 +205,7 @@ export default {
 		toggleDetails: function () {
 			this.updateHeight();
 			this.detailsOpen = !this.detailsOpen;
+			settings.energyflowDetails = this.detailsOpen;
 		},
 		updateHeight: function () {
 			this.detailsCompleteHeight = this.$refs.detailsInner.offsetHeight;
diff --git a/assets/js/components/Savings.vue b/assets/js/components/Savings.vue
index e2ceaae8..e6165014 100644
--- a/assets/js/components/Savings.vue
+++ b/assets/js/components/Savings.vue
@@ -123,7 +123,7 @@
 									<small>
 										{{
 											$t("footer.savings.since", {
-												since: fmtDayMonthYear(startDate),
+												since: startDate,
 											})
 										}}
 									</small>
@@ -157,7 +157,7 @@ export default {
 	mixins: [formatter],
 	props: {
 		selfConsumptionPercent: Number,
-		since: { type: Number, default: 0 },
+		since: String,
 		sponsor: String,
 		amount: { type: Number, default: 0 },
 		effectivePrice: { type: Number, default: 0 },
@@ -182,7 +182,10 @@ export default {
 			return { value, unit };
 		},
 		startDate() {
-			return new Date(this.since * 1000);
+			if (this.since) {
+				return this.fmtDayMonthYear(new Date(this.since));
+			}
+			return "";
 		},
 	},
 	methods: {
diff --git a/assets/js/components/Site.vue b/assets/js/components/Site.vue
index abb8fa56..5f54a1e3 100644
--- a/assets/js/components/Site.vue
+++ b/assets/js/components/Site.vue
@@ -75,7 +75,7 @@ export default {
 		savingsGridCharged: Number,
 		savingsSelfConsumptionCharged: Number,
 		savingsSelfConsumptionPercent: Number,
-		savingsSince: Number,
+		savingsSince: String,
 		savingsTotalCharged: Number,
 		tariffFeedIn: Number,
 		tariffGrid: Number,
diff --git a/assets/js/components/TargetCharge.vue b/assets/js/components/TargetCharge.vue
index c761b61e..7a58721b 100644
--- a/assets/js/components/TargetCharge.vue
+++ b/assets/js/components/TargetCharge.vue
@@ -19,7 +19,8 @@
 		<Teleport to="body">
 			<div
 				:id="modalId"
-				class="modal fade text-dark"
+				ref="modal"
+				class="modal fade text-dark modal-xl"
 				data-bs-backdrop="true"
 				tabindex="-1"
 				role="dialog"
@@ -43,9 +44,11 @@
 						</div>
 						<form @submit.prevent="setTargetTime">
 							<div class="modal-body">
-								<div class="form-group mb-2">
+								<div
+									class="form-group d-lg-flex align-items-baseline mb-2 justify-content-between"
+								>
 									<!-- eslint-disable vue/no-v-html -->
-									<label for="targetTimeLabel" class="mb-3">
+									<label for="targetTimeLabel" class="mb-3 me-3">
 										<span v-if="socBasedCharging">
 											{{
 												$t("main.targetCharge.descriptionSoc", {
@@ -62,15 +65,8 @@
 										</span>
 									</label>
 									<!-- eslint-enable vue/no-v-html -->
-									<div
-										class="d-flex justify-content-between"
-										:style="{ 'max-width': '350px' }"
-									>
-										<select
-											v-model="selectedDay"
-											class="form-select me-2"
-											:style="{ 'flex-basis': '60%' }"
-										>
+									<div class="d-flex justify-content-between date-selection">
+										<select v-model="selectedDay" class="form-select me-2">
 											<option
 												v-for="opt in dayOptions()"
 												:key="opt.value"
@@ -82,17 +78,25 @@
 										<input
 											v-model="selectedTime"
 											type="time"
-											class="form-control ms-2"
-											:style="{ 'flex-basis': '40%' }"
+											class="form-control ms-2 time-selection"
 											:step="60 * 5"
 											required
 										/>
 									</div>
 								</div>
-								<p v-if="!selectedTargetTimeValid" class="text-danger mb-0">
-									{{ $t("main.targetCharge.targetIsInThePast") }}
+								<p class="mb-0">
+									<span v-if="timeInThePast" class="text-danger">
+										{{ $t("main.targetCharge.targetIsInThePast") }}
+									</span>
+									<span v-else-if="timeTooFarInTheFuture" class="text-secondary">
+										{{ $t("main.targetCharge.targetIsTooFarInTheFuture") }}
+									</span>
+									&nbsp;
 								</p>
-								<TargetChargePlanMinimal v-else-if="plan.duration" v-bind="plan" />
+								<TargetChargePlan
+									v-if="targetChargePlanProps"
+									v-bind="targetChargePlanProps"
+								/>
 							</div>
 							<div class="modal-footer d-flex justify-content-between">
 								<button
@@ -108,9 +112,14 @@
 									type="submit"
 									class="btn btn-primary"
 									data-bs-dismiss="modal"
-									:disabled="!selectedTargetTimeValid"
+									:disabled="timeInThePast"
 								>
-									{{ $t("main.targetCharge.activate") }}
+									<span v-if="targetTime">
+										{{ $t("main.targetCharge.update") }}
+									</span>
+									<span v-else>
+										{{ $t("main.targetCharge.activate") }}
+									</span>
 								</button>
 							</div>
 						</form>
@@ -126,7 +135,7 @@ import Modal from "bootstrap/js/dist/modal";
 import "@h2d2/shopicons/es/filled/plus";
 import "@h2d2/shopicons/es/filled/edit";
 import LabelAndValue from "./LabelAndValue.vue";
-import TargetChargePlanMinimal from "./TargetChargePlanMinimal.vue";
+import TargetChargePlan from "./TargetChargePlan.vue";
 import api from "../api";
 
 import formatter from "../mixins/formatter";
@@ -136,7 +145,7 @@ const LAST_TARGET_TIME_KEY = "last_target_time";
 
 export default {
 	name: "TargetCharge",
-	components: { LabelAndValue, TargetChargePlanMinimal },
+	components: { LabelAndValue, TargetChargePlan },
 	mixins: [formatter],
 	props: {
 		id: [String, Number],
@@ -149,15 +158,32 @@ export default {
 	},
 	emits: ["target-time-updated", "target-time-removed"],
 	data: function () {
-		return { selectedDay: null, selectedTime: null, plan: {} };
+		return {
+			selectedDay: null,
+			selectedTime: null,
+			plan: {},
+			tariff: {},
+			modal: null,
+			isModalVisible: false,
+		};
 	},
 	computed: {
 		targetChargeEnabled: function () {
 			return this.targetTime;
 		},
-		selectedTargetTimeValid: function () {
+		timeInThePast: function () {
 			const now = new Date();
-			return now < this.selectedTargetTime;
+			return now >= this.selectedTargetTime;
+		},
+		timeTooFarInTheFuture: function () {
+			if (this.tariff?.rates) {
+				const lastRate = this.tariff.rates[this.tariff.rates.length - 1];
+				if (lastRate.end) {
+					const end = new Date(lastRate.end);
+					return this.selectedTargetTime >= end;
+				}
+			}
+			return false;
 		},
 		selectedTargetTime: function () {
 			return new Date(`${this.selectedDay}T${this.selectedTime || "00:00"}`);
@@ -168,6 +194,12 @@ export default {
 		targetEnergyFormatted: function () {
 			return this.fmtKWh(this.targetEnergy * 1e3, true, true, 1);
 		},
+		targetChargePlanProps: function () {
+			const targetTime = this.selectedTargetTime;
+			const { rates } = this.tariff;
+			const { duration, unit, plan } = this.plan;
+			return rates ? { duration, rates, plan, unit, targetTime } : null;
+		},
 	},
 	watch: {
 		targetTimeLabel: function () {
@@ -189,15 +221,40 @@ export default {
 		targetEnergy() {
 			this.updatePlan();
 		},
+		isModalVisible() {
+			this.updatePlan();
+		},
+	},
+	mounted() {
+		this.modal = Modal.getOrCreateInstance(this.$refs.modal);
+		this.$refs.modal.addEventListener("show.bs.modal", this.modalVisible);
+		this.$refs.modal.addEventListener("hide.bs.modal", this.modalInvisible);
+	},
+	unmounted() {
+		this.$refs.modal.removeEventListener("show.bs.modal", this.modalVisible);
+		this.$refs.modal.removeEventListener("hide.bs.modal", this.modalInvisible);
 	},
 	methods: {
+		modalVisible: function () {
+			this.isModalVisible = true;
+		},
+		modalInvisible: function () {
+			this.isModalVisible = false;
+		},
 		updatePlan: async function () {
-			if (this.selectedTargetTimeValid && (this.targetEnergy || this.targetSoc)) {
+			if (
+				this.isModalVisible &&
+				!this.timeInThePast &&
+				(this.targetEnergy || this.targetSoc)
+			) {
 				try {
-					const response = await api.get(`/loadpoints/${this.id}/target/plan`, {
+					const opts = {
 						params: { targetTime: this.selectedTargetTime },
-					});
-					this.plan = response.data.result;
+					};
+					this.plan = (
+						await api.get(`/loadpoints/${this.id}/target/plan`, opts)
+					).data.result;
+					this.tariff = (await api.get(`/tariff/planner`)).data.result;
 				} catch (e) {
 					console.error(e);
 				}
@@ -275,9 +332,8 @@ export default {
 			this.$emit("target-time-removed");
 		},
 		openModal() {
-			const modal = Modal.getOrCreateInstance(document.getElementById(this.modalId));
-			this.initInputFields();
-			modal.show();
+			this.modal.show();
+			this.$nextTick(this.initInputFields);
 		},
 	},
 };
@@ -295,4 +351,12 @@ export default {
 .value:hover {
 	color: var(--bs-color-white);
 }
+@media (min-width: 992px) {
+	.date-selection {
+		width: 370px;
+	}
+}
+.time-selection {
+	flex-basis: 200px;
+}
 </style>
diff --git a/assets/js/components/TargetChargePlan.story.vue b/assets/js/components/TargetChargePlan.story.vue
new file mode 100644
index 00000000..d6aeba97
--- /dev/null
+++ b/assets/js/components/TargetChargePlan.story.vue
@@ -0,0 +1,73 @@
+<script setup>
+import TargetChargePlan from "./TargetChargePlan.vue";
+
+const now = new Date();
+
+function createDate(hoursFromNow) {
+	const result = new Date(now.getTime());
+	result.setHours(result.getHours() + hoursFromNow);
+	return result;
+}
+
+function createRate(price, hoursFromNow, durationHours = 1) {
+	const start = new Date(now.getTime());
+	start.setHours(start.getHours() + hoursFromNow);
+	start.setMinutes(0);
+	start.setSeconds(0);
+	start.setMilliseconds(0);
+	const end = new Date(start.getTime());
+	end.setHours(start.getHours() + durationHours);
+	end.setMinutes(0);
+	end.setSeconds(0);
+	end.setMilliseconds(0);
+	return { start: start.toISOString(), end: end.toISOString(), price };
+}
+
+const co2 = {
+	rates: [
+		545, 518, 545, 518, 213, 545, 527, 527, 536, 518, 400, 336, 336, 339, 344, 336, 336, 336,
+		372, 400, 555, 555, 545, 555, 564, 545, 555, 545, 536, 545, 527, 536, 518, 545, 509, 336,
+		336, 336,
+	].map((price, i) => createRate(price, i)),
+	duration: 8695,
+	plan: [createRate(213, 4), createRate(336, 11), createRate(336, 12)],
+	unit: "gCO2eq",
+	targetTime: createDate(14),
+};
+
+const fixed = {
+	rates: [createRate(0.442, 0, 50)],
+	duration: 8695,
+	plan: [createRate(0.442, 12, 3)],
+	unit: "EUR",
+	targetTime: createDate(14),
+};
+
+const zoned = {
+	rates: [
+		createRate(3.72, 0, 4),
+		createRate(2.39, 4, 12),
+		createRate(3.72, 16, 12),
+		createRate(2.39, 28, 12),
+		createRate(3.72, 40, 12),
+	],
+	duration: 8695,
+	plan: [createRate(2.39, 13, 3)],
+	unit: "DKK",
+	targetTime: createDate(17),
+};
+</script>
+
+<template>
+	<Story title="TargetChargePlan">
+		<Variant title="co2">
+			<TargetChargePlan v-bind="co2" />
+		</Variant>
+		<Variant title="fixed">
+			<TargetChargePlan v-bind="fixed" />
+		</Variant>
+		<Variant title="zoned">
+			<TargetChargePlan v-bind="zoned" />
+		</Variant>
+	</Story>
+</template>
diff --git a/assets/js/components/TargetChargePlan.vue b/assets/js/components/TargetChargePlan.vue
new file mode 100644
index 00000000..7de39a2e
--- /dev/null
+++ b/assets/js/components/TargetChargePlan.vue
@@ -0,0 +1,283 @@
+<template>
+	<div class="plan pt-2">
+		<div class="details justify-content-between mb-2 d-flex justify-content-between">
+			<div class="text-start">
+				<div class="label">{{ $t("main.targetChargePlan.chargeDuration") }}</div>
+				<div class="value text-primary">{{ planDuration }}</div>
+			</div>
+			<div class="text-end">
+				<div class="label">
+					<span v-if="activeSlot">{{ activeSlotName }}</span>
+					<span v-else-if="isCo2">{{ $t("main.targetChargePlan.co2Label") }}</span>
+					<span v-else>{{ $t("main.targetChargePlan.priceLabel") }}</span>
+				</div>
+				<div class="value text-primary">
+					{{ fmtAvgPrice }}
+				</div>
+			</div>
+		</div>
+		<div class="chart">
+			<div
+				v-for="(slot, index) in slots"
+				:key="slot.start"
+				:data-index="index"
+				class="slot user-select-none"
+				:class="{ active: isActive(index), toLate: slot.toLate }"
+				@touchstart="activeIndex = index"
+				@mouseenter="activeIndex = index"
+				@touchmove="touchmove"
+				@touchend="activeIndex = null"
+				@mouseleave="activeIndex = null"
+				@mouseup="activeIndex = null"
+			>
+				<div class="slot-bar" :style="priceStyle(slot.price)">
+					<span v-if="slot.price === undefined" class="unknown">?</span>
+				</div>
+				<div class="slot-label">
+					{{ slot.startHour }}
+					<br />
+					<span v-if="slot.startHour === 0">{{ slot.day }}</span>
+				</div>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script>
+import formatter from "../mixins/formatter";
+import "@h2d2/shopicons/es/regular/eco1";
+import "@h2d2/shopicons/es/regular/clock";
+
+export default {
+	name: "TargetChargePlan",
+	mixins: [formatter],
+	props: {
+		duration: Number,
+		rates: Array,
+		plan: Array,
+		unit: String,
+		targetTime: Date,
+	},
+	data() {
+		return { activeIndex: null, startTime: new Date() };
+	},
+	computed: {
+		planDuration() {
+			return this.fmtShortDuration(this.duration, true);
+		},
+		maxPrice() {
+			let result = 0;
+			this.slots
+				.map((s) => s.price)
+				.filter((price) => price !== undefined)
+				.forEach((price) => {
+					result = Math.max(result, price);
+				});
+			return result;
+		},
+		isCo2() {
+			return this.unit === "gCO2eq";
+		},
+		avgPrice() {
+			let hourSum = 0;
+			let priceSum = 0;
+			this.convertDates(this.plan).forEach((slot) => {
+				const hours = (slot.end.getTime() - slot.start.getTime()) / 36e5;
+				if (slot.price) {
+					hourSum += hours;
+					priceSum += hours * slot.price;
+				}
+			});
+			return hourSum ? priceSum / hourSum : undefined;
+		},
+		fmtAvgPrice() {
+			let price = this.activeSlot ? this.activeSlot.price : this.avgPrice;
+			if (price === undefined) {
+				return this.$t("main.targetChargePlan.unknownPrice");
+			}
+			return this.isCo2
+				? `${Math.round(price)} g/kWh`
+				: this.fmtPricePerKWh(price, this.unit);
+		},
+		activeSlot() {
+			return this.slots[this.activeIndex];
+		},
+		activeSlotName() {
+			if (this.activeSlot) {
+				const { day, startHour, endHour } = this.activeSlot;
+				const range = `${startHour}–${endHour}`;
+				return this.$t("main.targetChargePlan.timeRange", { day, range });
+			}
+			return null;
+		},
+		slots() {
+			const result = [];
+
+			const rates = this.convertDates(this.rates);
+			const plan = this.convertDates(this.plan);
+
+			const oneHour = 60 * 60 * 1000;
+
+			for (let i = 0; i < 42; i++) {
+				const start = new Date(this.startTime.getTime() + oneHour * i);
+				const startHour = start.getHours();
+				start.setMinutes(0);
+				start.setSeconds(0);
+				start.setMilliseconds(0);
+				const end = new Date(start.getTime());
+				end.setHours(startHour + 1);
+				const endHour = end.getHours();
+				const day = this.weekdayShort(start);
+				const toLate = this.targetTime.getTime() <= start.getTime();
+				// TODO: handle multiple matching time slots
+				const price = this.findSlotInRange(start, end, rates)?.price;
+				const charging = this.findSlotInRange(start, end, plan) != null;
+				result.push({ day, price, startHour, endHour, charging, toLate });
+			}
+
+			return result;
+		},
+	},
+	watch: {
+		rates() {
+			this.startTime = new Date();
+		},
+	},
+	methods: {
+		ratePrice(start, end) {
+			return this.rates?.find((r) => {
+				const rStart = new Date(r.start).getTime();
+				return start.getTime() <= rStart && rStart < end.getTime();
+			}).price;
+		},
+		isActive(index) {
+			return this.activeIndex !== null
+				? this.activeIndex === index
+				: this.slots[index].charging;
+		},
+		priceStyle(price) {
+			return {
+				height: price === undefined ? "100%" : `${(100 / this.maxPrice) * price}%`,
+			};
+		},
+		touchmove(e) {
+			let $el = document.elementFromPoint(e.touches[0].clientX, e.touches[0].clientY);
+			if (!$el) return;
+			if (!$el.classList.contains("slot")) {
+				$el = $el.parent;
+			}
+			if (!$el) return;
+			const index = $el.getAttribute("data-index");
+			if (!index) return;
+			this.activeIndex = index * 1;
+		},
+		convertDates(list) {
+			if (!list?.length) {
+				return [];
+			}
+			return list.map((item) => {
+				return {
+					start: new Date(item.start),
+					end: new Date(item.end),
+					price: item.price,
+				};
+			});
+		},
+		findSlotInRange(start, end, slots) {
+			return slots.find((s) => {
+				if (s.start.getTime() < start.getTime()) {
+					return s.end.getTime() > start.getTime();
+				}
+				return s.start.getTime() < end.getTime();
+			});
+		},
+	},
+};
+</script>
+
+<style scoped>
+.root {
+	overflow: hidden;
+	position: relative;
+}
+.chart {
+	display: flex;
+	height: 140px;
+	align-items: flex-end;
+	overflow-y: none;
+	padding-bottom: 45px;
+}
+.slot {
+	text-align: center;
+	padding: 4px;
+	height: 100%;
+	display: flex;
+	justify-content: flex-end;
+	flex-direction: column;
+	position: relative;
+	opacity: 1;
+}
+@media (max-width: 991px) {
+	.chart {
+		overflow-x: auto;
+	}
+	.slot {
+		width: 20px;
+		flex-grow: 0;
+		flex-shrink: 0;
+	}
+}
+@media (min-width: 992px) {
+	.chart {
+		overflow-x: none;
+		justify-content: stretch;
+	}
+	.slot {
+		flex-grow: 1;
+		flex-shrink: 1;
+		flex-basis: 1;
+	}
+}
+.slot-bar {
+	background-clip: content-box !important;
+	background: var(--bs-gray-light);
+	border-radius: 8px;
+	width: 100%;
+	align-items: center;
+	display: flex;
+	justify-content: center;
+	color: var(--bs-white);
+}
+.slot-label {
+	color: var(--bs-gray-light);
+	line-height: 1.1;
+	position: absolute;
+	top: 100%;
+	left: -50%;
+	width: 200%;
+	text-align: center;
+}
+.slot.active .slot-bar {
+	background: var(--bs-primary);
+}
+.slot.active .slot-label {
+	color: var(--bs-primary);
+}
+.slot.toLate {
+	opacity: 0.33;
+}
+.slot.active {
+	opacity: 1;
+}
+.value {
+	font-size: 18px;
+	font-weight: bold;
+}
+.label {
+	color: var(--evcc-gray);
+	text-transform: uppercase;
+}
+.unknown {
+	margin: 0 -0.5rem;
+}
+</style>
diff --git a/assets/js/mixins/formatter.js b/assets/js/mixins/formatter.js
index f35eb46d..974ba58c 100644
--- a/assets/js/mixins/formatter.js
+++ b/assets/js/mixins/formatter.js
@@ -132,6 +132,11 @@ export default {
         minute: "numeric",
       }).format(date);
     },
+    weekdayShort: function (date) {
+      return new Intl.DateTimeFormat(this.$i18n.locale, {
+        weekday: "short",
+      }).format(date);
+    },
     fmtAbsoluteDate: function (date) {
       const weekday = this.weekdayPrefix(date);
       const hour = new Intl.DateTimeFormat(this.$i18n.locale, {
diff --git a/assets/js/settings.js b/assets/js/settings.js
index db655b16..195676bf 100644
--- a/assets/js/settings.js
+++ b/assets/js/settings.js
@@ -3,6 +3,7 @@ import { reactive, watch } from "vue";
 const SETTINGS_LOCALE = "settings_locale";
 const SETTINGS_THEME = "settings_theme";
 const SETTINGS_UNIT = "settings_unit";
+const SETTINGS_ENERGYFLOW_DETAILS = "settings_energyflow_details";
 
 function read(key) {
   return window.localStorage[key];
@@ -22,15 +23,27 @@ function save(key) {
   };
 }
 
+function readBool(key) {
+  return read(key) === "true";
+}
+
+function saveBool(key) {
+  return (value) => {
+    save(key)(value ? "true" : "false");
+  };
+}
+
 const settings = reactive({
   telemetry: null,
   locale: read(SETTINGS_LOCALE),
   theme: read(SETTINGS_THEME),
   unit: read(SETTINGS_UNIT),
+  energyflowDetails: readBool(SETTINGS_ENERGYFLOW_DETAILS),
 });
 
 watch(() => settings.locale, save(SETTINGS_LOCALE));
 watch(() => settings.theme, save(SETTINGS_THEME));
 watch(() => settings.unit, save(SETTINGS_UNIT));
+watch(() => settings.energyflowDetails, saveBool(SETTINGS_ENERGYFLOW_DETAILS));
 
 export default settings;
diff --git a/assets/js/views/ChargingSessions.vue b/assets/js/views/ChargingSessions.vue
index 28926b1a..86a0d5f5 100644
--- a/assets/js/views/ChargingSessions.vue
+++ b/assets/js/views/ChargingSessions.vue
@@ -12,10 +12,10 @@
 
 		<div class="row">
 			<main class="col-12">
-				<div class="mb-4">
+				<div class="d-flex justify-content-start mb-4">
 					<a
 						class="btn btn-outline-secondary text-nowrap my-2"
-						:href="`./api/sessions?format=csv&amp;lang=${$i18n.locale}`"
+						:href="csvHrefLink()"
 						download="sessions.csv"
 					>
 						{{ $t("sessions.downloadCsv") }}
@@ -23,97 +23,96 @@
 				</div>
 
 				<div v-for="group in sessionsByMonthAndLoadpoint" :key="group.month">
-					<div class="mx-2">
-						<div class="d-flex align-items-baseline my-5">
-							<h2 class="me-4 mb-0">
-								{{ formatGroupHeadline(group.month) }}
-							</h2>
-						</div>
+					<div class="d-flex align-items-center my-5">
+						<h2 class="me-4 mb-0">
+							{{ formatGroupHeadline(group.month) }}
+						</h2>
+						<a
+							class="btn btn-xs btn-outline-secondary text-nowrap"
+							:href="csvHrefLink(group.month)"
+							download="sessions.csv"
+						>
+							CSV
+						</a>
+					</div>
 
-						<div v-for="loadpoint in group.loadpoints" :key="loadpoint.name">
-							<div class="d-flex align-items-baseline mb-3">
-								<h3 class="me-4 mb-0">
-									{{ loadpoint.name }}
-								</h3>
-								<div class="large">{{ fmtKWh(loadpoint.total) }}</div>
-							</div>
+					<div v-for="loadpoint in group.loadpoints" :key="loadpoint.name">
+						<div class="d-flex align-items-baseline mb-3">
+							<h3 class="me-4 mb-0">
+								{{ loadpoint.name }}
+							</h3>
+							<div class="large">{{ fmtKWh(loadpoint.total) }}</div>
+						</div>
 
-							<ul class="breakdown text-gray d-sm-flex flex-sm-wrap ps-0 mb-2">
-								<li
-									v-for="(vehicle, id) in groupedKWh(
-										'vehicle',
-										loadpoint.sessions
-									)"
-									:key="id"
-									class="breakdown-item"
-								>
-									{{ vehicle.name }}: {{ fmtKWh(vehicle.energy) }}
-								</li>
-							</ul>
-							<div class="table-responsive my-3">
-								<table class="table">
-									<thead>
-										<tr>
-											<th scope="col">{{ $t("sessions.vehicle") }}</th>
-											<th scope="col" class="text-end ps-sm-4 pe-md-5">
-												{{ $t("sessions.energy") }}
-											</th>
-											<th scope="col" class="ps-3 ps-md-4 ps-md-5">
-												{{ $t("sessions.date") }}
-											</th>
-										</tr>
-									</thead>
-									<tbody>
-										<tr
-											v-for="(session, id) in loadpoint.sessions"
-											:key="id"
-											role="button"
-											@click="showDetails(session.id)"
+						<ul class="breakdown text-gray d-sm-flex flex-sm-wrap ps-0 mb-2">
+							<li
+								v-for="(vehicle, id) in groupedKWh('vehicle', loadpoint.sessions)"
+								:key="id"
+								class="breakdown-item"
+							>
+								{{ vehicle.name }}: {{ fmtKWh(vehicle.energy) }}
+							</li>
+						</ul>
+						<div class="table-responsive my-3">
+							<table class="table">
+								<thead>
+									<tr>
+										<th scope="col">{{ $t("sessions.vehicle") }}</th>
+										<th scope="col" class="text-end ps-sm-4 pe-md-5">
+											{{ $t("sessions.energy") }}
+										</th>
+										<th scope="col" class="ps-3 ps-md-4 ps-md-5">
+											{{ $t("sessions.date") }}
+										</th>
+									</tr>
+								</thead>
+								<tbody>
+									<tr
+										v-for="(session, id) in loadpoint.sessions"
+										:key="id"
+										role="button"
+										@click="showDetails(session.id)"
+									>
+										<td class="align-middle">
+											{{ session.vehicle }}
+										</td>
+										<td
+											class="text-nowrap text-end ps-sm-4 pe-md-5 align-middle"
 										>
-											<td class="align-middle">
-												{{ session.vehicle }}
-											</td>
-											<td
-												class="text-nowrap text-end ps-sm-4 pe-md-5 align-middle"
-											>
-												{{ fmtKWh(session.chargedEnergy * 1e3) }}
-											</td>
-											<td class="text-nowrap ps-3 ps-md-4 ps-md-5">
-												<span class="d-block d-sm-none">
-													{{
-														fmtFullDateTime(
-															new Date(session.created),
-															true
-														)
-													}}
-													<br />
-													{{
-														fmtFullDateTime(
-															new Date(session.finished),
-															true
-														)
-													}}
-												</span>
-												<span class="d-none d-sm-block">
-													{{
-														fmtFullDateTime(
-															new Date(session.created),
-															false
-														)
-													}}
-													<br />
-													{{
-														fmtFullDateTime(
-															new Date(session.finished),
-															false
-														)
-													}}
-												</span>
-											</td>
-										</tr>
-									</tbody>
-								</table>
-							</div>
+											{{ fmtKWh(session.chargedEnergy * 1e3) }}
+										</td>
+										<td class="text-nowrap ps-3 ps-md-4 ps-md-5">
+											<span class="d-block d-sm-none">
+												{{
+													fmtFullDateTime(new Date(session.created), true)
+												}}
+												<br />
+												{{
+													fmtFullDateTime(
+														new Date(session.finished),
+														true
+													)
+												}}
+											</span>
+											<span class="d-none d-sm-block">
+												{{
+													fmtFullDateTime(
+														new Date(session.created),
+														false
+													)
+												}}
+												<br />
+												{{
+													fmtFullDateTime(
+														new Date(session.finished),
+														false
+													)
+												}}
+											</span>
+										</td>
+									</tr>
+								</tbody>
+							</table>
 						</div>
 					</div>
 				</div>
@@ -131,7 +130,6 @@
 import Modal from "bootstrap/js/dist/modal";
 import TopNavigation from "../components/TopNavigation.vue";
 import "@h2d2/shopicons/es/bold/arrowback";
-import "@h2d2/shopicons/es/regular/trash";
 import formatter from "../mixins/formatter";
 import api from "../api";
 import store from "../store";
@@ -187,7 +185,7 @@ export default {
 		groupByMonth(sessions) {
 			return sessions.reduce((groups, session) => {
 				const date = new Date(session.finished);
-				const month = `${date.getFullYear()}.${date.getMonth()}`;
+				const month = `${date.getFullYear()}.${date.getMonth() + 1}`;
 				if (!groups[month]) groups[month] = [];
 				groups[month].push(session);
 				return groups;
@@ -219,7 +217,7 @@ export default {
 		formatGroupHeadline(group) {
 			const date = new Date();
 			const [year, month] = group.split(".");
-			date.setMonth(month);
+			date.setMonth(month - 1);
 			date.setFullYear(year);
 			return this.fmtMonthYear(date);
 		},
@@ -228,6 +226,18 @@ export default {
 			const modal = Modal.getOrCreateInstance(document.getElementById("sessionDetailsModal"));
 			modal.show();
 		},
+		csvHrefLink(groupKey) {
+			var url = `./api/sessions?format=csv&lang=${this.$i18n.locale}`;
+			if (groupKey) {
+				const [year, month] = groupKey.split(".");
+				url += `&year=${year}`;
+
+				if (month) {
+					url += `&month=${month}`;
+				}
+			}
+			return url;
+		},
 	},
 };
 </script>
diff --git a/charger/eebus.go b/charger/eebus.go
index fa2d918a..a2a1e575 100644
--- a/charger/eebus.go
+++ b/charger/eebus.go
@@ -12,7 +12,6 @@ import (
 	"github.com/evcc-io/evcc/core/loadpoint"
 	"github.com/evcc-io/evcc/server"
 	"github.com/evcc-io/evcc/util"
-	"github.com/evcc-io/evcc/util/request"
 )
 
 const (
@@ -99,7 +98,7 @@ func NewEEBus(ski, ip string, hasMeter, hasChargedEnergy bool) (api.Charger, err
 
 // waitForConnection wait for initial connection and returns an error on failure
 func (c *EEBus) waitForConnection() error {
-	timeout := time.After(request.Timeout)
+	timeout := time.After(30 * time.Second)
 	for {
 		select {
 		case <-timeout:
diff --git a/charger/nrgble/nrg_linux.go b/charger/nrg/ble/nrg_linux.go
similarity index 99%
rename from charger/nrgble/nrg_linux.go
rename to charger/nrg/ble/nrg_linux.go
index 680a52d2..f6388458 100644
--- a/charger/nrgble/nrg_linux.go
+++ b/charger/nrg/ble/nrg_linux.go
@@ -1,4 +1,4 @@
-package nrgble
+package ble
 
 import (
 	"errors"
diff --git a/charger/nrgble/service.go b/charger/nrg/ble/types.go
similarity index 99%
rename from charger/nrgble/service.go
rename to charger/nrg/ble/types.go
index c076c148..81506172 100644
--- a/charger/nrgble/service.go
+++ b/charger/nrg/ble/types.go
@@ -1,4 +1,4 @@
-package nrgble
+package ble
 
 const (
 	InfoService           = "8f75bba0-c903-11e4-9fe8-0002a5d6b15d"
diff --git a/charger/nrg/connect/types.go b/charger/nrg/connect/types.go
new file mode 100644
index 00000000..64de4f7a
--- /dev/null
+++ b/charger/nrg/connect/types.go
@@ -0,0 +1,51 @@
+package connect
+
+const (
+	SettingsPath     = "settings"
+	MeasurementsPath = "measurements"
+)
+
+// Measurements is the /api/measurements response
+type Measurements struct {
+	Message               string `json:"omitempty"` // api message if not ok
+	ChargingEnergy        float64
+	ChargingEnergyOverAll float64
+	ChargingPower         float64
+	ChargingPowerPhase    [3]float64
+	ChargingCurrentPhase  [3]float64
+	Frequency             float64
+}
+
+// Settings is the /api/settings request/response
+type Settings struct {
+	Message string `json:",omitempty"` // api message if not ok
+	Info    *Info  `json:",omitempty"`
+	Values  Values
+}
+
+// Info is Settings.Info
+type Info struct {
+	Connected bool
+}
+
+// Values is Settings.Values
+type Values struct {
+	ChargingStatus  *ChargingStatus  `json:",omitempty"`
+	ChargingCurrent *ChargingCurrent `json:",omitempty"`
+	DeviceMetadata  DeviceMetadata
+}
+
+// ChargingStatus is Settings.Values.ChargingStatus
+type ChargingStatus struct {
+	Charging bool
+}
+
+// ChargingCurrent is Settings.Values.ChargingCurrent
+type ChargingCurrent struct {
+	Value float64
+}
+
+// DeviceMetadata is Settings.Values.DeviceMetadata
+type DeviceMetadata struct {
+	Password string
+}
diff --git a/charger/nrgble_linux.go b/charger/nrgble_linux.go
index 5371a098..2c11ed55 100644
--- a/charger/nrgble_linux.go
+++ b/charger/nrgble_linux.go
@@ -8,7 +8,7 @@ import (
 	"time"
 
 	"github.com/evcc-io/evcc/api"
-	"github.com/evcc-io/evcc/charger/nrgble"
+	"github.com/evcc-io/evcc/charger/nrg/ble"
 	"github.com/evcc-io/evcc/util"
 	"github.com/godbus/dbus/v5"
 	"github.com/lunixbochs/struc"
@@ -121,12 +121,12 @@ func NewNRGKickBLE(device, mac string, pin int) (*NRGKickBLE, error) {
 }
 
 func (nrg *NRGKickBLE) connect() (*device.Device1, error) {
-	dev, err := nrgble.FindDevice(nrg.adapter, nrg.mac, nrgTimeout)
+	dev, err := ble.FindDevice(nrg.adapter, nrg.mac, nrgTimeout)
 	if err != nil {
 		return nil, fmt.Errorf("find device: %s", err)
 	}
 
-	err = nrgble.Connect(dev, nrg.agent, nrg.device)
+	err = ble.Connect(dev, nrg.agent, nrg.device)
 	if err != nil {
 		return nil, err
 	}
@@ -199,8 +199,8 @@ func (nrg *NRGKickBLE) write(service string, val interface{}) error {
 	return nil
 }
 
-func (nrg *NRGKickBLE) mergeSettings(info nrgble.Info) nrgble.Settings {
-	return nrgble.Settings{
+func (nrg *NRGKickBLE) mergeSettings(info ble.Info) ble.Settings {
+	return ble.Settings{
 		PIN:                  nrg.pin,
 		ChargingEnergyLimit:  19997, // magic const for "disable"
 		KWhPer100:            info.KWhPer100,
@@ -214,8 +214,8 @@ func (nrg *NRGKickBLE) mergeSettings(info nrgble.Info) nrgble.Settings {
 
 // Status implements the api.Charger interface
 func (nrg *NRGKickBLE) Status() (api.ChargeStatus, error) {
-	res := nrgble.Power{}
-	if err := nrg.read(nrgble.PowerService, &res); err != nil {
+	res := ble.Power{}
+	if err := nrg.read(ble.PowerService, &res); err != nil {
 		return api.StatusF, err
 	}
 
@@ -235,8 +235,8 @@ func (nrg *NRGKickBLE) Status() (api.ChargeStatus, error) {
 
 // Enabled implements the api.Charger interface
 func (nrg *NRGKickBLE) Enabled() (bool, error) {
-	res := nrgble.Info{}
-	if err := nrg.read(nrgble.InfoService, &res); err != nil {
+	res := ble.Info{}
+	if err := nrg.read(ble.InfoService, &res); err != nil {
 		return false, err
 	}
 
@@ -249,8 +249,8 @@ func (nrg *NRGKickBLE) Enabled() (bool, error) {
 
 // Enable implements the api.Charger interface
 func (nrg *NRGKickBLE) Enable(enable bool) error {
-	res := nrgble.Info{}
-	if err := nrg.read(nrgble.InfoService, &res); err != nil {
+	res := ble.Info{}
+	if err := nrg.read(ble.InfoService, &res); err != nil {
 		return err
 	}
 
@@ -261,7 +261,7 @@ func (nrg *NRGKickBLE) Enable(enable bool) error {
 		settings := nrg.mergeSettings(res)
 
 		nrg.log.TRACE.Printf("write settings (workaround): %+v", settings)
-		if err := nrg.write(nrgble.SettingsService, &settings); err != nil {
+		if err := nrg.write(ble.SettingsService, &settings); err != nil {
 			return err
 		}
 	}
@@ -271,13 +271,13 @@ func (nrg *NRGKickBLE) Enable(enable bool) error {
 
 	nrg.log.TRACE.Printf("write settings: %+v", settings)
 
-	return nrg.write(nrgble.SettingsService, &settings)
+	return nrg.write(ble.SettingsService, &settings)
 }
 
 // MaxCurrent implements the api.Charger interface
 func (nrg *NRGKickBLE) MaxCurrent(current int64) error {
-	res := nrgble.Info{}
-	if err := nrg.read(nrgble.InfoService, &res); err != nil {
+	res := ble.Info{}
+	if err := nrg.read(ble.InfoService, &res); err != nil {
 		return err
 	}
 
@@ -286,15 +286,15 @@ func (nrg *NRGKickBLE) MaxCurrent(current int64) error {
 
 	nrg.log.TRACE.Printf("write settings: %+v", settings)
 
-	return nrg.write(nrgble.SettingsService, &settings)
+	return nrg.write(ble.SettingsService, &settings)
 }
 
 var _ api.Meter = (*NRGKickBLE)(nil)
 
 // CurrentPower implements the api.Meter interface
 func (nrg *NRGKickBLE) CurrentPower() (float64, error) {
-	res := nrgble.Power{}
-	if err := nrg.read(nrgble.PowerService, &res); err != nil {
+	res := ble.Power{}
+	if err := nrg.read(ble.PowerService, &res); err != nil {
 		return 0, err
 	}
 
@@ -307,8 +307,8 @@ var _ api.MeterEnergy = (*NRGKickBLE)(nil)
 
 // TotalEnergy implements the api.MeterEnergy interface
 func (nrg *NRGKickBLE) TotalEnergy() (float64, error) {
-	res := nrgble.Energy{}
-	if err := nrg.read(nrgble.EnergyService, &res); err != nil {
+	res := ble.Energy{}
+	if err := nrg.read(ble.EnergyService, &res); err != nil {
 		return 0, err
 	}
 
@@ -321,8 +321,8 @@ var _ api.PhaseCurrents = (*NRGKickBLE)(nil)
 
 // Currents implements the api.PhaseCurrents interface
 func (nrg *NRGKickBLE) Currents() (float64, float64, float64, error) {
-	res := nrgble.VoltageCurrent{}
-	if err := nrg.read(nrgble.VoltageCurrentService, &res); err != nil {
+	res := ble.VoltageCurrent{}
+	if err := nrg.read(ble.VoltageCurrentService, &res); err != nil {
 		return 0, 0, 0, err
 	}
 
@@ -337,8 +337,8 @@ func (nrg *NRGKickBLE) Currents() (float64, float64, float64, error) {
 // ChargedEnergy implements the ChargeRater interface
 // NOTE: apparently shows energy of a stopped charging session, hence substituted by TotalEnergy
 // func (nrg *NRGKickBLE) ChargedEnergy() (float64, error) {
-// 	res := nrgble.Energy{}
-// 	if err := nrg.read(nrgble.EnergyService, &res); err != nil {
+// 	res := ble.Energy{}
+// 	if err := nrg.read(ble.EnergyService, &res); err != nil {
 // 		return 0, err
 // 	}
 // 	nrg.log.TRACE.Printf("energy: %+v", res)
diff --git a/charger/nrgconnect.go b/charger/nrgconnect.go
index bbe5ebf3..cac4f707 100644
--- a/charger/nrgconnect.go
+++ b/charger/nrgconnect.go
@@ -3,71 +3,28 @@ package charger
 import (
 	"errors"
 	"fmt"
+	"io"
 	"net/http"
+	"time"
 
 	"github.com/evcc-io/evcc/api"
+	"github.com/evcc-io/evcc/charger/nrg/connect"
+	"github.com/evcc-io/evcc/provider"
 	"github.com/evcc-io/evcc/util"
 	"github.com/evcc-io/evcc/util/request"
 )
 
 // https://www.nrgkick.com/wp-content/uploads/2019/08/20190814_API-Dokumentation_04.pdf
 
-const (
-	nrgSettings     = "settings"
-	nrgMeasurements = "measurements"
-)
-
-// NRGMeasurements is the /api/measurements response
-type NRGMeasurements struct {
-	Message               string `json:"omitempty"` // api message if not ok
-	ChargingEnergy        float64
-	ChargingEnergyOverAll float64
-	ChargingPower         float64
-	ChargingPowerPhase    [3]float64
-	ChargingCurrentPhase  [3]float64
-	Frequency             float64
-}
-
-// NRGSettings is the /api/settings request/response
-type NRGSettings struct {
-	Message string  `json:",omitempty"` // api message if not ok
-	Info    NRGInfo `json:",omitempty"`
-	Values  NRGValues
-}
-
-// NRGInfo is NRGSettings.Info
-type NRGInfo struct {
-	Connected bool `json:",omitempty"`
-}
-
-// NRGValues is NRGSettings.Values
-type NRGValues struct {
-	ChargingStatus  NRGChargingStatus
-	ChargingCurrent NRGChargingCurrent
-	DeviceMetadata  NRGDeviceMetadata
-}
-
-// NRGChargingStatus is NRGSettings.Values.ChargingStatus
-type NRGChargingStatus struct {
-	Charging *bool `json:",omitempty"` // use pointer to allow omitting false
-}
-
-// NRGChargingCurrent is NRGSettings.Values.ChargingCurrent
-type NRGChargingCurrent struct {
-	Value float64 `json:",omitempty"`
-}
-
-// NRGDeviceMetadata is NRGSettings.Values.DeviceMetadata
-type NRGDeviceMetadata struct {
-	Password string
-}
-
 // NRGKickConnect charger implementation
 type NRGKickConnect struct {
 	*request.Helper
-	uri      string
-	mac      string
-	password string
+	uri           string
+	mac           string
+	password      string
+	enabled       bool
+	settingsG     provider.Cacheable[connect.Settings]
+	measurementsG provider.Cacheable[connect.Measurements]
 }
 
 func init() {
@@ -76,18 +33,22 @@ func init() {
 
 // NewNRGKickConnectFromConfig creates a NRGKickConnect charger from generic config
 func NewNRGKickConnectFromConfig(other map[string]interface{}) (api.Charger, error) {
-	var cc struct {
+	cc := struct {
 		URI, Mac, Password string
+		Cache              time.Duration
+	}{
+		Cache: time.Second,
 	}
+
 	if err := util.DecodeOther(other, &cc); err != nil {
 		return nil, err
 	}
 
-	return NewNRGKickConnect(cc.URI, cc.Mac, cc.Password)
+	return NewNRGKickConnect(cc.URI, cc.Mac, cc.Password, cc.Cache)
 }
 
 // NewNRGKickConnect creates NRGKickConnect charger
-func NewNRGKickConnect(uri, mac, password string) (*NRGKickConnect, error) {
+func NewNRGKickConnect(uri, mac, password string, cache time.Duration) (*NRGKickConnect, error) {
 	nrg := &NRGKickConnect{
 		Helper:   request.NewHelper(util.NewLogger("nrgconn")),
 		uri:      util.DefaultScheme(uri, "http"),
@@ -95,6 +56,28 @@ func NewNRGKickConnect(uri, mac, password string) (*NRGKickConnect, error) {
 		password: password,
 	}
 
+	nrg.settingsG = provider.ResettableCached(func() (connect.Settings, error) {
+		var res connect.Settings
+
+		err := nrg.GetJSON(nrg.apiURL(connect.SettingsPath), &res)
+		if err != nil && res.Message != "" {
+			err = errors.New(res.Message)
+		}
+
+		return res, err
+	}, cache)
+
+	nrg.measurementsG = provider.ResettableCached(func() (connect.Measurements, error) {
+		var res connect.Measurements
+
+		err := nrg.GetJSON(nrg.apiURL(connect.MeasurementsPath), &res)
+		if err != nil && res.Message != "" {
+			err = errors.New(res.Message)
+		}
+
+		return res, err
+	}, cache)
+
 	return nrg, nil
 }
 
@@ -103,18 +86,25 @@ func (nrg *NRGKickConnect) apiURL(api string) string {
 }
 
 func (nrg *NRGKickConnect) putJSON(url string, data interface{}) error {
-	req, err := request.New(http.MethodPut, url, request.MarshalJSON(data))
+	req, err := request.New(http.MethodPut, url, request.MarshalJSON(data), request.JSONEncoding)
 
 	if err == nil {
-		var resp struct {
+		var res struct {
 			Message string
 		}
 
-		if err = nrg.DoJSON(req, &resp); err != nil {
-			if resp.Message != "" {
-				return fmt.Errorf("response: %s", resp.Message)
+		if err = nrg.DoJSON(req, &res); err != nil {
+			if err == io.EOF {
+				err = nil
+			} else if res.Message != "" {
+				return errors.New(res.Message)
 			}
 		}
+
+		if err == nil {
+			nrg.settingsG.Reset()
+			nrg.measurementsG.Reset()
+		}
 	}
 
 	return err
@@ -122,63 +112,86 @@ func (nrg *NRGKickConnect) putJSON(url string, data interface{}) error {
 
 // Status implements the api.Charger interface
 func (nrg *NRGKickConnect) Status() (api.ChargeStatus, error) {
-	return api.StatusC, nil
-}
-
-// Enabled implements the api.Charger interface
-func (nrg *NRGKickConnect) Enabled() (bool, error) {
-	var res NRGSettings
-	err := nrg.GetJSON(nrg.apiURL(nrgSettings), &res)
+	res, err := nrg.settingsG.Get()
 	if err != nil {
-		if res.Message != "" {
-			err = errors.New(res.Message)
-		}
+		return api.StatusNone, err
+	}
 
-		return false, err
+	if res.Values.ChargingStatus == nil {
+		return api.StatusNone, errors.New("unknown status")
 	}
 
-	return *res.Values.ChargingStatus.Charging, nil
+	if res.Values.ChargingStatus.Charging {
+		return api.StatusC, nil
+	}
+
+	return api.StatusB, nil
+}
+
+// Enabled implements the api.Charger interface
+func (nrg *NRGKickConnect) Enabled() (bool, error) {
+	return nrg.enabled, nil
 }
 
 // Enable implements the api.Charger interface
 func (nrg *NRGKickConnect) Enable(enable bool) error {
-	settings := NRGSettings{}
-	settings.Values.DeviceMetadata.Password = nrg.password
-	settings.Values.ChargingStatus.Charging = &enable
+	data := connect.Settings{
+		Values: connect.Values{
+			ChargingStatus: &connect.ChargingStatus{
+				Charging: enable,
+			},
+			DeviceMetadata: connect.DeviceMetadata{
+				Password: nrg.password,
+			},
+		},
+	}
+
+	err := nrg.putJSON(nrg.apiURL(connect.SettingsPath), data)
+	if err == nil {
+		nrg.enabled = enable
+	}
 
-	return nrg.putJSON(nrg.apiURL(nrgSettings), settings)
+	return err
 }
 
 // MaxCurrent implements the api.Charger interface
 func (nrg *NRGKickConnect) MaxCurrent(current int64) error {
-	settings := NRGSettings{}
-	settings.Values.DeviceMetadata.Password = nrg.password
-	settings.Values.ChargingCurrent.Value = float64(current)
+	data := connect.Settings{
+		Values: connect.Values{
+			ChargingStatus: &connect.ChargingStatus{
+				Charging: nrg.enabled,
+			},
+			ChargingCurrent: &connect.ChargingCurrent{
+				Value: float64(current),
+			},
+			DeviceMetadata: connect.DeviceMetadata{
+				Password: nrg.password,
+			},
+		},
+	}
 
-	return nrg.putJSON(nrg.apiURL(nrgSettings), settings)
+	return nrg.putJSON(nrg.apiURL(connect.SettingsPath), data)
 }
 
 var _ api.Meter = (*NRGKickConnect)(nil)
 
 // CurrentPower implements the api.Meter interface
 func (nrg *NRGKickConnect) CurrentPower() (float64, error) {
-	var res NRGMeasurements
-	err := nrg.GetJSON(nrg.apiURL(nrgMeasurements), &res)
-	if err != nil && res.Message != "" {
-		err = errors.New(res.Message)
+	res, err := nrg.measurementsG.Get()
+	if err != nil {
+		return 0, err
 	}
 
-	return 1000 * res.ChargingPower, err
+	return res.ChargingPower * 1e3, err
 }
 
 var _ api.MeterEnergy = (*NRGKickConnect)(nil)
 
 // TotalEnergy implements the api.MeterEnergy interface
 func (nrg *NRGKickConnect) TotalEnergy() (float64, error) {
-	var res NRGMeasurements
-	err := nrg.GetJSON(nrg.apiURL(nrgMeasurements), &res)
-	if err != nil && res.Message != "" {
-		err = errors.New(res.Message)
+	res, err := nrg.measurementsG.Get()
+	if err != nil {
+		return 0, err
 	}
 
 	return res.ChargingEnergyOverAll, err
@@ -188,10 +201,9 @@ var _ api.PhaseCurrents = (*NRGKickConnect)(nil)
 
 // Currents implements the api.PhaseCurrents interface
 func (nrg *NRGKickConnect) Currents() (float64, float64, float64, error) {
-	var res NRGMeasurements
-	err := nrg.GetJSON(nrg.apiURL(nrgMeasurements), &res)
-	if err != nil && res.Message != "" {
-		err = errors.New(res.Message)
+	res, err := nrg.measurementsG.Get()
+	if err != nil {
+		return 0, 0, 0, err
 	}
 
 	if len(res.ChargingCurrentPhase) != 3 {
@@ -207,7 +219,7 @@ func (nrg *NRGKickConnect) Currents() (float64, float64, float64, error) {
 // ChargedEnergy implements the ChargeRater interface
 // NOTE: apparently shows energy of a stopped charging session, hence substituted by TotalEnergy
 // func (nrg *NRGKickConnect) ChargedEnergy() (float64, error) {
-// 	var res NRGMeasurements
-// 	err := nrg.GetJSON(nrg.apiURL(nrgMeasurements), &res)
+// 	var res connect.Measurements
+// 	err := nrg.GetJSON(nrg.apiURL(connect.MeasurementsPath), &res)
 // 	return res.ChargingEnergy, err
 // }
diff --git a/charger/vestel.go b/charger/vestel.go
index 7ad33c10..af970cde 100644
--- a/charger/vestel.go
+++ b/charger/vestel.go
@@ -93,18 +93,13 @@ func NewVestel(uri string, id uint8) (*Vestel, error) {
 		current: 6,
 	}
 
-	// 5min failsafe timeout
-	if _, err := wb.conn.WriteSingleRegister(vestelRegFailsafeTimeout, 5*60); err != nil {
-		return nil, fmt.Errorf("could not set failsafe timeout: %v", err)
-	}
-
 	go wb.heartbeat()
 
 	return wb, nil
 }
 
 func (wb *Vestel) heartbeat() {
-	for range time.NewTicker(time.Minute).C {
+	for range time.NewTicker(time.Second * 3).C {
 		if _, err := wb.conn.WriteSingleRegister(vestelRegAlive, 1); err != nil {
 			wb.log.ERROR.Println("heartbeat:", err)
 		}
diff --git a/cmd/demo.yaml b/cmd/demo.yaml
index f01483f1..e32909db 100644
--- a/cmd/demo.yaml
+++ b/cmd/demo.yaml
@@ -150,7 +150,7 @@ vehicles:
       vm: shared
       script: |
         var lp = state.loadpoints[0];
-        if (lp.chargepower > 0) lp.vehicleSoc++; else lp.vehicleSoc--;
+        if (lp.chargepower > 0) lp.vehicleSoc+=0.1; else lp.vehicleSoc-=0.1;
         if (lp.vehicleSoc < 15) lp.vehicleSoc = 80;
         if (lp.vehicleSoc > 80) lp.vehicleSoc = 15;
         lp.vehicleSoc;
@@ -233,6 +233,9 @@ tariffs:
   grid:
     type: fixed
     price: 0.399 # EUR/kWh
+  #  grid:
+  #    type: awattar
+  #    region: de
   feedin:
     type: fixed
     price: 0.08 # EUR/kWh
diff --git a/core/site.go b/core/site.go
index 5ac23a63..e1620305 100644
--- a/core/site.go
+++ b/core/site.go
@@ -586,7 +586,7 @@ func (site *Site) prepare() {
 	site.publish("residualPower", site.ResidualPower)
 
 	site.publish("currency", site.tariffs.Currency.String())
-	site.publish("savingsSince", site.savings.Since().Unix())
+	site.publish("savingsSince", site.savings.Since())
 
 	site.publish("vehicles", vehicleTitles(site.GetVehicles()))
 }
diff --git a/go.mod b/go.mod
index 0e750e1d..acff674b 100644
--- a/go.mod
+++ b/go.mod
@@ -11,7 +11,7 @@ require (
 	github.com/andig/mbserver v0.0.0-20221101171752-364e71b0089c
 	github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef
 	github.com/avast/retry-go/v3 v3.1.1
-	github.com/aws/aws-sdk-go v1.44.181
+	github.com/aws/aws-sdk-go v1.44.185
 	github.com/basgys/goxml2json v1.1.0
 	github.com/basvdlei/gotsmart v0.0.3
 	github.com/benbjohnson/clock v1.3.0
@@ -27,8 +27,8 @@ require (
 	github.com/dustin/go-humanize v1.0.1
 	github.com/dylanmei/iso8601 v0.1.0
 	github.com/eclipse/paho.mqtt.golang v1.4.2
-	github.com/enbility/cemd v0.1.5
-	github.com/enbility/eebus-go v0.1.5
+	github.com/enbility/cemd v0.1.6
+	github.com/enbility/eebus-go v0.1.6
 	github.com/fatih/structs v1.1.0
 	github.com/foogod/go-powerwall v0.2.0
 	github.com/glebarez/sqlite v1.6.0
@@ -84,18 +84,18 @@ require (
 	github.com/spf13/cobra v1.6.1
 	github.com/spf13/jwalterweatherman v1.1.0
 	github.com/spf13/pflag v1.0.5
-	github.com/spf13/viper v1.14.0
+	github.com/spf13/viper v1.15.0
 	github.com/stretchr/testify v1.8.1
 	github.com/tv42/httpunix v0.0.0-20191220191345-2ba4b9c3382c
-	github.com/volkszaehler/mbmd v0.0.0-20221223133217-870b3b92b81e
+	github.com/volkszaehler/mbmd v0.0.0-20230122165729-773311cba1f9
 	github.com/writeas/go-strip-markdown v2.0.1+incompatible
 	gitlab.com/bboehmke/sunny v0.15.1-0.20211022160056-2fba1c86ade6
-	golang.org/x/exp v0.0.0-20230116083435-1de6713980de
+	golang.org/x/exp v0.0.0-20230118134722-a68e582fa157
 	golang.org/x/net v0.5.0
 	golang.org/x/oauth2 v0.4.0
 	golang.org/x/sync v0.1.0
 	golang.org/x/text v0.6.0
-	google.golang.org/api v0.107.0
+	google.golang.org/api v0.108.0
 	google.golang.org/grpc v1.52.0
 	google.golang.org/protobuf v1.28.1
 	gopkg.in/yaml.v3 v3.0.1
@@ -116,7 +116,7 @@ require (
 	github.com/cespare/xxhash/v2 v2.2.0 // indirect
 	github.com/chzyer/readline v1.5.1 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
-	github.com/fatih/color v1.13.0 // indirect
+	github.com/fatih/color v1.14.1 // indirect
 	github.com/felixge/httpsnoop v1.0.3 // indirect
 	github.com/fsnotify/fsnotify v1.6.0 // indirect
 	github.com/glebarez/go-sqlite v1.20.0 // indirect
@@ -143,7 +143,7 @@ require (
 	github.com/jinzhu/now v1.1.5 // indirect
 	github.com/jmespath/go-jmespath v0.4.0 // indirect
 	github.com/json-iterator/go v1.1.12 // indirect
-	github.com/klauspost/compress v1.15.14 // indirect
+	github.com/klauspost/compress v1.15.15 // indirect
 	github.com/leodido/go-urn v1.2.1 // indirect
 	github.com/magiconair/properties v1.8.7 // indirect
 	github.com/mattn/go-colorable v0.1.13 // indirect
@@ -158,7 +158,6 @@ require (
 	github.com/modern-go/reflect2 v1.0.2 // indirect
 	github.com/onsi/ginkgo v1.16.5 // indirect
 	github.com/pascaldekloe/name v1.0.1 // indirect
-	github.com/pelletier/go-toml v1.9.5 // indirect
 	github.com/pelletier/go-toml/v2 v2.0.6 // indirect
 	github.com/pkg/errors v0.9.1 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
@@ -185,15 +184,14 @@ require (
 	golang.org/x/term v0.4.0 // indirect
 	golang.org/x/tools v0.5.0 // indirect
 	google.golang.org/appengine v1.6.7 // indirect
-	google.golang.org/genproto v0.0.0-20230117162540-28d6b9783ac4 // indirect
+	google.golang.org/genproto v0.0.0-20230123190316-2c411cf9d197 // indirect
 	gopkg.in/go-playground/validator.v9 v9.31.0 // indirect
 	gopkg.in/ini.v1 v1.67.0 // indirect
 	gopkg.in/sourcemap.v1 v1.0.5 // indirect
-	gopkg.in/yaml.v2 v2.4.0 // indirect
 	modernc.org/libc v1.22.2 // indirect
 	modernc.org/mathutil v1.5.0 // indirect
 	modernc.org/memory v1.5.0 // indirect
-	modernc.org/sqlite v1.20.2 // indirect
+	modernc.org/sqlite v1.20.3 // indirect
 	nhooyr.io/websocket v1.8.7 // indirect
 )
 
diff --git a/go.sum b/go.sum
index 25f39d34..f485db65 100644
--- a/go.sum
+++ b/go.sum
@@ -100,8 +100,8 @@ github.com/avast/retry-go/v3 v3.1.1 h1:49Scxf4v8PmiQ/nY0aY3p0hDueqSmc7++cBbtiDGu
 github.com/avast/retry-go/v3 v3.1.1/go.mod h1:6cXRK369RpzFL3UQGqIUp9Q7GDrams+KsYWrfNA1/nQ=
 github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU=
 github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
-github.com/aws/aws-sdk-go v1.44.181 h1:w4OzE8bwIVo62gUTAp/uEFO2HSsUtf1pjXpSs36cluY=
-github.com/aws/aws-sdk-go v1.44.181/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI=
+github.com/aws/aws-sdk-go v1.44.185 h1:stasiou+Ucx2A0RyXRyPph4sLCBxVQK7DPPK8tNcl5g=
+github.com/aws/aws-sdk-go v1.44.185/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI=
 github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g=
 github.com/basgys/goxml2json v1.1.0 h1:4ln5i4rseYfXNd86lGEB+Vi652IsIXIvggKM/BhUKVw=
 github.com/basgys/goxml2json v1.1.0/go.mod h1:wH7a5Np/Q4QoECFIU8zTQlZwZkrilY0itPfecMw41Dw=
@@ -196,10 +196,10 @@ github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFP
 github.com/eclipse/paho.mqtt.golang v1.4.2 h1:66wOzfUHSSI1zamx7jR6yMEI5EuHnT1G6rNA5PM12m4=
 github.com/eclipse/paho.mqtt.golang v1.4.2/go.mod h1:JGt0RsEwEX+Xa/agj90YJ9d9DH2b7upDZMK9HRbFvCA=
 github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
-github.com/enbility/cemd v0.1.5 h1:9DHDH90YoldCpbg2gt06y0ZgOOk6pJB0BspBMrwnUq0=
-github.com/enbility/cemd v0.1.5/go.mod h1:PZfCRYAe9PDI8fKLADtvotiVTym9ZJ2LZaLfi4EeIaE=
-github.com/enbility/eebus-go v0.1.5 h1:IkRxwN+jCqQ4TCbbtjJf70iaeSUUicZZSUsMTLFtVpg=
-github.com/enbility/eebus-go v0.1.5/go.mod h1:BaXy+yk3pAURDqjaaPkxqJ6nlPDcEOT7ARs85U9T6YI=
+github.com/enbility/cemd v0.1.6 h1:tXpFeU1XlRTeSA1JiQkoySdlwnOvO9nq77u00JRrBY0=
+github.com/enbility/cemd v0.1.6/go.mod h1:rSIgtHrPfJdGE3jS9wFe6UvVohfVnODy/8HgiY3mvHs=
+github.com/enbility/eebus-go v0.1.6 h1:T+yxpOhdfakJFd5SBon44lXkjBUyaUITS093qIpMo3Q=
+github.com/enbility/eebus-go v0.1.6/go.mod h1:BaXy+yk3pAURDqjaaPkxqJ6nlPDcEOT7ARs85U9T6YI=
 github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g=
 github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
@@ -210,8 +210,8 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7
 github.com/fatih/camelcase v1.0.0/go.mod h1:yN2Sb0lFhZJUdVvtELVWefmrXpuZESvPmqwoZc+/fpc=
 github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
 github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
-github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
-github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
+github.com/fatih/color v1.14.1 h1:qfhVLaG5s+nCROl1zJsZRxFeYrHLqWroPOQ8BWiNb4w=
+github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg=
 github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
 github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
 github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
@@ -514,8 +514,8 @@ github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvW
 github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
 github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
 github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
-github.com/klauspost/compress v1.15.14 h1:i7WCKDToww0wA+9qrUZ1xOjp218vfFo3nTU6UHp+gOc=
-github.com/klauspost/compress v1.15.14/go.mod h1:QPwzmACJjUTFsnSHH934V6woptycfrDDJnH7hvFVbGM=
+github.com/klauspost/compress v1.15.15 h1:EF27CXIuDsYJ6mmvtBRlEuB2UVOqHG1tAXgZ7yIO+lw=
+github.com/klauspost/compress v1.15.15/go.mod h1:ZcK2JAFqKOpnBlxcLsJzYfrS9X1akm9fHZNnD9+Vo/4=
 github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
 github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
 github.com/koron/go-ssdp v0.0.3 h1:JivLMY45N76b4p/vsWGOKewBQu6uf39y8l+AQ7sDKx8=
@@ -552,14 +552,12 @@ github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GW
 github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
 github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
 github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
-github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
 github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
 github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
 github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
 github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
 github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
 github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
-github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
 github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
 github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
 github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
@@ -664,8 +662,6 @@ github.com/paypal/gatt v0.0.0-20151011220935-4ae819d591cf/go.mod h1:+AwQL2mK3Pd3
 github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k=
 github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
 github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE=
-github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
-github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
 github.com/pelletier/go-toml/v2 v2.0.6 h1:nrzqCb7j9cDFj2coyLNLaZuJTLjWjlaz6nvTvIwycIU=
 github.com/pelletier/go-toml/v2 v2.0.6/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek=
 github.com/performancecopilot/speed v3.0.0+incompatible/go.mod h1:/CLtqpZ5gBg1M9iaPbIdPPGyKcA8hKdoy6hAWba7Yac=
@@ -788,8 +784,8 @@ github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnIn
 github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
 github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 github.com/spf13/viper v1.6.3/go.mod h1:jUMtyi0/lB5yZH/FjyGAoH7IMNrIhlBf6pXZmbMDvzw=
-github.com/spf13/viper v1.14.0 h1:Rg7d3Lo706X9tHsJMUjdiwMpHB7W8WnSVOssIY+JElU=
-github.com/spf13/viper v1.14.0/go.mod h1:WT//axPky3FdvXHzGw33dNdXXXfFQqmEalje+egj8As=
+github.com/spf13/viper v1.15.0 h1:js3yy885G8xwJa6iOISGFwd+qlUo5AvyXb7CiihdtiU=
+github.com/spf13/viper v1.15.0/go.mod h1:fFcTBJxvhhzSJiZy8n+PeW6t8l+KeT/uTARa0jHOQLA=
 github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
 github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=
 github.com/streadway/amqp v0.0.0-20190827072141-edfb9018d271/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=
@@ -830,8 +826,8 @@ github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9
 github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc=
 github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
 github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
-github.com/volkszaehler/mbmd v0.0.0-20221223133217-870b3b92b81e h1:C+/EiUuPp4gjwQkU99wutMqCw9xussR8+eUBTD2uvi8=
-github.com/volkszaehler/mbmd v0.0.0-20221223133217-870b3b92b81e/go.mod h1:/vT7enSAahS3dQiDF4unUeiOGrikhe6AllhVIEBjtNg=
+github.com/volkszaehler/mbmd v0.0.0-20230122165729-773311cba1f9 h1:6Eb9ck8YjG6CQgtiVxkkBVgXeP3mIis8X4W2LMqjR4s=
+github.com/volkszaehler/mbmd v0.0.0-20230122165729-773311cba1f9/go.mod h1:9t5d4Wv2j8/yxSAiksgBAxzFUs4ZSFMDlgAbJRnQpAQ=
 github.com/writeas/go-strip-markdown v2.0.1+incompatible h1:IIqxTM5Jr7RzhigcL6FkrCNfXkvbR+Nbu1ls48pXYcw=
 github.com/writeas/go-strip-markdown v2.0.1+incompatible/go.mod h1:Rsyu10ZhbEK9pXdk8V6MVnZmTzRG0alMNLMwa0J01fE=
 github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
@@ -896,8 +892,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
 golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
 golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
 golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
-golang.org/x/exp v0.0.0-20230116083435-1de6713980de h1:DBWn//IJw30uYCgERoxCg84hWtA97F4wMiKOIh00Uf0=
-golang.org/x/exp v0.0.0-20230116083435-1de6713980de/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
+golang.org/x/exp v0.0.0-20230118134722-a68e582fa157 h1:fiNkyhJPUvxbRPbCqY/D9qdjmPzfHcpK3P4bM4gioSY=
+golang.org/x/exp v0.0.0-20230118134722-a68e582fa157/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
 golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
 golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
 golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -1199,8 +1195,8 @@ google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz513
 google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
 google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
 google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
-google.golang.org/api v0.107.0 h1:I2SlFjD8ZWabaIFOfeEDg3pf0BHJDh6iYQ1ic3Yu/UU=
-google.golang.org/api v0.107.0/go.mod h1:2Ts0XTHNVWxypznxWOYUeI4g3WdP9Pk2Qk58+a/O9MY=
+google.golang.org/api v0.108.0 h1:WVBc/faN0DkKtR43Q/7+tPny9ZoLZdIiAyG5Q9vFClg=
+google.golang.org/api v0.108.0/go.mod h1:2Ts0XTHNVWxypznxWOYUeI4g3WdP9Pk2Qk58+a/O9MY=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@@ -1247,8 +1243,8 @@ google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6D
 google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20230117162540-28d6b9783ac4 h1:yF0uHwqqYt2tIL2F4hxRWA1ZFX43SEunWAK8MnQiclk=
-google.golang.org/genproto v0.0.0-20230117162540-28d6b9783ac4/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM=
+google.golang.org/genproto v0.0.0-20230123190316-2c411cf9d197 h1:BwjeHhu4HS48EZmu1nS7flldBIDPC3qn+HqaSQ1K4x8=
+google.golang.org/genproto v0.0.0-20230123190316-2c411cf9d197/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM=
 google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.20.0/go.mod h1:chYK+tFQF0nDUGJgXMSgLCQk3phJEuONr2DCgLDdAQM=
@@ -1365,8 +1361,8 @@ modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
 modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
 modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
 modernc.org/sqlite v1.20.0/go.mod h1:EsYz8rfOvLCiYTy5ZFsOYzoCcRMu98YYkwAcCw5YIYw=
-modernc.org/sqlite v1.20.2 h1:9AaVzJH1Yf0u9iOZRjjuvqxLoGqybqVFbAUC5rvi9u8=
-modernc.org/sqlite v1.20.2/go.mod h1:zKcGyrICaxNTMEHSr1HQ2GUraP0j+845GYw37+EyT6A=
+modernc.org/sqlite v1.20.3 h1:SqGJMMxjj1PHusLxdYxeQSodg7Jxn9WWkaAQjKrntZs=
+modernc.org/sqlite v1.20.3/go.mod h1:zKcGyrICaxNTMEHSr1HQ2GUraP0j+845GYw37+EyT6A=
 modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
 modernc.org/tcl v1.15.0/go.mod h1:xRoGotBZ6dU+Zo2tca+2EqVEeMmOUBzHnhIwq4YrVnE=
 modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
diff --git a/histoire.config.js b/histoire.config.js
index 138339c2..f60de21a 100644
--- a/histoire.config.js
+++ b/histoire.config.js
@@ -5,4 +5,5 @@ export default defineConfig({
   plugins: [HstVue()],
   setupFile: "./histoire.setup.js",
   viteNodeInlineDeps: [/!axios/],
+  routerMode: "hash",
 });
diff --git a/i18n/de.toml b/i18n/de.toml
index 86562395..eb44d73c 100644
--- a/i18n/de.toml
+++ b/i18n/de.toml
@@ -62,14 +62,14 @@ power = "Ladeleistung"
 powerSub1 = "{activeClients} von {totalClients} Nutzern"
 powerSub2 = "laden..."
 greenShare = "Sonnenanteil"
-greenShareSub1 = "Leistung kommt von"
-greenShareSub2 = "aus PV & Hausakku"
+greenShareSub1 = "der Leistung kommt"
+greenShareSub2 = "von PV & Hausakku"
 greenEnergy = "Sonne"
 greenEnergySub1 = "über evcc geladen"
 greenEnergySub2 = "seit Oktober 2022"
 
 [footer.telemetry]
-optIn = "Ich möchte meine Ladedaten mit der Community teilen."
+optIn = "Ich möchte meine Ladedaten teilen."
 optInMoreDetails = "Mehr Details {0}."
 optInMoreDetailsLink = "hier"
 optInSponsorship = "Sponsoring erforderlich."
@@ -191,13 +191,22 @@ descriptionEnergy = "Bis wann sollen {targetEnergy} ins Fahrzeug geladen sein?"
 today = "heute"
 tomorrow = "morgen"
 targetIsInThePast = "Wähle einen Zeitpunkt in der Zukunft, Marty."
+targetIsTooFarInTheFuture = "Wir passen den Plan an sobald wir mehr über die Zukunft wissen."
 remove = "Entfernen"
 activate = "Aktivieren"
-planUnknown = "unbekannt"
+update = "Aktualisieren"
+planUnknown = "noch unbekannt"
 planDuration = "Ladezeit"
 planPeriodLabel = "Zeitraum"
 planPeriodValue = "{start} bis {end}"
 
+[main.targetChargePlan]
+timeRange = "{day} {range} Uhr"
+chargeDuration = "Ladezeit"
+co2Label = "CO₂-Emission Ø"
+priceLabel = "Energiepreis"
+unknownPrice = "noch unbekannt"
+
 [main.targetEnergy]
 label = "Ladeziel"
 noLimit = "keins"
diff --git a/i18n/en.toml b/i18n/en.toml
index ca19a8d9..cbc5d540 100644
--- a/i18n/en.toml
+++ b/i18n/en.toml
@@ -191,13 +191,22 @@ descriptionEnergy = "Until when should {targetEnergy} be loaded into the vehicle
 today = "today"
 tomorrow = "tomorrow"
 targetIsInThePast = "Pick a time in the future, Marty."
+targetIsTooFarInTheFuture = "We will adjust the plan as soon as we know more about the future."
 remove = "Remove"
 activate = "Activate"
-planUnknown = "unknown"
+update = "Update"
+planUnknown = "not known yet"
 planDuration = "Charging time"
 planPeriodLabel = "Period"
 planPeriodValue = "{start} to {end}"
 
+[main.targetChargePlan]
+timeRange = "{day} {range} h"
+chargeDuration = "Charging time"
+co2Label = "CO₂ emission Ø"
+priceLabel = "Energy price"
+unknownPrice = "still unknown"
+
 [main.targetEnergy]
 label = "Limit"
 noLimit = "none"
diff --git a/i18n/fi.toml b/i18n/fi.toml
index 4f7fb516..20ad76c6 100644
--- a/i18n/fi.toml
+++ b/i18n/fi.toml
@@ -158,6 +158,11 @@ descriptionSoc = "Milloin ajoneuvon tulee olla ladattu {targetSoc}%?"
 today = "tänään"
 setTargetTime = "ei mitään"
 tomorrow = "huomenna"
+planPeriodLabel = "Jakso"
+planPeriodValue = "{start} - {end}"
+descriptionEnergy = "Mihin mennessä {targetEnergy} tulisi ladata autoon?"
+planUnknown = "tuntematon"
+planDuration = "Latausaika"
 
 [main.targetEnergy]
 noLimit = "ei mitään"
@@ -182,6 +187,7 @@ vehicle = "Ajoneuvo"
 energy = "Ladattu"
 downloadCsv = "Lataa CSV muodossa"
 date = "Jakso"
+reallyDelete = "Haluatko varmasti poistaa tämän istunnon?"
 
 [sessions.csv]
 loadpoint = "Latauspiste"
@@ -227,3 +233,13 @@ auto = "Automaattinen"
 
 [settings.telemetry]
 label = "Telemetria"
+
+[session]
+cancel = "Peruuta"
+started = "Alkoi"
+finished = "Valmis"
+title = "Latausistunto"
+meterstart = "Mittarin käynnistys"
+meterstop = "Mittarin pysähdys"
+delete = "Poista"
+odometer = "Ajokilometrit"
diff --git a/i18n/lt.toml b/i18n/lt.toml
index 41c7107d..bfe8d2bf 100644
--- a/i18n/lt.toml
+++ b/i18n/lt.toml
@@ -176,11 +176,13 @@ tomorrow = "rytoj"
 targetIsInThePast = "Pasirinkite laiką ateityje."
 remove = "Panaikinti"
 activate = "Aktyvuoti"
-planUnknown = "nežinoma"
+planUnknown = "dar nežinome"
 planDuration = "Įkrovimo laikas"
 planPeriodLabel = "Periodas"
 planPeriodValue = "{start} iki {end}"
 descriptionEnergy = "Iki kada {targetEnergy} turėtų būti įkrauta į automobilį?"
+targetIsTooFarInTheFuture = "Pakoreguosime planą, kai tik gausime naujų duomenų."
+update = "Atnaujinti"
 
 [main.targetEnergy]
 label = "Limitas"
@@ -252,3 +254,10 @@ finished = "Pabaigta"
 title = "Įkrovimo sesija"
 delete = "Ištrinti"
 cancel = "Atšaukti"
+
+[main.targetChargePlan]
+unknownPrice = "dar nežinoma"
+chargeDuration = "Įkrovimo trukmė"
+priceLabel = "Energijos kaina"
+timeRange = "{day} {range} h"
+co2Label = "CO₂ emisijos Ø"
diff --git a/i18n/pt.toml b/i18n/pt.toml
index 103bf7da..ea48c6d5 100644
--- a/i18n/pt.toml
+++ b/i18n/pt.toml
@@ -28,12 +28,12 @@ modalUpdateStatusStart = "Instalação iniciada:"
 tabTitle = "Meus dados"
 footerShort = "{percent}% Sol"
 footerLong = "{percent}% energia solar"
-modalTitle = "Avaliação da energia de carregamento"
+modalTitle = "Avaliação da energia carregada"
 since = "desde {since}"
 percentTitle = "Energia solar"
 percentSelf = "{self} kWh sol"
 percentGrid = "{grid} kWh rede"
-priceTitle = "preço da energia"
+priceTitle = "Preço da energia"
 priceFeedIn = "{feedInPrice} alimentação"
 priceGrid = "{gridPrice} rede"
 savingsTitle = "Poupança"
@@ -41,16 +41,16 @@ savingsComparedToGrid = "em relação a rede"
 savingsTotalEnergy = "{total} kWh carregado"
 
 [footer.community]
-tabTitle = "Live-Community"
+tabTitle = "Comunidade ao vivo"
 power = "Poder de carregamento"
 powerSub1 = "{activeClients} de {totalClients} utilizadores"
 powerSub2 = "carregam..."
 greenShare = "Parte solar"
 greenShareSub1 = "energia fornecida por"
-greenShareSub2 = "solar, e bateria doméstica"
-greenEnergy = "Energia solar"
-greenEnergySub1 = "desde Outubro 2022"
-greenEnergySub2 = "carregado sobre evcc"
+greenShareSub2 = "solar e bateria doméstica"
+greenEnergy = "Sol"
+greenEnergySub1 = "carregado com evcc"
+greenEnergySub2 = "desde outubro de 2022"
 
 [footer.telemetry]
 optIn = "Quero contribuir com meus dados."
@@ -131,7 +131,7 @@ label = "Corrente de carga mínima"
 [main.vehicle]
 fallbackName = "Veículo"
 vehicleSoc = "Nível de carga"
-targetSoc = "Alvo de carregamento"
+targetSoc = "Alvo de carga"
 none = "Nenhum veículo"
 unknown = "Veículo convidado"
 changeVehicle = "Troca de veículo"
@@ -176,6 +176,13 @@ tomorrow = "amanhã"
 targetIsInThePast = "Escolha um momento no futuro, Marty."
 remove = "Remover"
 activate = "Ativar"
+descriptionEnergy = "Até quando {targetEnergy} deve ser carregado no veículo?"
+planUnknown = "desconhecido"
+planPeriodLabel = "Período"
+planPeriodValue = "{start} até {end}"
+planDuration = "Tempo de carga"
+targetIsTooFarInTheFuture = "Vamos ajustar o plano assim que soubermos mais sobre o futuro."
+update = "Atualizar"
 
 [main.targetEnergy]
 label = "Alvo de carregamento"
@@ -184,7 +191,7 @@ noLimit = "sem"
 [startupError]
 title = "Erro ao iniciar"
 description = "Verifique seu ficheiro de configuração. Se a mensagem de erro não ajudar, verifique o {0}."
-discussions = "GitHub Discussions"
+discussions = "Discussões no GitHub"
 hint = "Nota: Também pode ser que você tenha um equipamento com defeito (inversor, medidor, …). Verifique suas conexões de rede."
 configuration = "Configuração"
 configFile = "Ficheiro de configuração em uso:"
@@ -201,14 +208,15 @@ vehicle = "Veículo"
 odometer = "Quilometragem"
 energy = "Carregado"
 date = "Período"
+reallyDelete = "Você realmente quer excluir esta sessão?"
 
 [sessions.csv]
 loadpoint = "Ponto de carregamento"
 vehicle = "Veículo"
 identifier = "Identificador"
 chargedenergy = "Energia (kWh)"
-meterstart = "Leitura inícial do contador (kWh)"
-meterstop = "Leitura final do contador (kWh)"
+meterstart = "Início do contador (kWh)"
+meterstop = "Parada do contador (kWh)"
 created = "hora de início"
 finished = "hora final"
 odometer = "Quilometragem (km)"
@@ -237,3 +245,20 @@ mi = "milhas"
 
 [settings.telemetry]
 label = "Telemetria"
+
+[session]
+finished = "Finalizado"
+meterstart = "Início do contador"
+meterstop = "Parada do contador"
+title = "Processo de carregamento"
+odometer = "Quilometragem"
+started = "Começado"
+delete = "Excluir"
+cancel = "Cancelar"
+
+[main.targetChargePlan]
+timeRange = "{day} {range} h"
+chargeDuration = "Tempo de carga"
+co2Label = "Emissão de CO₂ Ø"
+unknownPrice = "ainda desconhecido"
+priceLabel = "Preço da energia"
diff --git a/i18n/ro.toml b/i18n/ro.toml
index fd4fe603..bd777020 100644
--- a/i18n/ro.toml
+++ b/i18n/ro.toml
@@ -1,5 +1,5 @@
 [header]
-login = "Autentificare Vehicul"
+login = "Login Vehicul"
 docs = "Documentatie"
 settings = "Configurare"
 sessions = "Sesiuni de Incarcare"
@@ -14,7 +14,7 @@ availableLong = "versiune noua disponibila"
 modalUpdateStarted = "Pornesc noua versiune evcc..."
 modalNoReleaseNotes = "Nu exista info despre aceasta versiune . Vezi:"
 modalCancel = "Inchide"
-modalUpdate = "Instalaza"
+modalUpdate = "Instaleaza"
 modalUpdateNow = "Instaleaza acum"
 modalDownload = "Download"
 modalUpdateStatusStart = "Instalare pornita:"
@@ -22,8 +22,8 @@ modalUpdateStatusStart = "Instalare pornita:"
 [settings.theme]
 auto = "system"
 label = "Design"
-dark = "negru"
-light = "alb"
+dark = "intunecat"
+light = "deschis"
 
 [settings]
 title = "Configurare"
diff --git a/meter/openwb.go b/meter/openwb.go
index 121e1618..d923d266 100644
--- a/meter/openwb.go
+++ b/meter/openwb.go
@@ -101,7 +101,11 @@ func NewOpenWBFromConfig(other map[string]interface{}) (api.Meter, error) {
 			return nil, errors.New("battery not available")
 		}
 
-		power = floatG(fmt.Sprintf("%s/housebattery/%s", cc.Topic, openwb.PowerTopic))
+		inner := floatG(fmt.Sprintf("%s/housebattery/%s", cc.Topic, openwb.PowerTopic))
+		power = func() (float64, error) {
+			f, err := inner()
+			return -f, err
+		}
 		soc = floatG(fmt.Sprintf("%s/housebattery/%s", cc.Topic, openwb.SocTopic))
 		capacity = cc.capacity.Decorator()
 
diff --git a/package-lock.json b/package-lock.json
index 748f7469..43be5edf 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,6 +13,7 @@
         "@popperjs/core": "^2.11.5",
         "@vitejs/plugin-vue": "^3.0.1",
         "@vue/compiler-sfc": "^3.2.30",
+        "@vue/test-utils": "^2.2.7",
         "axios": "^1.2.1",
         "bootstrap": "^5.2.2",
         "canvas-confetti": "^1.4.0",
@@ -39,6 +40,10 @@
         "vue-meta": "^3.0.0-alpha.2",
         "vue-router": "^4.0.12"
       },
+      "devDependencies": {
+        "happy-dom": "^8.1.4",
+        "vitest": "^0.27.1"
+      },
       "engines": {
         "node": ">=18.0.0",
         "npm": ">=8.0.0"
@@ -781,6 +786,21 @@
         "node": ">= 10"
       }
     },
+    "node_modules/@types/chai": {
+      "version": "4.3.4",
+      "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.4.tgz",
+      "integrity": "sha512-KnRanxnpfpjUTqTCXslZSEdLfXExwgNxYPdiO2WGUj8+HDjFi8R3k5RVKPeSCzLjCcshCAtVO2QBbVuAV4kTnw==",
+      "dev": true
+    },
+    "node_modules/@types/chai-subset": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/@types/chai-subset/-/chai-subset-1.3.3.tgz",
+      "integrity": "sha512-frBecisrNGz+F4T6bcc+NLeolfiojh5FxW2klu669+8BARtyQv2C/GkNW6FUodVe4BroGMP/wER/YDGc7rEllw==",
+      "dev": true,
+      "dependencies": {
+        "@types/chai": "*"
+      }
+    },
     "node_modules/@types/flexsearch": {
       "version": "0.7.3",
       "resolved": "https://registry.npmjs.org/@types/flexsearch/-/flexsearch-0.7.3.tgz",
@@ -942,6 +962,14 @@
       "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.2.45.tgz",
       "integrity": "sha512-Ewzq5Yhimg7pSztDV+RH1UDKBzmtqieXQlpTVm2AwraoRL/Rks96mvd8Vgi7Lj+h+TH8dv7mXD3FRZR3TUvbSg=="
     },
+    "node_modules/@vue/test-utils": {
+      "version": "2.2.7",
+      "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.2.7.tgz",
+      "integrity": "sha512-BMuoruUFTEqhLoOgsMcgNVMiByYbfHCKGr2C4CPdGtz/affUtDVX5zr1RnPuq0tYSiaqq+Enw5voUpG6JY8Q7g==",
+      "peerDependencies": {
+        "vue": "^3.0.1"
+      }
+    },
     "node_modules/abab": {
       "version": "2.0.6",
       "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz",
@@ -1080,6 +1108,15 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/assertion-error": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz",
+      "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==",
+      "dev": true,
+      "engines": {
+        "node": "*"
+      }
+    },
     "node_modules/asynckit": {
       "version": "0.4.0",
       "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -1202,6 +1239,15 @@
       "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
       "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="
     },
+    "node_modules/cac": {
+      "version": "6.7.14",
+      "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+      "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/call-bind": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
@@ -1265,6 +1311,24 @@
         "upper-case-first": "^2.0.2"
       }
     },
+    "node_modules/chai": {
+      "version": "4.3.7",
+      "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.7.tgz",
+      "integrity": "sha512-HLnAzZ2iupm25PlN0xFreAlBA5zaBSv3og0DdeGA4Ar6h6rJ3A0rolRUKJhSF2V10GZKDgWF/VmAEsNWjCRB+A==",
+      "dev": true,
+      "dependencies": {
+        "assertion-error": "^1.1.0",
+        "check-error": "^1.0.2",
+        "deep-eql": "^4.1.2",
+        "get-func-name": "^2.0.0",
+        "loupe": "^2.3.1",
+        "pathval": "^1.1.1",
+        "type-detect": "^4.0.5"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
     "node_modules/chalk": {
       "version": "2.4.2",
       "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
@@ -1297,6 +1361,15 @@
         "tslib": "^2.0.3"
       }
     },
+    "node_modules/check-error": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz",
+      "integrity": "sha512-BrgHpW9NURQgzoNyjfq0Wu6VFO6D7IZEmJNdtgNqpzGG8RuNFHt2jQxWlAs4HMe119chBnv+34syEZtc6IhLtA==",
+      "dev": true,
+      "engines": {
+        "node": "*"
+      }
+    },
     "node_modules/chevrotain": {
       "version": "4.1.1",
       "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-4.1.1.tgz",
@@ -1438,6 +1511,12 @@
         "node": ">= 8"
       }
     },
+    "node_modules/css.escape": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+      "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
+      "dev": true
+    },
     "node_modules/cssesc": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -1509,6 +1588,18 @@
       "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz",
       "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA=="
     },
+    "node_modules/deep-eql": {
+      "version": "4.1.3",
+      "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz",
+      "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==",
+      "dev": true,
+      "dependencies": {
+        "type-detect": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/deep-is": {
       "version": "0.1.4",
       "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -2930,6 +3021,15 @@
         "node": "6.* || 8.* || >= 10.*"
       }
     },
+    "node_modules/get-func-name": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz",
+      "integrity": "sha512-Hm0ixYtaSZ/V7C8FJrtZIuBBI+iSgL+1Aq82zSu8VQNB4S3Gk8e7Qs3VwBDJAhmRZcFqkl3tQu36g/Foh5I5ig==",
+      "dev": true,
+      "engines": {
+        "node": "*"
+      }
+    },
     "node_modules/get-intrinsic": {
       "version": "1.1.3",
       "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.3.tgz",
@@ -3083,6 +3183,20 @@
         "js-yaml": "bin/js-yaml.js"
       }
     },
+    "node_modules/happy-dom": {
+      "version": "8.1.4",
+      "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-8.1.4.tgz",
+      "integrity": "sha512-mUCzXHhSO6fOQlZwKW6z2f/+rYavKNxNrgY4nJ4dp+r8gTGbTENgMZGfM6eJD0DJPRFF8DFyngXdBF93wF96UA==",
+      "dev": true,
+      "dependencies": {
+        "css.escape": "^1.5.1",
+        "he": "^1.2.0",
+        "node-fetch": "^2.x.x",
+        "webidl-conversions": "^7.0.0",
+        "whatwg-encoding": "^2.0.0",
+        "whatwg-mimetype": "^3.0.0"
+      }
+    },
     "node_modules/has": {
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
@@ -3157,6 +3271,15 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/he": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
+      "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+      "dev": true,
+      "bin": {
+        "he": "bin/he"
+      }
+    },
     "node_modules/header-case": {
       "version": "2.0.4",
       "resolved": "https://registry.npmjs.org/header-case/-/header-case-2.0.4.tgz",
@@ -3748,6 +3871,18 @@
         "uc.micro": "^1.0.1"
       }
     },
+    "node_modules/local-pkg": {
+      "version": "0.4.2",
+      "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.4.2.tgz",
+      "integrity": "sha512-mlERgSPrbxU3BP4qBqAvvwlgW4MTg78iwJdGGnv7kibKjWcJksrG3t6LB5lXI93wXRDvG4NpUgJFmTG4T6rdrg==",
+      "dev": true,
+      "engines": {
+        "node": ">=14"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      }
+    },
     "node_modules/locate-path": {
       "version": "6.0.0",
       "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -3772,6 +3907,15 @@
       "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
       "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="
     },
+    "node_modules/loupe": {
+      "version": "2.3.6",
+      "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.6.tgz",
+      "integrity": "sha512-RaPMZKiMy8/JruncMU5Bt6na1eftNoo++R4Y+N2FrxkDVTrGvcyzFTsaGif4QTeKESheMGegbhw6iUAq+5A8zA==",
+      "dev": true,
+      "dependencies": {
+        "get-func-name": "^2.0.0"
+      }
+    },
     "node_modules/lower-case": {
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
@@ -3913,14 +4057,14 @@
       }
     },
     "node_modules/mlly": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.0.0.tgz",
-      "integrity": "sha512-QL108Hwt+u9bXdWgOI0dhzZfACovn5Aen4Xvc8Jasd9ouRH4NjnrXEiyP3nVvJo91zPlYjVRckta0Nt2zfoR6g==",
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.1.0.tgz",
+      "integrity": "sha512-cwzBrBfwGC1gYJyfcy8TcZU1f+dbH/T+TuOhtYP2wLv/Fb51/uV7HJQfBPtEupZ2ORLRU1EKFS/QfS3eo9+kBQ==",
       "dependencies": {
         "acorn": "^8.8.1",
         "pathe": "^1.0.0",
-        "pkg-types": "^1.0.0",
-        "ufo": "^1.0.0"
+        "pkg-types": "^1.0.1",
+        "ufo": "^1.0.1"
       }
     },
     "node_modules/mlly/node_modules/pathe": {
@@ -3974,6 +4118,48 @@
         "tslib": "^2.0.3"
       }
     },
+    "node_modules/node-fetch": {
+      "version": "2.6.8",
+      "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.8.tgz",
+      "integrity": "sha512-RZ6dBYuj8dRSfxpUSu+NsdF1dpPpluJxwOp+6IoDp/sH2QNDSvurYsAa+F1WxY2RjA1iP93xhcsUoYbF2XBqVg==",
+      "dev": true,
+      "dependencies": {
+        "whatwg-url": "^5.0.0"
+      },
+      "engines": {
+        "node": "4.x || >=6.0.0"
+      },
+      "peerDependencies": {
+        "encoding": "^0.1.0"
+      },
+      "peerDependenciesMeta": {
+        "encoding": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/node-fetch/node_modules/tr46": {
+      "version": "0.0.3",
+      "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
+      "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
+      "dev": true
+    },
+    "node_modules/node-fetch/node_modules/webidl-conversions": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+      "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
+      "dev": true
+    },
+    "node_modules/node-fetch/node_modules/whatwg-url": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
+      "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
+      "dev": true,
+      "dependencies": {
+        "tr46": "~0.0.3",
+        "webidl-conversions": "^3.0.0"
+      }
+    },
     "node_modules/node-releases": {
       "version": "2.0.8",
       "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.8.tgz",
@@ -4241,6 +4427,15 @@
       "resolved": "https://registry.npmjs.org/pathe/-/pathe-0.2.0.tgz",
       "integrity": "sha512-sTitTPYnn23esFR3RlqYBWn4c45WGeLcsKzQiUpXJAyfcWkolvlYpV8FLo7JishK946oQwMFUCHXQ9AjGPKExw=="
     },
+    "node_modules/pathval": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz",
+      "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==",
+      "dev": true,
+      "engines": {
+        "node": "*"
+      }
+    },
     "node_modules/picocolors": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
@@ -4683,6 +4878,12 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/siginfo": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+      "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+      "dev": true
+    },
     "node_modules/sirv": {
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.2.tgz",
@@ -4757,6 +4958,12 @@
       "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
       "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="
     },
+    "node_modules/stackback": {
+      "version": "0.0.2",
+      "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+      "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+      "dev": true
+    },
     "node_modules/statuses": {
       "version": "1.5.0",
       "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz",
@@ -4842,6 +5049,18 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/strip-literal": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-1.0.0.tgz",
+      "integrity": "sha512-5o4LsH1lzBzO9UFH63AJ2ad2/S2AVx6NtjOcaz+VTT2h1RiRvbipW72z8M/lxEhcPHDBQwpDrnTF7sXy/7OwCQ==",
+      "dev": true,
+      "dependencies": {
+        "acorn": "^8.8.1"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      }
+    },
     "node_modules/style-mod": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.0.0.tgz",
@@ -4879,6 +5098,12 @@
       "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
       "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="
     },
+    "node_modules/tinybench": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.3.1.tgz",
+      "integrity": "sha512-hGYWYBMPr7p4g5IarQE7XhlyWveh1EKhy4wUBS1LrHXCKYgvz+4/jCqgmJqZxxldesn05vccrtME2RLLZNW7iA==",
+      "dev": true
+    },
     "node_modules/tinypool": {
       "version": "0.1.3",
       "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.1.3.tgz",
@@ -4887,6 +5112,15 @@
         "node": ">=14.0.0"
       }
     },
+    "node_modules/tinyspy": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-1.0.2.tgz",
+      "integrity": "sha512-bSGlgwLBYf7PnUsQ6WOc6SJ3pGOcd+d8AA6EUnLDDM0kWEstC1JIlSZA3UNliDXhd9ABoS7hiRBDCu+XP/sf1Q==",
+      "dev": true,
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
     "node_modules/to-fast-properties": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
@@ -4999,6 +5233,15 @@
         "node": ">= 0.8.0"
       }
     },
+    "node_modules/type-detect": {
+      "version": "4.0.8",
+      "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
+      "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==",
+      "dev": true,
+      "engines": {
+        "node": ">=4"
+      }
+    },
     "node_modules/type-fest": {
       "version": "0.20.2",
       "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
@@ -5233,6 +5476,99 @@
         "fsevents": "~2.3.2"
       }
     },
+    "node_modules/vitest": {
+      "version": "0.27.1",
+      "resolved": "https://registry.npmjs.org/vitest/-/vitest-0.27.1.tgz",
+      "integrity": "sha512-1sIpQ1DVFTEn7c1ici1XHcVfdU4nKiBmPtPAtGKJJJLuJjojTv/OHGgcf69P57alM4ty8V4NMv+7Yoi5Cxqx9g==",
+      "dev": true,
+      "dependencies": {
+        "@types/chai": "^4.3.4",
+        "@types/chai-subset": "^1.3.3",
+        "@types/node": "*",
+        "acorn": "^8.8.1",
+        "acorn-walk": "^8.2.0",
+        "cac": "^6.7.14",
+        "chai": "^4.3.7",
+        "debug": "^4.3.4",
+        "local-pkg": "^0.4.2",
+        "picocolors": "^1.0.0",
+        "source-map": "^0.6.1",
+        "strip-literal": "^1.0.0",
+        "tinybench": "^2.3.1",
+        "tinypool": "^0.3.0",
+        "tinyspy": "^1.0.2",
+        "vite": "^3.0.0 || ^4.0.0",
+        "vite-node": "0.27.1",
+        "why-is-node-running": "^2.2.2"
+      },
+      "bin": {
+        "vitest": "vitest.mjs"
+      },
+      "engines": {
+        "node": ">=v14.16.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      },
+      "peerDependencies": {
+        "@edge-runtime/vm": "*",
+        "@vitest/browser": "*",
+        "@vitest/ui": "*",
+        "happy-dom": "*",
+        "jsdom": "*"
+      },
+      "peerDependenciesMeta": {
+        "@edge-runtime/vm": {
+          "optional": true
+        },
+        "@vitest/browser": {
+          "optional": true
+        },
+        "@vitest/ui": {
+          "optional": true
+        },
+        "happy-dom": {
+          "optional": true
+        },
+        "jsdom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vitest/node_modules/tinypool": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.3.0.tgz",
+      "integrity": "sha512-NX5KeqHOBZU6Bc0xj9Vr5Szbb1j8tUHIeD18s41aDJaPeC5QTdEhK0SpdpUrZlj2nv5cctNcSjaKNanXlfcVEQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
+    "node_modules/vitest/node_modules/vite-node": {
+      "version": "0.27.1",
+      "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-0.27.1.tgz",
+      "integrity": "sha512-d6+ue/3NzsfndWaPbYh/bFkHbmAWfDXI4B874zRx+WREnG6CUHUbBC8lKaRYZjeR6gCPN5m1aVNNRXBYICA9XA==",
+      "dev": true,
+      "dependencies": {
+        "cac": "^6.7.14",
+        "debug": "^4.3.4",
+        "mlly": "^1.1.0",
+        "pathe": "^0.2.0",
+        "picocolors": "^1.0.0",
+        "source-map": "^0.6.1",
+        "source-map-support": "^0.5.21",
+        "vite": "^3.0.0 || ^4.0.0"
+      },
+      "bin": {
+        "vite-node": "vite-node.mjs"
+      },
+      "engines": {
+        "node": ">=v14.16.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      }
+    },
     "node_modules/vue": {
       "version": "3.2.45",
       "resolved": "https://registry.npmjs.org/vue/-/vue-3.2.45.tgz",
@@ -5445,6 +5781,22 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/why-is-node-running": {
+      "version": "2.2.2",
+      "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.2.2.tgz",
+      "integrity": "sha512-6tSwToZxTOcotxHeA+qGCq1mVzKR3CwcJGmVcY+QE8SHy6TnpFnh8PAvPNHYr7EcuVeG0QSMxtYCuO1ta/G/oA==",
+      "dev": true,
+      "dependencies": {
+        "siginfo": "^2.0.0",
+        "stackback": "0.0.2"
+      },
+      "bin": {
+        "why-is-node-running": "cli.js"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/word-wrap": {
       "version": "1.2.3",
       "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
@@ -6160,6 +6512,21 @@
       "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
       "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A=="
     },
+    "@types/chai": {
+      "version": "4.3.4",
+      "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.4.tgz",
+      "integrity": "sha512-KnRanxnpfpjUTqTCXslZSEdLfXExwgNxYPdiO2WGUj8+HDjFi8R3k5RVKPeSCzLjCcshCAtVO2QBbVuAV4kTnw==",
+      "dev": true
+    },
+    "@types/chai-subset": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/@types/chai-subset/-/chai-subset-1.3.3.tgz",
+      "integrity": "sha512-frBecisrNGz+F4T6bcc+NLeolfiojh5FxW2klu669+8BARtyQv2C/GkNW6FUodVe4BroGMP/wER/YDGc7rEllw==",
+      "dev": true,
+      "requires": {
+        "@types/chai": "*"
+      }
+    },
     "@types/flexsearch": {
       "version": "0.7.3",
       "resolved": "https://registry.npmjs.org/@types/flexsearch/-/flexsearch-0.7.3.tgz",
@@ -6312,6 +6679,12 @@
       "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.2.45.tgz",
       "integrity": "sha512-Ewzq5Yhimg7pSztDV+RH1UDKBzmtqieXQlpTVm2AwraoRL/Rks96mvd8Vgi7Lj+h+TH8dv7mXD3FRZR3TUvbSg=="
     },
+    "@vue/test-utils": {
+      "version": "2.2.7",
+      "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.2.7.tgz",
+      "integrity": "sha512-BMuoruUFTEqhLoOgsMcgNVMiByYbfHCKGr2C4CPdGtz/affUtDVX5zr1RnPuq0tYSiaqq+Enw5voUpG6JY8Q7g==",
+      "requires": {}
+    },
     "abab": {
       "version": "2.0.6",
       "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz",
@@ -6411,6 +6784,12 @@
         "es-shim-unscopables": "^1.0.0"
       }
     },
+    "assertion-error": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz",
+      "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==",
+      "dev": true
+    },
     "asynckit": {
       "version": "0.4.0",
       "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -6490,6 +6869,12 @@
       "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
       "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="
     },
+    "cac": {
+      "version": "6.7.14",
+      "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+      "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+      "dev": true
+    },
     "call-bind": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
@@ -6533,6 +6918,21 @@
         "upper-case-first": "^2.0.2"
       }
     },
+    "chai": {
+      "version": "4.3.7",
+      "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.7.tgz",
+      "integrity": "sha512-HLnAzZ2iupm25PlN0xFreAlBA5zaBSv3og0DdeGA4Ar6h6rJ3A0rolRUKJhSF2V10GZKDgWF/VmAEsNWjCRB+A==",
+      "dev": true,
+      "requires": {
+        "assertion-error": "^1.1.0",
+        "check-error": "^1.0.2",
+        "deep-eql": "^4.1.2",
+        "get-func-name": "^2.0.0",
+        "loupe": "^2.3.1",
+        "pathval": "^1.1.1",
+        "type-detect": "^4.0.5"
+      }
+    },
     "chalk": {
       "version": "2.4.2",
       "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
@@ -6562,6 +6962,12 @@
         "tslib": "^2.0.3"
       }
     },
+    "check-error": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz",
+      "integrity": "sha512-BrgHpW9NURQgzoNyjfq0Wu6VFO6D7IZEmJNdtgNqpzGG8RuNFHt2jQxWlAs4HMe119chBnv+34syEZtc6IhLtA==",
+      "dev": true
+    },
     "chevrotain": {
       "version": "4.1.1",
       "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-4.1.1.tgz",
@@ -6682,6 +7088,12 @@
         "which": "^2.0.1"
       }
     },
+    "css.escape": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+      "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
+      "dev": true
+    },
     "cssesc": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -6735,6 +7147,15 @@
       "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz",
       "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA=="
     },
+    "deep-eql": {
+      "version": "4.1.3",
+      "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz",
+      "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==",
+      "dev": true,
+      "requires": {
+        "type-detect": "^4.0.0"
+      }
+    },
     "deep-is": {
       "version": "0.1.4",
       "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -7679,6 +8100,12 @@
       "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
       "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="
     },
+    "get-func-name": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz",
+      "integrity": "sha512-Hm0ixYtaSZ/V7C8FJrtZIuBBI+iSgL+1Aq82zSu8VQNB4S3Gk8e7Qs3VwBDJAhmRZcFqkl3tQu36g/Foh5I5ig==",
+      "dev": true
+    },
     "get-intrinsic": {
       "version": "1.1.3",
       "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.3.tgz",
@@ -7792,6 +8219,20 @@
         }
       }
     },
+    "happy-dom": {
+      "version": "8.1.4",
+      "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-8.1.4.tgz",
+      "integrity": "sha512-mUCzXHhSO6fOQlZwKW6z2f/+rYavKNxNrgY4nJ4dp+r8gTGbTENgMZGfM6eJD0DJPRFF8DFyngXdBF93wF96UA==",
+      "dev": true,
+      "requires": {
+        "css.escape": "^1.5.1",
+        "he": "^1.2.0",
+        "node-fetch": "^2.x.x",
+        "webidl-conversions": "^7.0.0",
+        "whatwg-encoding": "^2.0.0",
+        "whatwg-mimetype": "^3.0.0"
+      }
+    },
     "has": {
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
@@ -7836,6 +8277,12 @@
         "has-symbols": "^1.0.2"
       }
     },
+    "he": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
+      "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+      "dev": true
+    },
     "header-case": {
       "version": "2.0.4",
       "resolved": "https://registry.npmjs.org/header-case/-/header-case-2.0.4.tgz",
@@ -8251,6 +8698,12 @@
         "uc.micro": "^1.0.1"
       }
     },
+    "local-pkg": {
+      "version": "0.4.2",
+      "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.4.2.tgz",
+      "integrity": "sha512-mlERgSPrbxU3BP4qBqAvvwlgW4MTg78iwJdGGnv7kibKjWcJksrG3t6LB5lXI93wXRDvG4NpUgJFmTG4T6rdrg==",
+      "dev": true
+    },
     "locate-path": {
       "version": "6.0.0",
       "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -8269,6 +8722,15 @@
       "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
       "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="
     },
+    "loupe": {
+      "version": "2.3.6",
+      "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.6.tgz",
+      "integrity": "sha512-RaPMZKiMy8/JruncMU5Bt6na1eftNoo++R4Y+N2FrxkDVTrGvcyzFTsaGif4QTeKESheMGegbhw6iUAq+5A8zA==",
+      "dev": true,
+      "requires": {
+        "get-func-name": "^2.0.0"
+      }
+    },
     "lower-case": {
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
@@ -8381,14 +8843,14 @@
       "integrity": "sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g=="
     },
     "mlly": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.0.0.tgz",
-      "integrity": "sha512-QL108Hwt+u9bXdWgOI0dhzZfACovn5Aen4Xvc8Jasd9ouRH4NjnrXEiyP3nVvJo91zPlYjVRckta0Nt2zfoR6g==",
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.1.0.tgz",
+      "integrity": "sha512-cwzBrBfwGC1gYJyfcy8TcZU1f+dbH/T+TuOhtYP2wLv/Fb51/uV7HJQfBPtEupZ2ORLRU1EKFS/QfS3eo9+kBQ==",
       "requires": {
         "acorn": "^8.8.1",
         "pathe": "^1.0.0",
-        "pkg-types": "^1.0.0",
-        "ufo": "^1.0.0"
+        "pkg-types": "^1.0.1",
+        "ufo": "^1.0.1"
       },
       "dependencies": {
         "pathe": {
@@ -8432,6 +8894,39 @@
         "tslib": "^2.0.3"
       }
     },
+    "node-fetch": {
+      "version": "2.6.8",
+      "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.8.tgz",
+      "integrity": "sha512-RZ6dBYuj8dRSfxpUSu+NsdF1dpPpluJxwOp+6IoDp/sH2QNDSvurYsAa+F1WxY2RjA1iP93xhcsUoYbF2XBqVg==",
+      "dev": true,
+      "requires": {
+        "whatwg-url": "^5.0.0"
+      },
+      "dependencies": {
+        "tr46": {
+          "version": "0.0.3",
+          "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
+          "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
+          "dev": true
+        },
+        "webidl-conversions": {
+          "version": "3.0.1",
+          "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+          "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
+          "dev": true
+        },
+        "whatwg-url": {
+          "version": "5.0.0",
+          "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
+          "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
+          "dev": true,
+          "requires": {
+            "tr46": "~0.0.3",
+            "webidl-conversions": "^3.0.0"
+          }
+        }
+      }
+    },
     "node-releases": {
       "version": "2.0.8",
       "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.8.tgz",
@@ -8626,6 +9121,12 @@
       "resolved": "https://registry.npmjs.org/pathe/-/pathe-0.2.0.tgz",
       "integrity": "sha512-sTitTPYnn23esFR3RlqYBWn4c45WGeLcsKzQiUpXJAyfcWkolvlYpV8FLo7JishK946oQwMFUCHXQ9AjGPKExw=="
     },
+    "pathval": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz",
+      "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==",
+      "dev": true
+    },
     "picocolors": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
@@ -8915,6 +9416,12 @@
         "object-inspect": "^1.9.0"
       }
     },
+    "siginfo": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+      "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+      "dev": true
+    },
     "sirv": {
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.2.tgz",
@@ -8973,6 +9480,12 @@
       "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
       "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="
     },
+    "stackback": {
+      "version": "0.0.2",
+      "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+      "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+      "dev": true
+    },
     "statuses": {
       "version": "1.5.0",
       "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz",
@@ -9031,6 +9544,15 @@
       "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
       "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="
     },
+    "strip-literal": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-1.0.0.tgz",
+      "integrity": "sha512-5o4LsH1lzBzO9UFH63AJ2ad2/S2AVx6NtjOcaz+VTT2h1RiRvbipW72z8M/lxEhcPHDBQwpDrnTF7sXy/7OwCQ==",
+      "dev": true,
+      "requires": {
+        "acorn": "^8.8.1"
+      }
+    },
     "style-mod": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.0.0.tgz",
@@ -9059,11 +9581,23 @@
       "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
       "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="
     },
+    "tinybench": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.3.1.tgz",
+      "integrity": "sha512-hGYWYBMPr7p4g5IarQE7XhlyWveh1EKhy4wUBS1LrHXCKYgvz+4/jCqgmJqZxxldesn05vccrtME2RLLZNW7iA==",
+      "dev": true
+    },
     "tinypool": {
       "version": "0.1.3",
       "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.1.3.tgz",
       "integrity": "sha512-2IfcQh7CP46XGWGGbdyO4pjcKqsmVqFAPcXfPxcPXmOWt9cYkTP9HcDmGgsfijYoAEc4z9qcpM/BaBz46Y9/CQ=="
     },
+    "tinyspy": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-1.0.2.tgz",
+      "integrity": "sha512-bSGlgwLBYf7PnUsQ6WOc6SJ3pGOcd+d8AA6EUnLDDM0kWEstC1JIlSZA3UNliDXhd9ABoS7hiRBDCu+XP/sf1Q==",
+      "dev": true
+    },
     "to-fast-properties": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
@@ -9150,6 +9684,12 @@
         "prelude-ls": "^1.2.1"
       }
     },
+    "type-detect": {
+      "version": "4.0.8",
+      "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
+      "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==",
+      "dev": true
+    },
     "type-fest": {
       "version": "0.20.2",
       "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
@@ -9292,6 +9832,56 @@
         "toml-eslint-parser": "^0.4.0"
       }
     },
+    "vitest": {
+      "version": "0.27.1",
+      "resolved": "https://registry.npmjs.org/vitest/-/vitest-0.27.1.tgz",
+      "integrity": "sha512-1sIpQ1DVFTEn7c1ici1XHcVfdU4nKiBmPtPAtGKJJJLuJjojTv/OHGgcf69P57alM4ty8V4NMv+7Yoi5Cxqx9g==",
+      "dev": true,
+      "requires": {
+        "@types/chai": "^4.3.4",
+        "@types/chai-subset": "^1.3.3",
+        "@types/node": "*",
+        "acorn": "^8.8.1",
+        "acorn-walk": "^8.2.0",
+        "cac": "^6.7.14",
+        "chai": "^4.3.7",
+        "debug": "^4.3.4",
+        "local-pkg": "^0.4.2",
+        "picocolors": "^1.0.0",
+        "source-map": "^0.6.1",
+        "strip-literal": "^1.0.0",
+        "tinybench": "^2.3.1",
+        "tinypool": "^0.3.0",
+        "tinyspy": "^1.0.2",
+        "vite": "^3.0.0 || ^4.0.0",
+        "vite-node": "0.27.1",
+        "why-is-node-running": "^2.2.2"
+      },
+      "dependencies": {
+        "tinypool": {
+          "version": "0.3.0",
+          "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.3.0.tgz",
+          "integrity": "sha512-NX5KeqHOBZU6Bc0xj9Vr5Szbb1j8tUHIeD18s41aDJaPeC5QTdEhK0SpdpUrZlj2nv5cctNcSjaKNanXlfcVEQ==",
+          "dev": true
+        },
+        "vite-node": {
+          "version": "0.27.1",
+          "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-0.27.1.tgz",
+          "integrity": "sha512-d6+ue/3NzsfndWaPbYh/bFkHbmAWfDXI4B874zRx+WREnG6CUHUbBC8lKaRYZjeR6gCPN5m1aVNNRXBYICA9XA==",
+          "dev": true,
+          "requires": {
+            "cac": "^6.7.14",
+            "debug": "^4.3.4",
+            "mlly": "^1.1.0",
+            "pathe": "^0.2.0",
+            "picocolors": "^1.0.0",
+            "source-map": "^0.6.1",
+            "source-map-support": "^0.5.21",
+            "vite": "^3.0.0 || ^4.0.0"
+          }
+        }
+      }
+    },
     "vue": {
       "version": "3.2.45",
       "resolved": "https://registry.npmjs.org/vue/-/vue-3.2.45.tgz",
@@ -9444,6 +10034,16 @@
         "is-typed-array": "^1.1.10"
       }
     },
+    "why-is-node-running": {
+      "version": "2.2.2",
+      "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.2.2.tgz",
+      "integrity": "sha512-6tSwToZxTOcotxHeA+qGCq1mVzKR3CwcJGmVcY+QE8SHy6TnpFnh8PAvPNHYr7EcuVeG0QSMxtYCuO1ta/G/oA==",
+      "dev": true,
+      "requires": {
+        "siginfo": "^2.0.0",
+        "stackback": "0.0.2"
+      }
+    },
     "word-wrap": {
       "version": "1.2.3",
       "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
diff --git a/package.json b/package.json
index 27fb2de0..c9aef901 100644
--- a/package.json
+++ b/package.json
@@ -4,7 +4,7 @@
   "author": "naltatis & andig",
   "scripts": {
     "build": "vite build",
-    "test": "",
+    "test": "TZ=Europe/Berlin vitest",
     "lint": "prettier assets './**/*.{yml,yaml,toml}' --write && eslint assets/**/*.js assets/**/*.vue --fix",
     "dev": "vite",
     "histoire": "histoire dev"
@@ -17,6 +17,7 @@
     "@popperjs/core": "^2.11.5",
     "@vitejs/plugin-vue": "^3.0.1",
     "@vue/compiler-sfc": "^3.2.30",
+    "@vue/test-utils": "^2.2.7",
     "axios": "^1.2.1",
     "bootstrap": "^5.2.2",
     "canvas-confetti": "^1.4.0",
@@ -48,5 +49,9 @@
     "node": ">=18.0.0"
   },
   "license": "MIT",
-  "repository": "github:evcc-io/evcc"
+  "repository": "github:evcc-io/evcc",
+  "devDependencies": {
+    "happy-dom": "^8.1.4",
+    "vitest": "^0.27.1"
+  }
 }
diff --git a/provider/http.go b/provider/http.go
index fe10d16d..decba6a8 100644
--- a/provider/http.go
+++ b/provider/http.go
@@ -136,6 +136,8 @@ func (p *HTTP) WithAuth(typ, user, password string) (*HTTP, error) {
 		log.Redact(basicAuth)
 
 		p.Client.Transport = transport.BasicAuth(user, password, p.Client.Transport)
+	case "bearer":
+		p.Client.Transport = transport.BearerAuth(password, p.Client.Transport)
 	case "digest":
 		p.Client.Transport = digest.NewTransport(user, password, p.Client.Transport)
 	default:
diff --git a/push/pushover.go b/push/pushover.go
index 619a6693..e8e570dc 100644
--- a/push/pushover.go
+++ b/push/pushover.go
@@ -2,6 +2,7 @@ package push
 
 import (
 	"errors"
+	"strings"
 
 	"github.com/evcc-io/evcc/util"
 	"github.com/gregdel/pushover"
@@ -15,6 +16,7 @@ func init() {
 type PushOver struct {
 	log        *util.Logger
 	app        *pushover.Pushover
+	device     string
 	recipients []string
 }
 
@@ -23,6 +25,7 @@ func NewPushOverFromConfig(other map[string]interface{}) (Messenger, error) {
 	var cc struct {
 		App        string
 		Recipients []string
+		Devices    []string
 		Events     map[string]EventTemplate
 	}
 
@@ -37,6 +40,7 @@ func NewPushOverFromConfig(other map[string]interface{}) (Messenger, error) {
 	m := &PushOver{
 		log:        util.NewLogger("pushover"),
 		app:        pushover.New(cc.App),
+		device:     strings.Join(cc.Devices, ","),
 		recipients: cc.Recipients,
 	}
 
@@ -46,6 +50,7 @@ func NewPushOverFromConfig(other map[string]interface{}) (Messenger, error) {
 // Send sends to all receivers
 func (m *PushOver) Send(title, msg string) {
 	message := pushover.NewMessageWithTitle(msg, title)
+	message.DeviceName = m.device
 
 	for _, id := range m.recipients {
 		go func(id string) {
diff --git a/server/http_session_handler.go b/server/http_session_handler.go
index f8172db3..b83fb3a7 100644
--- a/server/http_session_handler.go
+++ b/server/http_session_handler.go
@@ -4,7 +4,9 @@ import (
 	"context"
 	"encoding/json"
 	"errors"
+	"fmt"
 	"net/http"
+	"strconv"
 
 	"github.com/evcc-io/evcc/api"
 	"github.com/evcc-io/evcc/core/db"
@@ -14,9 +16,9 @@ import (
 	"golang.org/x/text/language"
 )
 
-func csvResult(ctx context.Context, w http.ResponseWriter, res any) {
+func csvResult(ctx context.Context, w http.ResponseWriter, res any, filename string) {
 	w.Header().Set("Content-Type", "text/csv")
-	w.Header().Set("Content-Disposition", `attachment; filename="sessions.csv"`)
+	w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`.csv"`)
 
 	if ww, ok := res.(api.CsvWriter); ok {
 		_ = ww.WriteCsv(ctx, w)
@@ -33,8 +35,31 @@ func sessionHandler(w http.ResponseWriter, r *http.Request) {
 	}
 
 	var res db.Sessions
+	year := r.URL.Query().Get("year")
+	month := r.URL.Query().Get("month")
 
-	if txn := dbserver.Instance.Table("sessions").Where("charged_kwh>=0.05").Order("created desc").Find(&res); txn.Error != nil {
+	filename := "session"
+
+	fmtYear := "%"
+	fmtMonth := "%"
+
+	if year != "" {
+		fmtYear = year
+		filename += "-" + fmtYear
+
+		if month != "" {
+			iMonth, err := strconv.Atoi(month)
+			if err != nil {
+				jsonError(w, http.StatusInternalServerError, err)
+				return
+			}
+			fmtMonth = fmt.Sprintf("%02d", iMonth)
+			filename += "." + fmtMonth
+		}
+	}
+
+	whereQuery := "charged_kwh>=0.05 and strftime('%Y', created) like ? and strftime('%m', created) like ?"
+	if txn := dbserver.Instance.Where(whereQuery, fmtYear, fmtMonth).Order("created desc").Find(&res); txn.Error != nil {
 		jsonError(w, http.StatusInternalServerError, txn.Error)
 		return
 	}
@@ -50,7 +75,7 @@ func sessionHandler(w http.ResponseWriter, r *http.Request) {
 		}
 
 		ctx := context.WithValue(context.Background(), locale.Locale, lang)
-		csvResult(ctx, w, &res)
+		csvResult(ctx, w, &res, filename)
 		return
 	}
 
diff --git a/server/influxdb.go b/server/influxdb.go
index 938ed648..ba5f69e5 100644
--- a/server/influxdb.go
+++ b/server/influxdb.go
@@ -2,12 +2,16 @@ package server
 
 import (
 	"fmt"
+	"reflect"
+	"strconv"
+	"strings"
 	"sync"
 	"time"
 
 	"github.com/evcc-io/evcc/core/loadpoint"
 	"github.com/evcc-io/evcc/util"
 	influxdb2 "github.com/influxdata/influxdb-client-go/v2"
+	"github.com/influxdata/influxdb-client-go/v2/api"
 	influxlog "github.com/influxdata/influxdb-client-go/v2/log"
 )
 
@@ -54,22 +58,10 @@ func NewInfluxClient(url, token, org, user, password, database string) *Influx {
 	}
 }
 
-// supportedType checks if type can be written as influx value
-func (m *Influx) supportedType(p util.Param) bool {
-	if p.Val == nil {
-		return true
-	}
-
-	switch val := p.Val.(type) {
-	case int, int64, float64:
-		return true
-	case [3]float64:
-		return true
-	case []float64:
-		return len(val) == 3
-	default:
-		return false
-	}
+// writePoint asynchronously writes a point to influx
+func (m *Influx) writePoint(writer api.WriteAPI, key string, fields map[string]any, tags map[string]string) {
+	m.log.TRACE.Printf("write %s=%v (%v)", key, fields, tags)
+	writer.WritePoint(influxdb2.NewPoint(key, tags, fields, time.Now()))
 }
 
 // Run Influx publisher
@@ -90,49 +82,64 @@ func (m *Influx) Run(loadPoints []loadpoint.API, in <-chan util.Param) {
 	// add points to batch for async writing
 	for param := range in {
 		// vehicle name
-		if param.Loadpoint != nil {
-			if name, ok := param.Val.(string); ok && param.Key == "vehicleTitle" {
-				vehicles[*param.Loadpoint] = name
+		if param.Loadpoint != nil && param.Key == "vehicleTitle" {
+			if vehicle, ok := param.Val.(string); ok {
+				vehicles[*param.Loadpoint] = vehicle
 				continue
 			}
 		}
 
-		if !m.supportedType(param) {
-			continue
-		}
+		fields := make(map[string]any)
 
-		tags := map[string]string{}
+		tags := make(map[string]string)
 		if param.Loadpoint != nil {
 			tags["loadpoint"] = loadPoints[*param.Loadpoint].Name()
 			tags["vehicle"] = vehicles[*param.Loadpoint]
 		}
 
-		fields := map[string]interface{}{}
-
-		// array to slice
-		val := param.Val
-		if v, ok := val.([3]float64); ok {
-			val = v[:]
-		}
+		switch val := param.Val.(type) {
+		case int, int64, float64:
+			fields["value"] = param.Val
 
-		// add slice as phase values
-		if phases, ok := val.([]float64); ok {
-			var total float64
-			for i, v := range phases {
-				total += v
+		case [3]float64:
+			// add array as phase values
+			for i, v := range val {
 				fields[fmt.Sprintf("l%d", i+1)] = v
 			}
 
-			// add total as "value"
-			val = total
-		}
+		default:
+			// allow writing nil values
+			if param.Val == nil {
+				break
+			}
+
+			// slice of structs
+			if typ := reflect.TypeOf(param.Val); typ.Kind() == reflect.Slice && typ.Elem().Kind() == reflect.Struct {
+				val := reflect.ValueOf(param.Val)
+
+				// loop slice
+				for i := 0; i < val.Len(); i++ {
+					val := val.Index(i)
+					typ := val.Type()
+
+					// loop struct
+					for j := 0; j < typ.NumField(); j++ {
+						n := typ.Field(j).Name
+						v := val.Field(j).Interface()
+
+						key := param.Key + strings.ToUpper(n[:1]) + n[1:]
+						fields["value"] = v
+						tags["id"] = strconv.Itoa(i + 1)
 
-		fields["value"] = val
+						m.writePoint(writer, key, fields, tags)
+					}
+				}
+			}
+
+			continue
+		}
 
-		// write asynchronously
-		m.log.TRACE.Printf("write %s=%v (%v)", param.Key, param.Val, tags)
-		p := influxdb2.NewPoint(param.Key, tags, fields, time.Now())
-		writer.WritePoint(p)
+		m.writePoint(writer, param.Key, fields, tags)
 	}
 
 	m.client.Close()
diff --git a/server/socket.go b/server/socket.go
index a7d812be..1c83dcb9 100644
--- a/server/socket.go
+++ b/server/socket.go
@@ -94,7 +94,7 @@ func encode(v interface{}) (string, error) {
 	case time.Time:
 		var b []byte
 		if !val.IsZero() {
-			b, _ = val.MarshalText()
+			b, _ = val.Round(time.Second).Local().MarshalText()
 		}
 		s = fmt.Sprintf(`"%s"`, string(b))
 	case time.Duration:
diff --git a/tariff/awattar.go b/tariff/awattar.go
index f1c16ca6..592b08c7 100644
--- a/tariff/awattar.go
+++ b/tariff/awattar.go
@@ -10,6 +10,7 @@ import (
 	"github.com/evcc-io/evcc/tariff/awattar"
 	"github.com/evcc-io/evcc/util"
 	"github.com/evcc-io/evcc/util/request"
+	"golang.org/x/exp/slices"
 )
 
 type Awattar struct {
@@ -103,5 +104,5 @@ func (t *Awattar) Unit() string {
 func (t *Awattar) Rates() (api.Rates, error) {
 	t.mux.Lock()
 	defer t.mux.Unlock()
-	return append([]api.Rate{}, t.data...), outdatedError(t.updated, time.Hour)
+	return slices.Clone(t.data), outdatedError(t.updated, time.Hour)
 }
diff --git a/tariff/fixed/day.go b/tariff/fixed/day.go
index 27d56733..bbee9961 100644
--- a/tariff/fixed/day.go
+++ b/tariff/fixed/day.go
@@ -78,7 +78,14 @@ func ParseDays(s string) ([]Day, error) {
 			return nil, fmt.Errorf("invalid day range: %s", segment)
 		}
 
-		from, err := ParseDay(fromto[0])
+		fromToFrom := fromto[0]
+
+		// single empty segment
+		if len(fromto) == 1 && strings.TrimSpace(fromToFrom) == "" {
+			return slices.Clone(Week), nil
+		}
+
+		from, err := ParseDay(fromToFrom)
 		if err != nil {
 			return nil, err
 		}
diff --git a/tariff/fixed/timerange.go b/tariff/fixed/timerange.go
index ec91c2db..310c11a4 100644
--- a/tariff/fixed/timerange.go
+++ b/tariff/fixed/timerange.go
@@ -75,6 +75,10 @@ func ParseTimeRange(s string) (TimeRange, error) {
 		return TimeRange{}, err
 	}
 
+	if tom := to.Minutes(); tom != 0 && from.Minutes() >= tom {
+		return TimeRange{}, fmt.Errorf("invalid time range: %s, <from> must be before <to>", s)
+	}
+
 	return TimeRange{from, to}, nil
 }
 
diff --git a/tariff/gruenstromindex.go b/tariff/gruenstromindex.go
index 85530768..556b5be6 100644
--- a/tariff/gruenstromindex.go
+++ b/tariff/gruenstromindex.go
@@ -9,6 +9,7 @@ import (
 	"github.com/evcc-io/evcc/api"
 	"github.com/evcc-io/evcc/util"
 	"github.com/evcc-io/evcc/util/request"
+	"golang.org/x/exp/slices"
 )
 
 type GrünStromIndex struct {
@@ -132,5 +133,5 @@ func (t *GrünStromIndex) Unit() string {
 func (t *GrünStromIndex) Rates() (api.Rates, error) {
 	t.mux.Lock()
 	defer t.mux.Unlock()
-	return append(api.Rates{}, t.data...), outdatedError(t.updated, time.Hour)
+	return slices.Clone(t.data), outdatedError(t.updated, time.Hour)
 }
diff --git a/tariff/octopus.go b/tariff/octopus.go
index 5c280b8e..b25588c9 100644
--- a/tariff/octopus.go
+++ b/tariff/octopus.go
@@ -2,13 +2,14 @@ package tariff
 
 import (
 	"errors"
-	"github.com/evcc-io/evcc/tariff/octopus"
 	"sync"
 	"time"
 
 	"github.com/evcc-io/evcc/api"
+	"github.com/evcc-io/evcc/tariff/octopus"
 	"github.com/evcc-io/evcc/util"
 	"github.com/evcc-io/evcc/util/request"
+	"golang.org/x/exp/slices"
 )
 
 type Octopus struct {
@@ -99,5 +100,5 @@ func (t *Octopus) Unit() string {
 func (t *Octopus) Rates() (api.Rates, error) {
 	t.mux.Lock()
 	defer t.mux.Unlock()
-	return append([]api.Rate{}, t.data...), outdatedError(t.updated, time.Hour)
+	return slices.Clone(t.data), outdatedError(t.updated, time.Hour)
 }
diff --git a/tariff/tibber.go b/tariff/tibber.go
index 1ab21de5..9a41d846 100644
--- a/tariff/tibber.go
+++ b/tariff/tibber.go
@@ -11,6 +11,7 @@ import (
 	"github.com/evcc-io/evcc/util"
 	"github.com/evcc-io/evcc/util/request"
 	"github.com/shurcooL/graphql"
+	"golang.org/x/exp/slices"
 )
 
 type Tibber struct {
@@ -144,5 +145,5 @@ func (t *Tibber) Unit() string {
 func (t *Tibber) Rates() (api.Rates, error) {
 	t.mux.Lock()
 	defer t.mux.Unlock()
-	return append([]api.Rate{}, t.data...), outdatedError(t.updated, time.Hour)
+	return slices.Clone(t.data), outdatedError(t.updated, time.Hour)
 }
diff --git a/templates/definition/charger/go-e-gemini.yaml b/templates/definition/charger/go-e-gemini.yaml
deleted file mode 100644
index 0d13baea..00000000
--- a/templates/definition/charger/go-e-gemini.yaml
+++ /dev/null
@@ -1,17 +0,0 @@
-template: go-e-gemini
-products:
-  - brand: go-eCharger
-    description:
-      generic: Gemini
-capabilities: ["1p3p", "rfid"]
-requirements:
-  description:
-    de: Für 1P/3P-Phasenumschaltung muss die HTTP API v2 im Charger aktiviert sein.
-    en: For 1P/3P-Phase switching the HTTP API v2 in the charger setup needs to be activated.
-  uri: https://docs.evcc.io/docs/devices/chargers/#gemini--
-  evcc: ["sponsorship"]
-params:
-  - name: host
-render: |
-  type: go-e
-  uri: http://{{ .host }}
diff --git a/templates/definition/charger/go-e-v3.yaml b/templates/definition/charger/go-e-v3.yaml
index fc268910..de79a1b9 100644
--- a/templates/definition/charger/go-e-v3.yaml
+++ b/templates/definition/charger/go-e-v3.yaml
@@ -1,8 +1,9 @@
 template: go-e-v3
+covers: ["go-e-gemini"]
 products:
   - brand: go-eCharger
     description:
-      generic: HOME+, HOMEfix (V3)
+      generic: Gemini, HOME+, HOMEfix (V3)
 capabilities: ["1p3p", "rfid"]
 requirements:
   description:
diff --git a/templates/docs/charger/go-e-gemini_0.yaml b/templates/docs/charger/go-e-gemini_0.yaml
deleted file mode 100644
index 3da9a1ea..00000000
--- a/templates/docs/charger/go-e-gemini_0.yaml
+++ /dev/null
@@ -1,12 +0,0 @@
-product:
-  brand: go-eCharger
-  description: Gemini
-capabilities: ["1p3p", "rfid"]
-requirements: ["sponsorship"]
-description: |
-  Für 1P/3P-Phasenumschaltung muss die HTTP API v2 im Charger aktiviert sein.
-render:
-  - default: |
-      type: template
-      template: go-e-gemini
-      host: 192.0.2.2 # IP-Adresse oder Hostname
diff --git a/templates/docs/charger/go-e-v3_0.yaml b/templates/docs/charger/go-e-v3_0.yaml
index 98c111bc..1f2e120f 100644
--- a/templates/docs/charger/go-e-v3_0.yaml
+++ b/templates/docs/charger/go-e-v3_0.yaml
@@ -1,6 +1,6 @@
 product:
   brand: go-eCharger
-  description: HOME+, HOMEfix (V3)
+  description: Gemini, HOME+, HOMEfix (V3)
 capabilities: ["1p3p", "rfid"]
 requirements: ["sponsorship"]
 description: |
diff --git a/util/transport/bearer.go b/util/transport/bearer.go
new file mode 100644
index 00000000..51e370fa
--- /dev/null
+++ b/util/transport/bearer.go
@@ -0,0 +1,15 @@
+package transport
+
+import (
+	"net/http"
+)
+
+// BearerAuth creates an HTTP transport performing HTTP authorization using an OAuth 2.0 Bearer Token
+func BearerAuth(token string, base http.RoundTripper) http.RoundTripper {
+	return &Decorator{
+		Decorator: DecorateHeaders(map[string]string{
+			"Authorization": "Bearer " + token,
+		}),
+		Base: base,
+	}
+}
diff --git a/vehicle/vw/id/api.go b/vehicle/vw/id/api.go
index a973af01..d84e302d 100644
--- a/vehicle/vw/id/api.go
+++ b/vehicle/vw/id/api.go
@@ -64,7 +64,7 @@ func (v *API) Vehicles() ([]Vehicle, error) {
 // Status implements the /status response.
 // It is callers responsibility to check for embedded (partial) errors.
 func (v *API) Status(vin string) (res Status, err error) {
-	// NOTE use `all` to retrieve entire status (or charging,fuelStatus,climatisation)
+	// NOTE use `all` to retrieve entire status (or charging,fuelStatus,climatisation,measurements)
 	uri := fmt.Sprintf("%s/vehicles/%s/selectivestatus?jobs=all", BaseURL, vin)
 
 	req, err := request.New(http.MethodGet, uri, nil, request.AcceptJSON)


Test patch:
diff --git a/assets/js/components/TargetChargePlan.test.js b/assets/js/components/TargetChargePlan.test.js
new file mode 100644
index 00000000..eb8cc10f
--- /dev/null
+++ b/assets/js/components/TargetChargePlan.test.js
@@ -0,0 +1,136 @@
+import { mount, config } from "@vue/test-utils";
+import { beforeAll, describe, expect, test } from "vitest";
+import TargetChargePlan from "./TargetChargePlan.vue";
+
+config.global.mocks["$i18n"] = { locale: "de-DE" };
+config.global.mocks["$t"] = (a) => a;
+
+describe("basics", () => {
+  const DATE_START = new Date("2023-01-11T11:00:00+01:00");
+  const DATE_TARGET = new Date("2023-01-11T13:00:00+01:00");
+  const TARIFF_FIXED = [
+    {
+      start: "2023-01-11T11:00:00+01:00",
+      end: "2023-01-22T00:00:00+01:00",
+      price: 0.4,
+    },
+  ];
+  const PLAN = [
+    {
+      start: "2023-01-11T12:00:00+01:00",
+      end: "2023-01-11T13:00:00+01:00",
+    },
+  ];
+
+  const wrapper = mount(TargetChargePlan, {
+    props: {
+      plan: PLAN,
+      targetTime: DATE_TARGET,
+      rates: TARIFF_FIXED,
+    },
+  });
+  wrapper.setData({ startTime: DATE_START });
+
+  let result = null;
+
+  beforeAll(() => {
+    result = wrapper.vm.slots;
+  });
+
+  test("should return 42 slots", () => {
+    expect(result.length).eq(42);
+  });
+
+  test("slots should be an hour apart", () => {
+    expect(result[0].startHour).eq(11);
+    expect(result[0].endHour).eq(12);
+    expect(result[0].day).eq("Mi");
+
+    expect(result[1].startHour).eq(12);
+    expect(result[1].endHour).eq(13);
+    expect(result[1].day).eq("Mi");
+
+    expect(result[11].startHour).eq(22);
+    expect(result[11].endHour).eq(23);
+    expect(result[11].day).eq("Mi");
+
+    expect(result[24].startHour).eq(11);
+    expect(result[24].endHour).eq(12);
+    expect(result[24].day).eq("Do");
+  });
+
+  test("slots after target should be toLate", () => {
+    expect(result[0].toLate).eq(false);
+    expect(result[1].toLate).eq(false);
+    expect(result[2].toLate).eq(true);
+    expect(result[3].toLate).eq(true);
+  });
+
+  test("slots are marked if charging is happening in them", () => {
+    expect(result[0].charging).eq(false);
+    expect(result[1].charging).eq(true);
+    expect(result[2].charging).eq(false);
+    expect(result[3].charging).eq(false);
+  });
+
+  test("all slots have the same fixed price", () => {
+    result.forEach((slot) => expect(slot.price).eq(0.4));
+  });
+});
+
+describe("zoned tariffs", () => {
+  const DATE_START = new Date("2023-01-11T11:00:00+01:00");
+  const DATE_TARGET = new Date("2023-01-11T16:00:00+01:00");
+  const TARIFF_ZONED = [
+    {
+      start: "2023-01-11T11:00:00+01:00",
+      end: "2023-01-11T12:00:00+01:00",
+      price: 0.2,
+    },
+    {
+      start: "2023-01-11T12:00:00+01:00",
+      end: "2023-01-22T00:00:00+01:00",
+      price: 0.4,
+    },
+  ];
+  const PLAN = [
+    {
+      start: "2023-01-11T11:30:00+01:00",
+      end: "2023-01-11T13:00:00+01:00",
+    },
+    {
+      start: "2023-01-11T14:30:00+01:00",
+      end: "2023-01-11T16:00:00+01:00",
+    },
+  ];
+
+  let result = null;
+
+  const wrapper = mount(TargetChargePlan, {
+    props: {
+      plan: PLAN,
+      targetTime: DATE_TARGET,
+      rates: TARIFF_ZONED,
+    },
+  });
+  wrapper.setData({ startTime: DATE_START });
+
+  beforeAll(() => {
+    result = wrapper.vm.slots;
+  });
+
+  test("handle multiple charging slots", () => {
+    expect(result[0].charging).eq(true);
+    expect(result[1].charging).eq(true);
+    expect(result[2].charging).eq(false);
+    expect(result[3].charging).eq(true);
+    expect(result[4].charging).eq(true);
+    expect(result[5].charging).eq(false);
+  });
+
+  test("first slot is cheap, others are expensive", () => {
+    const [first, ...others] = result;
+    expect(first.price).eq(0.2);
+    others.forEach((slot) => expect(slot.price).eq(0.4));
+  });
+});
diff --git a/tariff/fixed/day_test.go b/tariff/fixed/day_test.go
index 9c63438a..d98aa28c 100644
--- a/tariff/fixed/day_test.go
+++ b/tariff/fixed/day_test.go
@@ -35,10 +35,17 @@ func TestParseDays(t *testing.T) {
 	assert.NoError(t, err)
 	assert.Equal(t, []Day{Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday}, d)
 
+	_, err = ParseDays(" ")
+	assert.NoError(t, err)
+	assert.Equal(t, []Day{Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday}, d)
+
 	d, err = ParseDays("1, 3-7")
 	assert.NoError(t, err)
 	assert.Equal(t, []Day{Monday, Wednesday, Thursday, Friday, Saturday, Sunday}, d)
 
+	_, err = ParseDays("-")
+	assert.EqualError(t, err, "invalid day: ")
+
 	_, err = ParseDays("-1")
 	assert.EqualError(t, err, "invalid day: ")
 
diff --git a/tariff/fixed/timerange_test.go b/tariff/fixed/timerange_test.go
index 9af73121..a7a59892 100644
--- a/tariff/fixed/timerange_test.go
+++ b/tariff/fixed/timerange_test.go
@@ -11,11 +11,24 @@ func TestParseTimeRange(t *testing.T) {
 	assert.NoError(t, err)
 	assert.Equal(t, []TimeRange{{HourMin{7, 0}, HourMin{12, 30}}}, d, "hour:min range")
 
-	d, err = ParseTimeRanges("8-10")
+	d, err = ParseTimeRanges(" 8-10 ")
 	assert.NoError(t, err)
 	assert.Equal(t, []TimeRange{{HourMin{8, 0}, HourMin{10, 0}}}, d, "hour range")
 
-	d, err = ParseTimeRanges("8-10,20-22:30")
+	_, err = ParseTimeRanges("-1-10")
+	assert.EqualError(t, err, "invalid time: ")
+
+	_, err = ParseTimeRanges("8-25")
+	assert.EqualError(t, err, "invalid time: 25", "<to> after midnight")
+
+	d, err = ParseTimeRanges("8-0")
+	assert.NoError(t, err)
+	assert.Equal(t, []TimeRange{{HourMin{8, 0}, HourMin{0, 0}}}, d, "hour till midnight (0)")
+
+	_, err = ParseTimeRanges("10-8")
+	assert.EqualError(t, err, "invalid time range: 10-8, <from> must be before <to>")
+
+	d, err = ParseTimeRanges("8-10, 20-22:30")
 	assert.NoError(t, err)
 	assert.Equal(t, []TimeRange{
 		{HourMin{8, 0}, HourMin{10, 0}},
diff --git a/vitest.config.js b/vitest.config.js
new file mode 100644
index 00000000..29af457c
--- /dev/null
+++ b/vitest.config.js
@@ -0,0 +1,12 @@
+import { mergeConfig } from "vite";
+import { defineConfig } from "vitest/config";
+import viteConfig from "./vite.config";
+
+export default mergeConfig(
+  viteConfig,
+  defineConfig({
+    test: {
+      environment: "happy-dom",
+    },
+  })
+);