UI: New Navigation, Battery & Forecast Page (#27753)

This commit is contained in:
Michael Geers 2026-03-25 20:22:31 +01:00 • committed by GitHub
parent ef365f7907
commit 7b5103df44
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 3327 additions and 889 deletions

View file

@ -91,6 +91,9 @@
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
--safe-area-inset-left: env(safe-area-inset-left, 0px);
--safe-area-inset-right: env(safe-area-inset-right, 0px);
--tab-bar-height: 3rem;
--tab-bar-background: var(--evcc-background);
}
:root.dark {
@ -108,6 +111,7 @@
--bs-border-color-translucent: rgba(255, 255, 255, 0.175);
--bs-code-color: var(--evcc-dark-green);
--evcc-backdrop: #000000aa; /* black + transparent */
--tab-bar-background: var(--evcc-box);
color-scheme: dark;
}
@ -362,7 +366,7 @@ a:hover {
background-color: var(--bs-gray-dark) !important;
}
@media screen and (min-width: 400px) and (max-width: 574px) {
@media screen and (--xs-to-sm) {
.d-xs-none {
display: none !important;
}
@ -478,6 +482,13 @@ small {
filter: invert(1);
}
.circle-badge {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
}
.dropdown-menu {
border: none;
border-radius: 10px;

View file

@ -1,9 +1,16 @@
/* Bootstrap Breakpoints as Custom Media Queries */
@custom-media --xs-and-up (min-width: 400px);
@custom-media --sm-and-up (min-width: 576px);
@custom-media --md-and-up (min-width: 768px);
@custom-media --lg-and-up (min-width: 992px);
@custom-media --xl-and-up (min-width: 1200px);
@custom-media --xxl-and-up (min-width: 1400px);
/* Range Breakpoints */
@custom-media --xs-to-sm (min-width: 400px) and (max-width: 575.98px);
@custom-media --sm-to-md (min-width: 576px) and (max-width: 767.98px);
@custom-media --sm-to-lg (min-width: 576px) and (max-width: 991.98px);
@custom-media --md-to-lg (min-width: 768px) and (max-width: 991.98px);
@custom-media --light-mode (prefers-color-scheme: light);
@custom-media --dark-mode (prefers-color-scheme: dark);

View file

@ -0,0 +1,106 @@
import AboutModal from "./AboutModal.vue";
import type { Meta, StoryFn } from "@storybook/vue3";
const releaseNotes = `
<h1>v0.303.1</h1>
<h2>Changelog</h2>
<h3>Other Changes ☀️</h3>
<ul>
<li>Home Assistant: allow switch for enable/disable</li>
<li>Nexblue: remove broken 1p3p</li>
<li>Optimizer: return infeasable error</li>
<li>Revert: Safari: web socket bug redirect workaround</li>
</ul>
<h3>Bug Fixes 🐞</h3>
<ul>
<li>HomeAssistant: fix changelog</li>
<li>Optimizer: fix invalid battery capacity</li>
<li>SGready: fix panic</li>
</ul>
<h1>v0.303.0</h1>
<h2>Changelog</h2>
<h3>Breaking Changes 🚨</h3>
<ul>
<li>HEMS: refactor handling of production/feedin limits (BC)</li>
<li>Migrate optimizer (BC)</li>
</ul>
<h3>New Features 💫</h3>
<ul>
<li>Add RAEDIAN NEO and NEX AC charger</li>
<li>Audi: add vehicle features</li>
<li>ChargeX: add heartbeat to prevent PAC_Target_Timeout fallback</li>
<li>Heating: add continuous feature to improve heatpump experience</li>
<li>Sigenergy: add maxacpower</li>
<li>Tariff UI: add multiline formula support</li>
</ul>
<h3>Bug Fixes 🐞</h3>
<ul>
<li>Mercedes: fix auth</li>
<li>Nexblue: fix phase switching API endpoint</li>
<li>Planner: fix <code>this.updatePlanPreviewDebounced is null</code></li>
<li>Websocket: fix logging breaking digest auth</li>
</ul>
`;
export default {
title: "AboutModal",
component: AboutModal,
argTypes: {
installed: { control: "text" },
commit: { control: "text" },
availableVersion: { control: "text" },
releaseNotes: { control: "text" },
hasUpdater: { control: "boolean" },
uploadMessage: { control: "text" },
uploadProgress: { control: "number" },
},
} as Meta<typeof AboutModal>;
const Template: StoryFn<typeof AboutModal> = (args) => ({
components: { AboutModal },
setup() {
return { args };
},
template: '<AboutModal v-bind="args" />',
mounted() {
const el = document.getElementById("aboutModal");
if (el) {
import("bootstrap/js/dist/modal").then(({ default: Modal }) => {
Modal.getOrCreateInstance(el).show();
});
}
},
});
export const Stable = Template.bind({});
Stable.args = {
installed: "0.303.1",
availableVersion: "0.303.1",
};
export const StableUpdateAvailable = Template.bind({});
StableUpdateAvailable.args = {
installed: "0.303.0",
availableVersion: "0.303.1",
releaseNotes,
};
export const StableUpdateWithUpdater = Template.bind({});
StableUpdateWithUpdater.args = {
installed: "0.303.0",
availableVersion: "0.303.1",
releaseNotes,
hasUpdater: true,
};
export const Nightly = Template.bind({});
Nightly.args = {
installed: "0.303.1",
commit: "5ce7be4a9f3b2c1d",
availableVersion: "0.303.1",
};
export const DevBuild = Template.bind({});
DevBuild.args = {
installed: "0.0.0",
};

View file

@ -0,0 +1,249 @@
<template>
<GenericModal id="aboutModal" :size="modalSize">
<template #title>
<a :href="websiteUrl" target="_blank" rel="noopener noreferrer"
><Logo class="about-logo"
/></a>
</template>
<div v-if="updateStarted">
<p>{{ $t("footer.version.modalUpdateStarted") }}</p>
<div class="progress my-3">
<div
class="progress-bar progress-bar-striped progress-bar-animated"
role="progressbar"
:style="{ width: uploadProgress + '%' }"
></div>
</div>
<p>{{ updateStatus }} {{ uploadMessage }}</p>
</div>
<div v-else>
<table class="about-table">
<tbody>
<tr>
<th>{{ $t("footer.version.labelVersion") }}</th>
<td v-if="development">---</td>
<td v-else>
<div class="d-flex flex-wrap column-gap-2 align-items-baseline">
<span class="text-nowrap">
<a
:href="releaseNotesUrl(installed)"
target="_blank"
rel="noopener noreferrer"
>
v{{ installed }}
</a>
<template v-if="nightly">
(<a
:href="githubCommitUrl"
target="_blank"
rel="noopener noreferrer"
><span class="font-monospace">{{
shortCommit
}}</span></a
>)
</template>
</span>
<span
v-if="!nightly && !newVersionAvailable"
class="text-muted text-nowrap"
>{{ $t("footer.version.latestVersion") }}</span
>
<span v-if="newVersionAvailable" class="text-nowrap">{{
$t("footer.version.availableLong")
}}</span>
</div>
</td>
</tr>
<tr>
<th>{{ $t("footer.version.labelRelease") }}</th>
<td>{{ releaseName }}</td>
</tr>
<tr>
<th>{{ $t("footer.version.labelWebsite") }}</th>
<td>
<a :href="websiteUrl" target="_blank" rel="noopener noreferrer">
{{ websiteDomain }}
</a>
</td>
</tr>
</tbody>
</table>
<!-- changelog -->
<template v-if="newVersionAvailable">
<hr />
<h6>{{ $t("footer.version.modalNextRelease") }}</h6>
<!-- eslint-disable vue/no-v-html -->
<div v-if="releaseNotes" class="release-notes" v-html="cleanedReleaseNotes"></div>
<!-- eslint-enable vue/no-v-html -->
<p v-else>
{{ $t("footer.version.modalNoReleaseNotes") }}
<a :href="releaseNotesUrl(availableVersion)">GitHub</a>.
</p>
</template>
<!-- update actions -->
<template v-if="newVersionAvailable">
<div class="d-flex justify-content-end mt-3">
<button v-if="hasUpdater" type="button" class="btn btn-primary" @click="update">
{{ $t("footer.version.modalUpdateNow") }}
</button>
<a
v-else
:href="releaseNotesUrl(availableVersion)"
target="_blank"
rel="noopener noreferrer"
class="btn btn-outline-primary"
>
{{ $t("footer.version.modalViewOnGitHub") }}
</a>
</div>
</template>
<!-- open source -->
<hr />
<p class="mb-0 small d-flex flex-wrap column-gap-1">
<i18n-t keypath="footer.version.madeByCommunity" tag="span">
<a
:href="githubRepoUrl"
target="_blank"
rel="noopener noreferrer"
class="text-nowrap"
>{{ $t("footer.version.community") }}</a
>
</i18n-t>
<i18n-t keypath="footer.version.poweredByOpenSource" tag="span" class="d-inline">
<a
class="text-muted"
:href="githubDependenciesUrl"
target="_blank"
rel="noopener noreferrer"
>{{ $t("footer.version.openSource") }}</a
>
</i18n-t>
</p>
</div>
</GenericModal>
</template>
<script lang="ts">
import GenericModal from "./Helper/GenericModal.vue";
import "@h2d2/shopicons/es/regular/gift";
import "@h2d2/shopicons/es/filled/heart";
import Logo from "./Footer/Logo.vue";
import api from "@/api";
import { extractDomain } from "@/utils/extractDomain";
import { isDevelopment, isNightly, getReleaseName, shortCommit } from "@/utils/version";
import { defineComponent } from "vue";
const GITHUB_REPO = "https://github.com/evcc-io/evcc";
const EVCC_WEBSITE = "https://evcc.io/";
export default defineComponent({
name: "AboutModal",
components: { GenericModal, Logo },
props: {
installed: { type: String, default: "" },
commit: String,
availableVersion: String,
releaseNotes: String,
hasUpdater: Boolean,
uploadMessage: String,
uploadProgress: Number,
},
data() {
return {
updateStarted: false,
updateStatus: "",
};
},
computed: {
development() {
return isDevelopment(this.installed);
},
nightly() {
return isNightly(this.installed, this.commit);
},
releaseName() {
return getReleaseName(this.installed, this.commit);
},
websiteUrl() {
return EVCC_WEBSITE;
},
websiteDomain() {
return extractDomain(EVCC_WEBSITE);
},
githubRepoUrl() {
return GITHUB_REPO;
},
githubDependenciesUrl() {
return `${GITHUB_REPO}/network/dependencies`;
},
shortCommit() {
return shortCommit(this.commit);
},
githubCommitUrl() {
return `${GITHUB_REPO}/commit/${this.commit}`;
},
modalSize() {
return this.newVersionAvailable ? undefined : "sm";
},
cleanedReleaseNotes() {
if (!this.releaseNotes) return "";
return this.releaseNotes.replaceAll("<h2>Changelog</h2>", "");
},
newVersionAvailable() {
return (
this.availableVersion &&
!this.development &&
this.availableVersion !== this.installed
);
},
},
methods: {
async update() {
try {
await api.post("update");
this.updateStatus = this.$t("footer.version.modalUpdateStatusStart");
this.updateStarted = true;
} catch (e) {
this.updateStatus = `${this.$t("footer.version.modalUpdateStatusStart")} ${e}`;
}
},
releaseNotesUrl(version?: string) {
return `${GITHUB_REPO}/releases/tag/${version}`;
},
},
});
</script>
<style scoped>
.about-logo {
height: 2.5rem;
}
.about-table th {
padding-right: 1rem;
font-weight: normal;
vertical-align: top;
}
.about-table td {
vertical-align: top;
}
.release-notes :deep(h1) {
font-size: 1.5rem;
font-weight: bold;
margin: 2rem 0 1rem;
text-transform: none;
}
.release-notes :deep(h2) {
font-size: 1.25rem;
font-weight: bold;
}
.release-notes :deep(h3) {
font-size: 1rem;
font-weight: bold;
}
.release-notes :deep(h1:first-child) {
margin-top: 0;
}
</style>

View file

@ -30,217 +30,14 @@
</li>
</ul>
<div v-show="usageTabActive" class="row">
<p class="text-center text-md-start col-md-6 order-md-2 col-lg-3 order-lg-3 pt-lg-2">
{{ $t("batterySettings.batteryLevel") }}:
<strong>{{ fmtSoc(batterySoc) }}</strong>
<small v-for="(line, index) in batteryDetails" :key="index" class="d-block">
{{ line }}
</small>
</p>
<div
class="col-md-6 order-md-1 col-lg-3 order-lg-2 mb-5 mb-lg-0 battery justify-content-center justify-content-md-start"
>
<div class="batteryLimits">
<CustomSelect
id="batterySettingsBuffer"
:options="bufferOptions"
:selected="selectedBufferSoc"
class="bufferSoc p-2 end-0"
:class="{
'bufferSoc--hidden': selectedBufferSoc === selectedPrioritySoc,
}"
:style="{ top: `${topHeight}%` }"
@change="changeBufferSoc"
>
<span class="text-decoration-underline text-nowrap pe-none">
{{ fmtSoc(selectedBufferSoc) }}
</span>
</CustomSelect>
<CustomSelect
id="batterySettingsPriority"
:options="priorityOptions"
:selected="selectedPrioritySoc"
class="prioritySoc p-2 end-0"
:style="{ top: `${100 - bottomHeight}%` }"
@change="changePrioritySoc"
>
<span class="text-decoration-underline text-nowrap pe-none">
{{ fmtSoc(selectedPrioritySoc) }}
</span>
</CustomSelect>
</div>
<div class="progress me-md-0">
<div
class="bg-dark-green progress-bar text-light align-items-center"
role="button"
:style="{ height: `${topHeight}%` }"
@click="toggleBufferStart"
>
<shopicon-regular-lightning
size="m"
class="icon"
:style="iconStyle(topHeight)"
></shopicon-regular-lightning>
</div>
<div
class="bg-darker-green progress-bar text-light align-items-center"
:style="{ height: `${middleHeight}%` }"
>
<shopicon-regular-car3
size="m"
class="icon"
:style="iconStyle(middleHeight)"
></shopicon-regular-car3>
</div>
<div
class="bg-darkest-green progress-bar text-light align-items-center"
:style="{ height: `${bottomHeight}%` }"
>
<shopicon-regular-home
size="m"
class="icon"
:style="iconStyle(bottomHeight)"
></shopicon-regular-home>
</div>
<div
class="batterySoc ps-0 bg-white pe-none"
:style="{ top: `${100 - batterySoc}%` }"
></div>
<div
class="bufferStartIndicator pe-none"
:class="{
'bufferStartIndicator--hidden':
!selectedBufferStartSoc || selectedBufferSoc === 100,
}"
:style="{ top: `${bufferStartTop}%` }"
>
<div class="bufferStartIndicator__left"></div>
<div class="bufferStartIndicator__right"></div>
</div>
</div>
</div>
<div class="col-md-12 order-md-3 col-lg-6 order-lg-1 legend pt-lg-2">
<p class="d-flex">
<shopicon-regular-lightning
size="s"
class="flex-shrink-0 me-2"
></shopicon-regular-lightning>
<span class="d-block">
{{ $t("batterySettings.legendTopName") }}
<i18n-t
keypath="batterySettings.legendTopSubline"
tag="small"
class="d-block"
scope="global"
>
<template #soc>
<CustomSelect
id="batterySettingsBufferTop"
class="custom-select-inline"
:options="bufferOptions"
:selected="selectedBufferSoc"
@change="changeBufferSoc"
>
<span class="text-decoration-underline">
{{ fmtSoc(selectedBufferSoc) }}
</span>
</CustomSelect>
</template>
</i18n-t>
<small class="d-block">
{{ $t("batterySettings.legendTopAutostart") }}
<CustomSelect
id="batterySettingsBufferStart"
class="custom-select-inline"
:selected="selectedBufferStartSoc"
:options="bufferStartOptions"
@change="changeBufferStart"
>
<span class="text-decoration-underline">
{{ selectedBufferStartName }}
</span>
</CustomSelect>
</small>
</span>
</p>
<p class="d-flex">
<shopicon-regular-car3
size="s"
class="flex-shrink-0 me-2"
></shopicon-regular-car3>
<span class="d-block">
{{ $t("batterySettings.legendMiddleName") }}
<i18n-t
keypath="batterySettings.legendMiddleSubline"
tag="small"
class="d-block"
scope="global"
>
<template #soc>
<CustomSelect
id="batterySettingsPriorityMiddle"
class="custom-select-inline"
:options="priorityOptions"
:selected="selectedPrioritySoc"
@change="changePrioritySoc"
>
<span class="text-decoration-underline">
{{ fmtSoc(selectedPrioritySoc) }}
</span>
</CustomSelect>
</template>
</i18n-t>
</span>
</p>
<p class="d-flex">
<shopicon-regular-home
size="s"
class="flex-shrink-0 me-2"
></shopicon-regular-home>
<span class="d-block">
{{ $t("batterySettings.legendBottomName") }}
<i18n-t
keypath="batterySettings.legendBottomSubline"
tag="small"
class="d-block"
scope="global"
>
<template #soc>
<CustomSelect
id="batterySettingsPriorityBottom"
class="custom-select-inline"
:options="priorityOptions"
:selected="selectedPrioritySoc"
@change="changePrioritySoc"
>
<span class="text-decoration-underline">
{{ fmtSoc(selectedPrioritySoc) }}
</span>
</CustomSelect>
</template>
</i18n-t>
</span>
</p>
<div v-if="controllable" class="form-check form-switch mt-4">
<input
id="batteryDischargeControl"
:checked="batteryDischargeControl"
class="form-check-input"
type="checkbox"
role="switch"
@change="changeDischargeControl"
/>
<div class="form-check-label">
<label for="batteryDischargeControl">
{{ $t("batterySettings.discharge") }}
</label>
</div>
</div>
</div>
</div>
<BatteryUsageSettings
v-show="usageTabActive"
:buffer-soc="bufferSoc"
:priority-soc="prioritySoc"
:buffer-start-soc="bufferStartSoc"
:battery-discharge-control="batteryDischargeControl"
:battery="battery"
/>
<SmartCostLimit
v-if="isModalVisible"
v-show="gridTabActive"
@ -255,24 +52,17 @@
</template>
<script lang="ts">
import "@h2d2/shopicons/es/regular/lightning";
import "@h2d2/shopicons/es/regular/car3";
import "@h2d2/shopicons/es/regular/home";
import SmartCostLimit from "../Tariff/SmartCostLimit.vue";
import CustomSelect from "../Helper/CustomSelect.vue";
import BatteryUsageSettings from "./BatteryUsageSettings.vue";
import GenericModal from "../Helper/GenericModal.vue";
import formatter, { POWER_UNIT } from "@/mixins/formatter";
import collector from "@/mixins/collector.js";
import api from "@/api";
import settings from "@/settings";
import { defineComponent, type PropType } from "vue";
import type { Battery, SelectOption, CURRENCY, Forecast } from "@/types/evcc";
import type { Battery, CURRENCY, Forecast } from "@/types/evcc";
import { SMART_COST_TYPE } from "@/types/evcc";
export default defineComponent({
name: "BatterySettingsModal",
components: { SmartCostLimit, CustomSelect, GenericModal },
mixins: [formatter, collector],
components: { SmartCostLimit, BatteryUsageSettings, GenericModal },
props: {
bufferSoc: { type: Number, default: 100 },
prioritySoc: { type: Number, default: 0 },
@ -289,9 +79,6 @@ export default defineComponent({
data() {
return {
isModalVisible: false,
selectedBufferSoc: 100,
selectedPrioritySoc: 0,
selectedBufferStartSoc: 0,
gridTabActive: false,
};
},
@ -299,23 +86,9 @@ export default defineComponent({
usageTabActive() {
return !this.gridTabActive;
},
batterySoc() {
return this.battery?.soc ?? 0;
},
batteryDevices() {
return this.battery?.devices ?? [];
},
priorityOptions() {
const options = [];
for (let i = 100; i >= 0; i -= 5) {
// avoid intersection with buffer soc; allow everything if they touch
const disabled =
i > this.selectedBufferSoc &&
!(this.selectedBufferSoc == this.selectedPrioritySoc);
options.push({ value: i, name: this.fmtSoc(i), disabled });
}
return options;
},
controllable() {
return this.batteryDevices.some(({ controllable }) => controllable);
},
@ -328,88 +101,11 @@ export default defineComponent({
}
return this.forecast?.grid;
},
bufferOptions() {
const options = [];
for (let i = 100; i >= 5; i -= 5) {
options.push({
value: i,
name: this.fmtSoc(i),
disabled: i < this.selectedPrioritySoc,
});
}
return options;
},
bufferStartTop() {
if (!this.selectedBufferStartSoc) return 0;
return 100 - this.selectedBufferStartSoc;
},
bufferStartOptions() {
const options = [];
for (let i = 100; i >= this.selectedBufferSoc; i -= 5) {
options.push({
value: i,
name: this.getBufferStartName(i),
});
}
options.push({
value: 0,
name: this.getBufferStartName(0),
});
return options;
},
bufferStartOption(): SelectOption<number> | undefined {
return this.bufferStartOptions.find((option) => this.bufferStartSoc >= option.value);
},
selectedBufferStartName() {
return this.getBufferStartName(this.selectedBufferStartSoc);
},
topHeight() {
return 100 - (this.bufferSoc || 100);
},
middleHeight() {
return 100 - this.topHeight - this.bottomHeight;
},
bottomHeight() {
return this.prioritySoc;
},
batteryDetails() {
if (!this.batteryDevices.length) {
return;
}
const multipleBatteries = this.batteryDevices.length > 1;
return this.batteryDevices
.filter(({ capacity }) => capacity > 0)
.map(({ soc = 0, capacity }) => {
const energy = this.fmtWh(
(capacity / 100) * soc * 1e3,
POWER_UNIT.KW,
!multipleBatteries,
1
);
const total = this.fmtWh(capacity * 1e3, POWER_UNIT.KW, true, 1);
const name = multipleBatteries ? "↳ " : "";
const formattedSoc = multipleBatteries ? ` (${this.fmtSoc(soc)})` : "";
const formattedEnergy = this.$t("batterySettings.capacity", {
energy,
total,
});
return `${name}${formattedEnergy}${formattedSoc}`;
});
},
lastSmartCostLimit() {
return settings.lastBatterySmartCostLimit;
},
},
watch: {
prioritySoc(soc) {
this.selectedPrioritySoc = soc;
},
bufferSoc(soc) {
this.selectedBufferSoc = soc || 100;
},
bufferStartSoc(soc) {
this.selectedBufferStartSoc = soc;
},
batteryGridChargeLimit() {
this.verifyTabs();
},
@ -417,11 +113,6 @@ export default defineComponent({
this.verifyTabs();
},
},
mounted() {
this.selectedBufferSoc = this.bufferSoc || 100;
this.selectedPrioritySoc = this.prioritySoc;
this.selectedBufferStartSoc = this.bufferStartSoc;
},
methods: {
showGridTab() {
this.gridTabActive = true;
@ -444,175 +135,6 @@ export default defineComponent({
modalInvisible() {
this.isModalVisible = false;
},
changeBufferStart($event: Event) {
this.setBufferStartSoc(parseInt(($event.target as HTMLInputElement).value, 10));
},
changePrioritySoc($event: Event) {
const soc = parseInt(($event.target as HTMLInputElement).value, 10);
if (soc > (this.bufferSoc || 100)) {
this.saveBufferSoc(soc);
if (soc > this.bufferStartSoc && this.bufferStartSoc > 0) {
this.setBufferStartSoc(soc);
}
} else {
this.savePrioritySoc(soc);
}
},
toggleBufferStart() {
const options = this.bufferStartOptions.map((option) => option.value);
const index = options.findIndex((value) => this.bufferStartSoc >= value);
const nextIndex = index === 0 ? options.length - 1 : index - 1;
this.setBufferStartSoc(options[nextIndex]!);
},
async setBufferStartSoc(soc: number) {
this.selectedBufferStartSoc = soc;
await this.saveBufferStartSoc(this.selectedBufferStartSoc);
},
async changeBufferSoc($event: Event) {
const soc = parseInt(($event.target as HTMLInputElement).value, 10);
if (soc > this.bufferStartSoc && this.bufferStartSoc > 0) {
await this.setBufferStartSoc(soc);
}
await this.saveBufferSoc(soc);
},
async savePrioritySoc(soc: number) {
this.selectedPrioritySoc = soc;
try {
await api.post(`prioritysoc/${encodeURIComponent(soc)}`);
} catch (err) {
console.error(err);
}
},
async saveBufferSoc(soc: number) {
this.selectedBufferSoc = soc;
try {
await api.post(`buffersoc/${encodeURIComponent(soc)}`);
} catch (err) {
console.error(err);
}
},
async saveBufferStartSoc(soc: number) {
try {
await api.post(`bufferstartsoc/${encodeURIComponent(soc)}`);
} catch (err) {
console.error(err);
}
},
iconStyle(height: number) {
let scale = 1;
if (height <= 10) scale = 0.75;
if (height <= 5) scale = 0;
return { transform: `scale(${scale})` };
},
fmtSoc(soc: number) {
return this.fmtPercentage(soc);
},
async changeDischargeControl(e: Event) {
try {
await api.post(
`batterydischargecontrol/${(e.target as HTMLInputElement).checked ? "true" : "false"}`
);
} catch (err) {
console.error(err);
}
},
getBufferStartName(value: number) {
const key = value === 0 ? "never" : value === 100 ? "full" : "above";
const soc = this.fmtSoc(value);
return this.$t(`batterySettings.bufferStart.${key}`, { soc });
},
},
});
</script>
<style scoped>
.battery {
height: 285px;
display: flex;
}
.batteryLimits {
width: 50px;
position: relative;
}
.bufferStart,
.bufferSoc,
.prioritySoc {
position: absolute !important;
transform: translateY(-50%);
transition-property: top, opacity;
transition-timing-function: linear;
transition-duration: var(--evcc-transition-fast);
opacity: 1;
}
.bufferStart--hidden,
.bufferSoc--hidden {
opacity: 0;
pointer-events: none;
}
.batterySoc,
.bufferStartIndicator {
position: absolute;
transition-property: top, opacity;
transition-timing-function: linear;
transition-duration: var(--evcc-transition-fast);
transform: translateY(-50%);
}
.batterySoc {
--size: 0.7rem;
border-radius: var(--size);
left: 1rem;
right: 1rem;
height: var(--size);
opacity: 0.8;
}
.bufferStartIndicator {
--size: 0.7rem;
display: flex;
justify-content: space-between;
left: calc(-1 * var(--size) / 4);
right: calc(-1 * var(--size) / 4);
}
.bufferStartIndicator--hidden {
opacity: 0;
transform: translateY(-50%);
}
.bufferStartIndicator__left,
.bufferStartIndicator__right {
height: var(--size);
width: var(--size);
background-color: var(--evcc-box);
}
.bufferStartIndicator__left {
border-radius: 0 var(--size) var(--size) 0;
}
.bufferStartIndicator__right {
border-radius: var(--size) 0 0 var(--size);
}
.progress {
flex: 1;
height: 100%;
min-width: 100px;
max-width: 130px;
margin-right: 50px;
flex-direction: column;
position: relative;
border-radius: 1rem;
background-color: var(--evcc-box) !important;
}
.progress-bar {
transition: height var(--evcc-transition-fast) linear;
}
.icon {
transition: transform var(--evcc-transition-fast) linear;
z-index: 1;
border-radius: 0.5rem;
}
.custom-select-inline {
display: inline-block !important;
}
</style>

View file

@ -0,0 +1,511 @@
<template>
<div class="row">
<p class="text-center text-md-start col-md-6 order-md-2 col-lg-3 order-lg-3 pt-lg-2">
{{ $t("batterySettings.batteryLevel") }}:
<strong>{{ fmtSoc(batterySoc) }}</strong>
<small v-for="(line, index) in batteryDetails" :key="index" class="d-block">
{{ line }}
</small>
</p>
<div
class="col-md-6 order-md-1 col-lg-3 order-lg-2 mb-5 mb-lg-0 battery justify-content-center justify-content-md-start"
>
<div class="batteryLimits">
<CustomSelect
id="batterySettingsBuffer"
:options="bufferOptions"
:selected="selectedBufferSoc"
class="bufferSoc p-2 end-0"
:class="{
'bufferSoc--hidden': selectedBufferSoc === selectedPrioritySoc,
}"
:style="{ top: `${topHeight}%` }"
@change="changeBufferSoc"
>
<span class="text-decoration-underline text-nowrap pe-none">
{{ fmtSoc(selectedBufferSoc) }}
</span>
</CustomSelect>
<CustomSelect
id="batterySettingsPriority"
:options="priorityOptions"
:selected="selectedPrioritySoc"
class="prioritySoc p-2 end-0"
:style="{ top: `${100 - bottomHeight}%` }"
@change="changePrioritySoc"
>
<span class="text-decoration-underline text-nowrap pe-none">
{{ fmtSoc(selectedPrioritySoc) }}
</span>
</CustomSelect>
</div>
<div class="progress me-md-0">
<div
class="bg-dark-green progress-bar text-light align-items-center"
role="button"
:style="{ height: `${topHeight}%` }"
@click="toggleBufferStart"
>
<shopicon-regular-lightning
size="m"
class="icon"
:style="iconStyle(topHeight)"
></shopicon-regular-lightning>
</div>
<div
class="bg-darker-green progress-bar text-light align-items-center"
:style="{ height: `${middleHeight}%` }"
>
<shopicon-regular-car3
size="m"
class="icon"
:style="iconStyle(middleHeight)"
></shopicon-regular-car3>
</div>
<div
class="bg-darkest-green progress-bar text-light align-items-center"
:style="{ height: `${bottomHeight}%` }"
>
<shopicon-regular-home
size="m"
class="icon"
:style="iconStyle(bottomHeight)"
></shopicon-regular-home>
</div>
<div
class="batterySoc ps-0 bg-white pe-none"
:style="{ top: `${100 - batterySoc}%` }"
></div>
<div
class="bufferStartIndicator pe-none"
:class="{
'bufferStartIndicator--hidden':
!selectedBufferStartSoc || selectedBufferSoc === 100,
}"
:style="{ top: `${bufferStartTop}%` }"
>
<div class="bufferStartIndicator__left"></div>
<div class="bufferStartIndicator__right"></div>
</div>
</div>
</div>
<div class="col-md-12 order-md-3 col-lg-6 order-lg-1 legend pt-lg-2">
<p class="d-flex">
<shopicon-regular-lightning
size="s"
class="flex-shrink-0 me-2"
></shopicon-regular-lightning>
<span class="d-block">
{{ $t("batterySettings.legendTopName") }}
<i18n-t
keypath="batterySettings.legendTopSubline"
tag="small"
class="d-block"
scope="global"
>
<template #soc>
<CustomSelect
id="batterySettingsBufferTop"
class="custom-select-inline"
:options="bufferOptions"
:selected="selectedBufferSoc"
@change="changeBufferSoc"
>
<span class="text-decoration-underline">
{{ fmtSoc(selectedBufferSoc) }}
</span>
</CustomSelect>
</template>
</i18n-t>
<small class="d-block">
{{ $t("batterySettings.legendTopAutostart") }}
<CustomSelect
id="batterySettingsBufferStart"
class="custom-select-inline"
:selected="selectedBufferStartSoc"
:options="bufferStartOptions"
@change="changeBufferStart"
>
<span class="text-decoration-underline">
{{ selectedBufferStartName }}
</span>
</CustomSelect>
</small>
</span>
</p>
<p class="d-flex">
<shopicon-regular-car3 size="s" class="flex-shrink-0 me-2"></shopicon-regular-car3>
<span class="d-block">
{{ $t("batterySettings.legendMiddleName") }}
<i18n-t
keypath="batterySettings.legendMiddleSubline"
tag="small"
class="d-block"
scope="global"
>
<template #soc>
<CustomSelect
id="batterySettingsPriorityMiddle"
class="custom-select-inline"
:options="priorityOptions"
:selected="selectedPrioritySoc"
@change="changePrioritySoc"
>
<span class="text-decoration-underline">
{{ fmtSoc(selectedPrioritySoc) }}
</span>
</CustomSelect>
</template>
</i18n-t>
</span>
</p>
<p class="d-flex">
<shopicon-regular-home size="s" class="flex-shrink-0 me-2"></shopicon-regular-home>
<span class="d-block">
{{ $t("batterySettings.legendBottomName") }}
<i18n-t
keypath="batterySettings.legendBottomSubline"
tag="small"
class="d-block"
scope="global"
>
<template #soc>
<CustomSelect
id="batterySettingsPriorityBottom"
class="custom-select-inline"
:options="priorityOptions"
:selected="selectedPrioritySoc"
@change="changePrioritySoc"
>
<span class="text-decoration-underline">
{{ fmtSoc(selectedPrioritySoc) }}
</span>
</CustomSelect>
</template>
</i18n-t>
</span>
</p>
<div v-if="controllable" class="form-check form-switch mt-4">
<input
id="batteryDischargeControl"
:checked="batteryDischargeControl"
class="form-check-input"
type="checkbox"
role="switch"
@change="changeDischargeControl"
/>
<div class="form-check-label">
<label for="batteryDischargeControl">
{{ $t("batterySettings.discharge") }}
</label>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import "@h2d2/shopicons/es/regular/lightning";
import "@h2d2/shopicons/es/regular/car3";
import "@h2d2/shopicons/es/regular/home";
import CustomSelect from "../Helper/CustomSelect.vue";
import formatter, { POWER_UNIT } from "@/mixins/formatter";
import api from "@/api";
import { defineComponent, type PropType } from "vue";
import type { Battery } from "@/types/evcc";
export default defineComponent({
name: "BatteryUsageSettings",
components: { CustomSelect },
mixins: [formatter],
props: {
bufferSoc: { type: Number, default: 100 },
prioritySoc: { type: Number, default: 0 },
bufferStartSoc: { type: Number, default: 0 },
batteryDischargeControl: Boolean,
battery: { type: Object as PropType<Battery> },
},
data() {
return {
selectedBufferSoc: 100,
selectedPrioritySoc: 0,
selectedBufferStartSoc: 0,
};
},
computed: {
batterySoc() {
return this.battery?.soc ?? 0;
},
batteryDevices() {
return this.battery?.devices ?? [];
},
priorityOptions() {
const options = [];
for (let i = 100; i >= 0; i -= 5) {
const disabled =
i > this.selectedBufferSoc &&
!(this.selectedBufferSoc == this.selectedPrioritySoc);
options.push({ value: i, name: this.fmtSoc(i), disabled });
}
return options;
},
controllable() {
return this.batteryDevices.some(({ controllable }) => controllable);
},
bufferOptions() {
const options = [];
for (let i = 100; i >= 5; i -= 5) {
options.push({
value: i,
name: this.fmtSoc(i),
disabled: i < this.selectedPrioritySoc,
});
}
return options;
},
bufferStartTop() {
if (!this.selectedBufferStartSoc) return 0;
return 100 - this.selectedBufferStartSoc;
},
bufferStartOptions() {
const options = [];
for (let i = 100; i >= this.selectedBufferSoc; i -= 5) {
options.push({
value: i,
name: this.getBufferStartName(i),
});
}
options.push({
value: 0,
name: this.getBufferStartName(0),
});
return options;
},
selectedBufferStartName() {
return this.getBufferStartName(this.selectedBufferStartSoc);
},
topHeight() {
return 100 - (this.bufferSoc || 100);
},
middleHeight() {
return 100 - this.topHeight - this.bottomHeight;
},
bottomHeight() {
return this.prioritySoc;
},
batteryDetails() {
if (!this.batteryDevices.length) {
return;
}
const multipleBatteries = this.batteryDevices.length > 1;
return this.batteryDevices
.filter(({ capacity }) => capacity > 0)
.map(({ soc = 0, capacity }) => {
const energy = this.fmtWh(
(capacity / 100) * soc * 1e3,
POWER_UNIT.KW,
!multipleBatteries,
1
);
const total = this.fmtWh(capacity * 1e3, POWER_UNIT.KW, true, 1);
const name = multipleBatteries ? "↳ " : "";
const formattedSoc = multipleBatteries ? ` (${this.fmtSoc(soc)})` : "";
const formattedEnergy = this.$t("batterySettings.capacity", {
energy,
total,
});
return `${name}${formattedEnergy}${formattedSoc}`;
});
},
},
watch: {
prioritySoc(soc) {
this.selectedPrioritySoc = soc;
},
bufferSoc(soc) {
this.selectedBufferSoc = soc || 100;
},
bufferStartSoc(soc) {
this.selectedBufferStartSoc = soc;
},
},
mounted() {
this.selectedBufferSoc = this.bufferSoc || 100;
this.selectedPrioritySoc = this.prioritySoc;
this.selectedBufferStartSoc = this.bufferStartSoc;
},
methods: {
changeBufferStart($event: Event) {
this.setBufferStartSoc(parseInt(($event.target as HTMLInputElement).value, 10));
},
changePrioritySoc($event: Event) {
const soc = parseInt(($event.target as HTMLInputElement).value, 10);
if (soc > (this.bufferSoc || 100)) {
this.saveBufferSoc(soc);
if (soc > this.bufferStartSoc && this.bufferStartSoc > 0) {
this.setBufferStartSoc(soc);
}
} else {
this.savePrioritySoc(soc);
}
},
toggleBufferStart() {
const options = this.bufferStartOptions.map((option) => option.value);
const index = options.findIndex((value) => this.bufferStartSoc >= value);
const nextIndex = index === 0 ? options.length - 1 : index - 1;
this.setBufferStartSoc(options[nextIndex]!);
},
async setBufferStartSoc(soc: number) {
this.selectedBufferStartSoc = soc;
await this.saveBufferStartSoc(this.selectedBufferStartSoc);
},
async changeBufferSoc($event: Event) {
const soc = parseInt(($event.target as HTMLInputElement).value, 10);
if (soc > this.bufferStartSoc && this.bufferStartSoc > 0) {
await this.setBufferStartSoc(soc);
}
await this.saveBufferSoc(soc);
},
async savePrioritySoc(soc: number) {
this.selectedPrioritySoc = soc;
try {
await api.post(`prioritysoc/${encodeURIComponent(soc)}`);
} catch (err) {
console.error(err);
}
},
async saveBufferSoc(soc: number) {
this.selectedBufferSoc = soc;
try {
await api.post(`buffersoc/${encodeURIComponent(soc)}`);
} catch (err) {
console.error(err);
}
},
async saveBufferStartSoc(soc: number) {
try {
await api.post(`bufferstartsoc/${encodeURIComponent(soc)}`);
} catch (err) {
console.error(err);
}
},
iconStyle(height: number) {
let scale = 1;
if (height <= 10) scale = 0.75;
if (height <= 5) scale = 0;
return { transform: `scale(${scale})` };
},
fmtSoc(soc: number) {
return this.fmtPercentage(soc);
},
async changeDischargeControl(e: Event) {
try {
await api.post(
`batterydischargecontrol/${(e.target as HTMLInputElement).checked ? "true" : "false"}`
);
} catch (err) {
console.error(err);
}
},
getBufferStartName(value: number) {
const key = value === 0 ? "never" : value === 100 ? "full" : "above";
const soc = this.fmtSoc(value);
return this.$t(`batterySettings.bufferStart.${key}`, { soc });
},
},
});
</script>
<style scoped>
.battery {
height: 285px;
display: flex;
}
.batteryLimits {
width: 50px;
position: relative;
}
.bufferStart,
.bufferSoc,
.prioritySoc {
position: absolute !important;
transform: translateY(-50%);
transition-property: top, opacity;
transition-timing-function: linear;
transition-duration: var(--evcc-transition-fast);
opacity: 1;
}
.bufferStart--hidden,
.bufferSoc--hidden {
opacity: 0;
pointer-events: none;
}
.batterySoc,
.bufferStartIndicator {
position: absolute;
transition-property: top, opacity;
transition-timing-function: linear;
transition-duration: var(--evcc-transition-fast);
transform: translateY(-50%);
}
.batterySoc {
--size: 0.7rem;
border-radius: var(--size);
left: 1rem;
right: 1rem;
height: var(--size);
opacity: 0.8;
}
.bufferStartIndicator {
--size: 0.7rem;
display: flex;
justify-content: space-between;
left: calc(-1 * var(--size) / 4);
right: calc(-1 * var(--size) / 4);
}
.bufferStartIndicator--hidden {
opacity: 0;
transform: translateY(-50%);
}
.bufferStartIndicator__left,
.bufferStartIndicator__right {
height: var(--size);
width: var(--size);
background-color: var(--evcc-box);
}
.bufferStartIndicator__left {
border-radius: 0 var(--size) var(--size) 0;
}
.bufferStartIndicator__right {
border-radius: var(--size) 0 0 var(--size);
}
.progress {
flex: 1;
height: 100%;
min-width: 100px;
max-width: 130px;
margin-right: 50px;
flex-direction: column;
position: relative;
border-radius: 1rem;
background-color: var(--evcc-box) !important;
}
.progress-bar {
transition: height var(--evcc-transition-fast) linear;
}
.icon {
transition: transform var(--evcc-transition-fast) linear;
z-index: 1;
border-radius: 0.5rem;
}
.custom-select-inline {
display: inline-block !important;
}
</style>

View file

@ -0,0 +1,116 @@
<template>
<nav
class="bottom-tab-bar d-flex position-fixed start-0 end-0 bottom-0"
:class="{ 'bottom-tab-bar--hidden': hidden }"
data-testid="bottom-tab-bar"
>
<div class="container d-flex align-items-stretch px-0">
<Item to="/" :label="$t('tabBar.charge')" exact>
<shopicon-regular-lightning class="tab-icon"></shopicon-regular-lightning>
</Item>
<Item v-if="batteryConfigured" to="/battery" :label="$t('tabBar.battery')">
<BatteryIcon
class="tab-icon"
:soc="batterySoc || 0"
:grid-charge="batteryGridChargeActive"
:hold="batteryHold"
/>
</Item>
<Item to="/forecast" :label="$t('tabBar.forecast')">
<ForecastGraphIcon class="tab-icon" />
</Item>
<Item to="/sessions" :label="$t('tabBar.sessions')">
<SessionsIcon class="tab-icon" />
</Item>
<MoreItem
:active="moreActive"
:auth-providers="authProviders"
:sponsor="sponsor"
:fatal="fatal"
:experimental="experimental"
:evopt="evopt"
:installed="installed"
:commit="commit"
/>
</div>
</nav>
</template>
<script lang="ts">
import "@h2d2/shopicons/es/regular/lightning";
import ForecastGraphIcon from "../MaterialIcon/ForecastGraph.vue";
import SessionsIcon from "../MaterialIcon/Sessions.vue";
import BatteryIcon from "../Energyflow/BatteryIcon.vue";
import Item from "./Item.vue";
import MoreItem from "./MoreItem.vue";
import { defineComponent, type PropType } from "vue";
import type { FatalError, Forecast, Sponsor, EvOpt, AuthProviders, Battery } from "@/types/evcc";
export default defineComponent({
name: "BottomTabBar",
components: {
BatteryIcon,
ForecastGraphIcon,
SessionsIcon,
Item,
MoreItem,
},
props: {
battery: { type: Object as PropType<Battery> },
batteryGridChargeActive: Boolean,
batteryMode: { type: String as PropType<string> },
forecast: { type: Object as PropType<Forecast> },
authProviders: { type: Object as PropType<AuthProviders>, default: () => ({}) },
sponsor: { type: Object as PropType<Sponsor>, default: () => ({}) },
fatal: { type: Array as PropType<FatalError[]>, default: () => [] },
experimental: Boolean,
offline: Boolean,
startupCompleted: Boolean,
evopt: { type: Object as PropType<EvOpt>, required: false },
installed: String,
commit: String,
},
computed: {
hidden() {
return this.offline || this.startupCompleted === false;
},
batterySoc() {
return this.battery?.soc;
},
batteryHold() {
return this.batteryMode === "hold";
},
batteryConfigured() {
return (this.battery?.devices?.length ?? 0) > 0;
},
moreActive() {
const mainTabs = ["/", "/battery", "/forecast", "/sessions"];
return !mainTabs.includes(this.$route.path);
},
},
});
</script>
<style scoped>
.bottom-tab-bar {
z-index: 1030;
background: color-mix(in srgb, var(--tab-bar-background) 80%, transparent);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-top: 1px solid var(--evcc-gray-10);
box-shadow: 0 -1px 6px rgba(0, 0, 0, 0.05);
transition: transform var(--evcc-transition-fast) ease-in;
}
.bottom-tab-bar--hidden {
transform: translateY(100%);
}
:root.dark .bottom-tab-bar {
border-top-color: var(--bs-border-color);
}
</style>

View file

@ -0,0 +1,130 @@
<template>
<component
:is="to ? 'router-link' : 'div'"
v-bind="linkProps"
class="tab-item d-flex flex-column flex-md-row align-items-center justify-content-center gap-md-1 text-decoration-none position-relative"
:class="{ active: !to && active }"
:data-testid="label ? `tab-${label.toLowerCase()}` : undefined"
>
<span
class="tab-content d-flex flex-column flex-md-row align-items-center position-relative"
>
<span
v-if="badge"
class="tab-badge circle-badge position-absolute"
:class="badge"
></span>
<slot />
<span
v-if="label"
class="tab-label mt-1 mt-md-0 ms-md-1 text-truncate text-center text-md-start"
>{{ label }}</span
>
</span>
<slot name="menu" />
</component>
</template>
<script lang="ts">
import { defineComponent } from "vue";
export default defineComponent({
name: "BottomTabItem",
props: {
to: { type: String, default: undefined },
label: { type: String, default: undefined },
exact: { type: Boolean, default: false },
active: { type: Boolean, default: false },
badge: { type: String, default: undefined },
},
computed: {
linkProps() {
if (!this.to) return {};
return {
to: this.to,
exactActiveClass: this.exact ? "active" : undefined,
activeClass: this.exact ? undefined : "active",
};
},
},
});
</script>
<style scoped>
@import "../../../css/breakpoints.css";
.tab-item {
--pt: 0.4rem;
--pb: max(0.4rem, var(--safe-area-inset-bottom));
flex: 1 1 0;
min-width: 0;
height: calc(var(--tab-bar-height) + var(--pb));
padding-top: var(--pt);
padding-bottom: var(--pb);
color: var(--evcc-gray);
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
transition: color var(--evcc-transition-fast);
}
.tab-item::before {
content: "";
position: absolute;
top: 0;
left: 15%;
width: 70%;
height: 2px;
border-radius: 0 0 2px 2px;
background: transparent;
transition: background var(--evcc-transition-fast);
}
.tab-item:hover {
color: color-mix(in srgb, var(--evcc-gray) 70%, white);
}
.tab-item:active {
color: color-mix(in srgb, var(--evcc-gray) 70%, black);
}
.tab-item.active {
color: var(--bs-primary);
}
.tab-item.active::before {
background: var(--bs-primary);
}
.tab-label {
display: none;
font-size: 10px;
line-height: 1.2;
}
@media (--xs-and-up) {
.tab-label {
display: block;
}
}
@media (--md-and-up) {
.tab-label {
font-size: 14px;
font-weight: normal;
}
}
.tab-badge {
top: -4px;
right: -4px;
}
@media (--md-and-up) {
.tab-badge {
top: -6px;
right: -14px;
}
}
</style>

View file

@ -0,0 +1,86 @@
<template>
<Item
:active="active"
:label="$t('tabBar.more')"
:badge="showRootBadge ? badgeClass : undefined"
data-testid="tab-more"
role="button"
@click="toggleMenu"
>
<MoreIcon class="tab-icon d-block" />
<template #menu>
<MoreMenu
:open="open"
:auth-providers="authProviders"
:sponsor="sponsor"
:fatal="fatal"
:experimental="experimental"
:evopt="evopt"
:installed="installed"
:commit="commit"
@close="open = false"
/>
</template>
</Item>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import Item from "./Item.vue";
import MoreIcon from "../MaterialIcon/More.vue";
import MoreMenu from "./MoreMenu.vue";
import { isUserConfigError } from "@/utils/fatal";
import type { FatalError, Sponsor, EvOpt, AuthProviders } from "@/types/evcc";
export default defineComponent({
name: "MoreItem",
components: { Item, MoreIcon, MoreMenu },
props: {
active: Boolean,
authProviders: { type: Object as PropType<AuthProviders>, default: () => ({}) },
sponsor: { type: Object as PropType<Sponsor>, default: () => ({}) },
fatal: { type: Array as PropType<FatalError[]>, default: () => [] },
experimental: Boolean,
evopt: { type: Object as PropType<EvOpt>, required: false },
installed: String,
commit: String,
},
data() {
return { open: false };
},
computed: {
providers() {
return Object.entries(this.authProviders)
.filter(([, provider]) => !provider.authenticated)
.map(([title, { authenticated, id }]) => ({
title,
authenticated,
id,
}));
},
authorizationRequired() {
return this.providers.length > 0;
},
sponsorExpires(): boolean {
return !!this.sponsor?.status?.expiresSoon;
},
showConfigBadge() {
return this.sponsorExpires || isUserConfigError(this.fatal);
},
showRootBadge() {
return this.authorizationRequired || this.showConfigBadge;
},
badgeClass() {
if (this.fatal.length > 0) {
return "bg-danger";
}
return "bg-warning";
},
},
methods: {
toggleMenu() {
this.open = !this.open;
},
},
});
</script>

View file

@ -0,0 +1,226 @@
<template>
<Teleport to="body">
<div class="more-backdrop" :class="{ open }" @click="$emit('close')"></div>
</Teleport>
<div class="more-menu dropdown-menu" :class="{ open }" @click.stop="$emit('close')">
<button type="button" class="dropdown-item" @click="openHelpModal">
{{ $t("header.needHelp") }}
</button>
<button
type="button"
class="dropdown-item d-flex align-items-center"
@click="openAboutModal"
>
<span>evcc</span>
<span class="ms-2 text-muted small">{{ versionLabel }}</span>
</button>
<button v-if="isApp" type="button" class="dropdown-item" @click="openNativeSettings">
{{ $t("header.nativeSettings") }}
</button>
<button v-if="showLogout" type="button" class="dropdown-item" @click="doLogout">
{{ $t("header.logout") }}
</button>
<template v-if="authorizationRequired">
<hr class="dropdown-divider" />
<h6 class="dropdown-header">
{{ $t("authProviders.authorizationRequired") }}
</h6>
<button
v-for="provider in providers"
:key="provider.id"
type="button"
class="dropdown-item"
@click="handleAuthRequired"
>
<span
class="d-inline-block p-1 rounded-circle border border-light bg-warning"
></span>
{{ provider.title }}
</button>
</template>
<hr class="dropdown-divider" />
<router-link class="dropdown-item" to="/log" active-class="active">
{{ $t("log.title") }}
</router-link>
<button type="button" class="dropdown-item" @click="openSettingsModal">
{{ $t("settings.title") }}
</button>
<router-link class="dropdown-item" to="/config" active-class="active">
<span v-if="showConfigBadge" class="circle-badge me-1" :class="badgeClass"></span>
{{ $t("config.main.title") }}
</router-link>
<router-link
v-if="optimizeAvailable"
class="dropdown-item"
to="/optimize"
active-class="active"
>
Optimize
</router-link>
</div>
</template>
<script lang="ts">
import Modal from "bootstrap/js/dist/modal";
import { logout, isLoggedIn } from "../Auth/auth";
import { isApp, sendToApp } from "@/utils/native";
import { getShortVersion } from "@/utils/version";
import { isUserConfigError } from "@/utils/fatal";
import { defineComponent, type PropType } from "vue";
import type { FatalError, Sponsor, EvOpt, AuthProviders } from "@/types/evcc";
export default defineComponent({
name: "MoreMenu",
props: {
open: { type: Boolean, default: false },
authProviders: { type: Object as PropType<AuthProviders>, default: () => ({}) },
sponsor: { type: Object as PropType<Sponsor>, default: () => ({}) },
fatal: { type: Array as PropType<FatalError[]>, default: () => [] },
experimental: Boolean,
evopt: { type: Object as PropType<EvOpt>, required: false },
installed: String,
commit: String,
},
emits: ["close"],
data() {
return {
isApp: isApp(),
onClickOutside: undefined as ((e: MouseEvent) => void) | undefined,
};
},
computed: {
providers() {
return Object.entries(this.authProviders)
.filter(([, provider]) => !provider.authenticated)
.map(([title, { authenticated, id }]) => ({
title,
authenticated,
id,
}));
},
authorizationRequired() {
return this.providers.length > 0;
},
sponsorExpires(): boolean {
return !!this.sponsor?.status?.expiresSoon;
},
showConfigBadge() {
return this.sponsorExpires || isUserConfigError(this.fatal);
},
badgeClass() {
if (this.fatal.length > 0) {
return "bg-danger";
}
return "bg-warning";
},
versionLabel() {
return getShortVersion(this.installed || "", this.commit);
},
optimizeAvailable() {
return !!this.evopt && this.experimental;
},
showLogout() {
return isLoggedIn();
},
},
mounted() {
this.onClickOutside = (e: MouseEvent) => {
if (this.open && !this.$el.contains(e.target as Node)) {
this.$emit("close");
}
};
document.addEventListener("click", this.onClickOutside, true);
},
unmounted() {
if (this.onClickOutside) {
document.removeEventListener("click", this.onClickOutside, true);
}
},
methods: {
handleAuthRequired() {
this.$router.push({ path: "/config" });
},
openSettingsModal() {
const modal = Modal.getOrCreateInstance(
document.getElementById("globalSettingsModal") as HTMLElement
);
modal.show();
},
openHelpModal() {
const modal = Modal.getOrCreateInstance(
document.getElementById("helpModal") as HTMLElement
);
modal.show();
},
openAboutModal() {
const modal = Modal.getOrCreateInstance(
document.getElementById("aboutModal") as HTMLElement
);
modal.show();
},
openNativeSettings() {
sendToApp({ type: "settings" });
},
async doLogout() {
await logout();
this.$router.push({ path: "/" });
},
},
});
</script>
<style scoped>
.more-backdrop {
position: fixed;
inset: 0;
z-index: 1029;
background-color: var(--evcc-backdrop);
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
opacity: 0;
visibility: hidden;
transition:
opacity var(--evcc-transition-fast),
visibility var(--evcc-transition-fast);
}
.more-backdrop.open {
opacity: 1;
visibility: visible;
}
.more-menu {
display: block;
position: absolute;
right: 15%;
bottom: calc(100% + 0.5rem);
min-width: 70%;
-webkit-user-select: none;
user-select: none;
opacity: 0;
visibility: hidden;
transform: translateY(0.5rem);
transition:
opacity var(--evcc-transition-fast),
transform var(--evcc-transition-fast),
visibility var(--evcc-transition-fast);
background: color-mix(in srgb, var(--tab-bar-background) 80%, transparent);
}
.more-menu.open {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
:root.dark .more-menu {
border: 1px solid var(--bs-border-color);
}
.dropdown-item.active,
.dropdown-item.router-link-active {
background-color: transparent;
color: var(--bs-primary);
border-left: 2px solid var(--bs-primary);
}
</style>

View file

@ -1,7 +1,7 @@
<template>
<footer class="footer" data-testid="footer">
<div class="container py-2">
<div class="d-flex justify-content-between gap-2">
<div class="d-flex justify-content-between align-items-center gap-2">
<Version v-bind="version" />
<Savings v-bind="savings" />
</div>

View file

@ -3,7 +3,12 @@
<div v-if="offline || starting" class="modal-backdrop" />
<div
class="fixed-bottom alert d-flex justify-content-center align-items-center mb-0 rounded-0 p-2"
:class="{ visible: visible, 'alert-danger': showError, 'alert-secondary': !showError }"
:class="{
visible: visible,
'alert-danger': showError,
'alert-secondary': !showError,
'alert--bottomtabs': experimental && !blocking,
}"
role="alert"
data-testid="bottom-banner"
>
@ -33,14 +38,14 @@
</div>
<div
v-else-if="showError"
class="d-flex align-items-center container px-4 justify-content-center"
class="d-flex align-items-center container px-0 px-sm-4 justify-content-center flex-wrap gap-2"
data-testid="fatal-error"
>
<shopicon-regular-car1
size="m"
class="fatal-icon flex-grow-0 flex-shrink-0"
class="fatal-icon flex-grow-0 flex-shrink-0 d-none d-sm-block"
></shopicon-regular-car1>
<div class="mx-4 mt-1">
<div class="mx-3 mt-1">
<div>
<strong>
{{ $t("offline.configurationError") }}
@ -52,7 +57,7 @@
</div>
</div>
</div>
<RestartButton error @restart="restart" />
<RestartButton class="ms-auto" error @restart="restart" />
</div>
</div>
</div>
@ -76,6 +81,7 @@ export default defineComponent({
offline: Boolean,
fatal: { type: Array as PropType<FatalError[]>, default: () => [] },
startupCompleted: Boolean,
experimental: Boolean,
},
data() {
return { dismissed: false };
@ -90,6 +96,9 @@ export default defineComponent({
starting() {
return this.startupCompleted === false;
},
blocking() {
return this.offline || this.starting || this.restarting;
},
visible() {
return (
this.starting ||
@ -136,7 +145,12 @@ export default defineComponent({
min-height: 58px;
transition:
transform var(--evcc-transition-fast) ease-in,
opacity var(--evcc-transition-fast) ease-in;
opacity var(--evcc-transition-fast) ease-in,
padding-bottom var(--evcc-transition-fast) ease-in;
padding-bottom: max(0.5rem, var(--safe-area-inset-bottom)) !important;
border-bottom: none;
border-left: none;
border-right: none;
/* above backdrop, below modal https://getbootstrap.com/docs/5.3/layout/z-index/ */
z-index: 1054 !important;
}
@ -145,7 +159,8 @@ export default defineComponent({
transform: translateY(0);
transition:
transform var(--evcc-transition-medium) ease-in,
opacity var(--evcc-transition-medium) ease-in;
opacity var(--evcc-transition-medium) ease-in,
padding-bottom var(--evcc-transition-fast) ease-in;
}
.fatal-icon {
@ -164,6 +179,12 @@ export default defineComponent({
transform: translateY(6px) rotate(170deg);
}
}
.alert--bottomtabs {
z-index: 1029 !important;
padding-bottom: calc(
var(--tab-bar-height) + max(0.4rem, var(--safe-area-inset-bottom)) + 0.75rem
) !important;
}
.btn-close {
filter: none;
}

View file

@ -1,9 +1,6 @@
import Version from "./Version.vue";
import type { Meta, StoryFn } from "@storybook/vue3";
const releaseNotes =
'<h1>0.141</h1><p>Bug fixes:</p><ul><li>Withdraw Kia and Hyundai apis (<a class="issue-link js-issue-link" data-error-text="Failed to load title" data-id="817450535" data-permission-text="Title is private" data-url="https://github.com/evcc-io/evcc/issues/699" data-hovercard-type="pull_request" data-hovercard-url="/evcc-io/evcc/pull/699/hovercard" href="https://github.com/evcc-io/evcc/pull/699">#699</a>)</li><li>Simplify Tesla integration and fix upstream changes (<a class="issue-link js-issue-link" data-error-text="Failed to load title" data-id="817525274" data-permission-text="Title is private" data-url="https://github.com/evcc-io/evcc/issues/700" data-hovercard-type="pull_request" data-hovercard-url="/evcc-io/evcc/pull/700/hovercard" href="https://github.com/evcc-io/evcc/pull/700">#700</a>)</li><li>SHM: Check connected status (<a class="issue-link js-issue-link" data-error-text="Failed to load title" data-id="811567860" data-permission-text="Title is private" data-url="https://github.com/evcc-io/evcc/issues/673" data-hovercard-type="pull_request" data-hovercard-url="/evcc-io/evcc/pull/673/hovercard" href="https://github.com/evcc-io/evcc/pull/673">#673</a>)</li></ul><p>Enhancements:</p><ul><li>Add Seat api (<a class="issue-link js-issue-link" data-error-text="Failed to load title" data-id="812885392" data-permission-text="Title is private" data-url="https://github.com/evcc-io/evcc/issues/681" data-hovercard-type="pull_request" data-hovercard-url="/evcc-io/evcc/pull/681/hovercard" href="https://github.com/evcc-io/evcc/pull/681">#681</a>)</li><li>Add Skoda api (<a class="issue-link js-issue-link" data-error-text="Failed to load title" data-id="812883796" data-permission-text="Title is private" data-url="https://github.com/evcc-io/evcc/issues/680" data-hovercard-type="pull_request" data-hovercard-url="/evcc-io/evcc/pull/680/hovercard" href="https://github.com/evcc-io/evcc/pull/680">#680</a>)</li><li>Add Peugeot/Opel/Citroen api (<a class="issue-link js-issue-link" data-error-text="Failed to load title" data-id="815612446" data-permission-text="Title is private" data-url="https://github.com/evcc-io/evcc/issues/692" data-hovercard-type="pull_request" data-hovercard-url="/evcc-io/evcc/pull/692/hovercard" href="https://github.com/evcc-io/evcc/pull/692">#692</a>)</li><li>KEBA: Support mA current resolution adjustments (<a class="issue-link js-issue-link" data-error-text="Failed to load title" data-id="801358501" data-permission-text="Title is private" data-url="https://github.com/evcc-io/evcc/issues/646" data-hovercard-type="pull_request" data-hovercard-url="/evcc-io/evcc/pull/646/hovercard" href="https://github.com/evcc-io/evcc/pull/646">#646</a>)</li></ul>';
export default {
title: "Footer/Version",
component: Version,
@ -14,8 +11,6 @@ export default {
installed: { control: "text" },
available: { control: "text" },
commit: { control: "text" },
releaseNotes: { control: "text" },
hasUpdater: { control: "boolean" },
},
} as Meta<typeof Version>;
@ -29,34 +24,25 @@ const Template: StoryFn<typeof Version> = (args) => ({
export const Latest = Template.bind({});
Latest.args = {
installed: "0.141",
available: "0.141",
installed: "0.303.1",
available: "0.303.1",
};
export const Nightly = Template.bind({});
Nightly.args = {
installed: "0.141",
available: "0.141",
installed: "0.303.1",
available: "0.303.1",
commit: "5ce7be4",
};
export const UpdateAvailable = Template.bind({});
UpdateAvailable.args = {
installed: "0.141",
available: "0.142",
releaseNotes,
};
export const AutoUpdater = Template.bind({});
AutoUpdater.args = {
installed: "0.141",
available: "0.142",
releaseNotes,
hasUpdater: true,
installed: "0.303.0",
available: "0.303.1",
};
export const NoReleaseNotes = Template.bind({});
NoReleaseNotes.args = {
installed: "0.141",
available: "0.142",
installed: "0.303.0",
available: "0.303.1",
};

View file

@ -33,99 +33,14 @@
<Logo class="logo me-2 flex-shrink-0" />
<span class="text-decoration-underline text-truncate">v{{ installed }}</span>
</a>
<Teleport to="body">
<div
id="updateModal"
class="modal fade text-dark"
tabindex="-1"
role="dialog"
aria-hidden="true"
>
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ $t("footer.version.modalTitle") }}</h5>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
></button>
</div>
<div class="modal-body">
<div v-if="updateStarted">
<p>{{ $t("footer.version.modalUpdateStarted") }}</p>
<div class="progress my-3">
<div
class="progress-bar progress-bar-striped progress-bar-animated"
role="progressbar"
:style="{ width: uploadProgress + '%' }"
></div>
</div>
<p>{{ updateStatus }} {{ uploadMessage }}</p>
</div>
<div v-else>
<p>
<small>
{{ $t("footer.version.modalInstalledVersion") }}:
{{ installed }}
</small>
</p>
<!-- eslint-disable vue/no-v-html -->
<div v-if="releaseNotes" v-html="releaseNotes"></div>
<!-- eslint-enable vue/no-v-html -->
<p v-else>
{{ $t("footer.version.modalNoReleaseNotes") }}
<a :href="releaseNotesUrl(available)">GitHub</a>.
</p>
</div>
</div>
<div class="modal-footer d-flex justify-content-between">
<button
type="button"
class="btn btn-outline-secondary"
:disabled="updateStarted"
data-bs-dismiss="modal"
>
{{ $t("footer.version.modalCancel") }}
</button>
<div>
<button
v-if="hasUpdater"
type="button"
class="btn btn-primary"
:disabled="updateStarted"
@click="update"
>
<span v-if="updateStarted">
<span
class="spinner-border spinner-border-sm"
role="status"
aria-hidden="true"
>
</span>
{{ $t("footer.version.modalUpdate") }}
</span>
<span v-else>{{ $t("footer.version.modalUpdateNow") }}</span>
</button>
<a v-else :href="releaseNotesUrl(available)" class="btn btn-primary">
{{ $t("footer.version.modalDownload") }}
</a>
</div>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script lang="ts">
import Modal from "bootstrap/js/dist/modal";
import "@h2d2/shopicons/es/regular/gift";
import "@h2d2/shopicons/es/regular/moonstars";
import api from "@/api";
import Logo from "./Logo.vue";
import { isDevelopment } from "@/utils/version";
import { defineComponent } from "vue";
export default defineComponent({
@ -134,17 +49,7 @@ export default defineComponent({
props: {
installed: String,
available: String,
releaseNotes: String,
commit: String,
hasUpdater: Boolean,
uploadMessage: String,
uploadProgress: Number,
},
data() {
return {
updateStarted: false,
updateStatus: "",
};
},
computed: {
githubHashUrl() {
@ -152,23 +57,13 @@ export default defineComponent({
},
newVersionAvailable() {
return (
this.available && // available version already computed?
this.installed != "[[.Version]]" && // go template parsed?
this.installed != "0.0.0" && // make used?
this.available != this.installed
this.available &&
!isDevelopment(this.installed || "") &&
this.available !== this.installed
);
},
},
methods: {
async update() {
try {
await api.post("update");
this.updateStatus = this.$t("footer.version.modalUpdateStatusStart");
this.updateStarted = true;
} catch (e) {
this.updateStatus = `${this.$t("footer.version.modalUpdateStatusStart")} ${e}`;
}
},
releaseNotesUrl(version?: string) {
return version == "0.0.0"
? `https://github.com/evcc-io/evcc/releases`
@ -176,7 +71,7 @@ export default defineComponent({
},
openModal() {
const modal = Modal.getOrCreateInstance(
document.getElementById("updateModal") as HTMLElement
document.getElementById("aboutModal") as HTMLElement
);
modal.show();
},

View file

@ -0,0 +1,128 @@
<template>
<div ref="scrollEl" class="forecast-chart-scroll" @scroll="onScroll">
<div ref="chartEl" :style="{ height: '200px', width: chartWidth + 'px' }"></div>
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import {
FONT_FAMILY,
markPointLabel,
tooltipStyle,
forecastGrid,
forecastXAxes,
forecastYAxis,
clampStart,
filterForecastSlots,
minSlotIndex,
maxSlotIndex,
} from "./echarts";
import colors from "@/colors";
import formatter from "@/mixins/formatter";
import chartMixin from "./chartMixin";
import type { ForecastSlot } from "./types";
export default defineComponent({
name: "Co2Chart",
mixins: [formatter, chartMixin],
props: {
co2: { type: Array as PropType<ForecastSlot[]>, required: true },
},
computed: {
slots(): ForecastSlot[] {
return filterForecastSlots(this.co2, this.startDate, this.endDate);
},
markPoints(): {
coord: [string, number];
value: string;
label?: Record<string, unknown>;
}[] {
const slots = this.slots;
if (!slots.length) return [];
const minIdx = minSlotIndex(slots);
const maxIdx = maxSlotIndex(slots);
const points: {
coord: [string, number];
value: string;
label?: Record<string, unknown>;
}[] = [];
if (slots[minIdx]) {
points.push({
coord: [clampStart(slots[minIdx]!.start, this.startDate), slots[minIdx]!.value],
value: this.fmtGrams(slots[minIdx]!.value),
label: { position: "bottom", offset: [0, 2] },
});
}
if (maxIdx !== minIdx && slots[maxIdx]) {
points.push({
coord: [clampStart(slots[maxIdx]!.start, this.startDate), slots[maxIdx]!.value],
value: this.fmtGrams(slots[maxIdx]!.value),
});
}
return points;
},
chartOption(): Record<string, unknown> {
const co2Color = colors.co2 || "";
// eslint-disable-next-line @typescript-eslint/no-this-alias
const vThis = this;
return {
animationDuration: 0,
textStyle: { fontFamily: FONT_FAMILY },
grid: forecastGrid(),
tooltip: {
trigger: "axis",
axisPointer: { type: "line", snap: true, lineStyle: { color: "transparent" } },
...tooltipStyle(co2Color, () => this.chart),
formatter(params: { value: [string, number] }[]) {
const p = params[0];
if (!p) return "";
const d = new Date(p.value[0]);
const time = `${vThis.weekdayShort(d)} ${vThis.fmtHourMinute(d)}`;
return `${time}<br/>${vThis.fmtCo2Medium(p.value[1])}`;
},
},
xAxis: forecastXAxes(this.startDate, this.endDate, this.weekdayShort),
yAxis: forecastYAxis({
splitNumber: 2,
axisLabel: {
color: colors.muted,
formatter: (value: number) => `${Math.round(value)}`,
},
}),
series: [
{
type: "line",
data: this.slots.map((s) => [s.start, s.value]),
smooth: 0.05,
symbol: "circle",
symbolSize: 6,
showSymbol: false,
lineStyle: { color: co2Color, width: 3 },
emphasis: {
disabled: false,
scale: false,
itemStyle: { color: co2Color, borderColor: co2Color, borderWidth: 2 },
},
markPoint: markPointLabel(
co2Color,
this.tooltipVisible ? [] : this.markPoints,
this.startDate,
this.endDate
),
},
],
};
},
},
});
</script>
<style scoped>
.forecast-chart-scroll {
overflow-x: auto;
margin-bottom: 1.5rem;
padding-bottom: 4px;
}
</style>

View file

@ -0,0 +1,174 @@
<template>
<div ref="scrollEl" class="forecast-chart-scroll" @scroll="onScroll">
<div ref="chartEl" :style="{ height: '200px', width: chartWidth + 'px' }"></div>
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import {
FONT_FAMILY,
markPointLabel,
tooltipStyle,
forecastGrid,
forecastXAxes,
forecastYAxis,
clampStart,
filterForecastSlots,
minSlotIndex,
maxSlotIndex,
} from "./echarts";
import colors from "@/colors";
import formatter from "@/mixins/formatter";
import chartMixin from "./chartMixin";
import type { CURRENCY } from "@/types/evcc";
import type { ForecastSlot } from "./types";
export default defineComponent({
name: "PriceChart",
mixins: [formatter, chartMixin],
props: {
grid: { type: Array as PropType<ForecastSlot[]>, required: true },
currency: { type: String as PropType<CURRENCY> },
zoom: { type: Boolean, default: false },
},
data() {
return {
hoveredIndex: -1 as number,
};
},
computed: {
slots(): ForecastSlot[] {
return filterForecastSlots(this.grid, this.startDate, this.endDate);
},
markPoints(): { coord: [string, number]; value: string }[] {
const slots = this.slots;
if (!slots.length) return [];
const minIdx = minSlotIndex(slots);
const maxIdx = maxSlotIndex(slots);
const points: { coord: [string, number]; value: string }[] = [];
if (slots[minIdx]) {
points.push({
coord: [clampStart(slots[minIdx]!.start, this.startDate), slots[minIdx]!.value],
value: this.fmtPricePerKWh(slots[minIdx]!.value, this.currency, true, true),
});
}
if (maxIdx !== minIdx && slots[maxIdx]) {
points.push({
coord: [clampStart(slots[maxIdx]!.start, this.startDate), slots[maxIdx]!.value],
value: this.fmtPricePerKWh(slots[maxIdx]!.value, this.currency, true, true),
});
}
return points;
},
yAxisConfig(): Record<string, unknown> {
const values = this.slots.map((s) => s.value);
const dataMin = Math.min(...values);
const dataMax = Math.max(...values);
const fullMin = Math.min(0, dataMin);
const fullRange = dataMax - fullMin || 1;
const rawInterval = fullRange / 5;
const magnitude = Math.pow(10, Math.floor(Math.log10(rawInterval)));
const nice = [1, 2, 2.5, 5, 10].find((n) => n * magnitude >= rawInterval) || 10;
const interval = nice * magnitude;
if (this.zoom) {
return {
min: Math.floor(dataMin / interval) * interval,
max: Math.ceil(dataMax / interval) * interval,
interval,
};
}
return {
min: fullMin,
max: Math.ceil(dataMax / interval) * interval,
interval,
};
},
chartOption(): Record<string, unknown> {
const priceColor = colors.price || "";
// eslint-disable-next-line @typescript-eslint/no-this-alias
const vThis = this;
return {
animationDuration: 0,
textStyle: { fontFamily: FONT_FAMILY },
grid: forecastGrid(),
tooltip: {
trigger: "axis",
axisPointer: { type: "line", snap: true, lineStyle: { color: "transparent" } },
...tooltipStyle(priceColor, () => this.chart),
formatter(params: { value: [string, number] }[]) {
const p = params[0];
if (!p) return "";
const d = new Date(p.value[0]);
const time = `${vThis.weekdayShort(d)} ${vThis.fmtHourMinute(d)}`;
return `${time}<br/>${vThis.fmtPricePerKWh(p.value[1], vThis.currency, false, true)}`;
},
},
xAxis: forecastXAxes(this.startDate, this.endDate, this.weekdayShort),
yAxis: forecastYAxis({
...this.yAxisConfig,
axisLabel: {
color: colors.muted,
formatter: (value: number) => {
const v =
this.currency && this.energyPriceSubunit(this.currency)
? value * 100
: value;
return `${Math.round(v)}`;
},
},
}),
series: [
{
type: "bar",
cursor: "default",
data: this.slots.map((s, i) => ({
value: [clampStart(s.start, this.startDate), s.value],
itemStyle:
this.hoveredIndex >= 0 && i !== this.hoveredIndex
? { opacity: 0.33 }
: undefined,
})),
barMaxWidth: 4,
barMinWidth: 4,
itemStyle: {
color: priceColor,
borderRadius: 2,
},
emphasis: { disabled: true },
markPoint: markPointLabel(
priceColor,
this.tooltipVisible ? [] : this.markPoints,
this.startDate,
this.endDate
),
},
],
};
},
},
mounted() {
this.chart!.on("showTip", (params: unknown) => {
const p = params as { dataIndex?: number };
if (p.dataIndex != null) this.hoveredIndex = p.dataIndex;
});
this.chart!.on("hideTip", () => {
this.hoveredIndex = -1;
});
this.chart!.getZr().on("mouseout", () => {
this.tooltipVisible = false;
this.hoveredIndex = -1;
});
},
});
</script>
<style scoped>
.forecast-chart-scroll {
overflow-x: auto;
margin-bottom: 1.5rem;
padding-bottom: 4px;
}
</style>

View file

@ -0,0 +1,139 @@
<template>
<div ref="scrollEl" class="forecast-chart-scroll" @scroll="onScroll">
<div ref="chartEl" :style="{ height: '200px', width: chartWidth + 'px' }"></div>
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import {
FONT_FAMILY,
markPointLabel,
tooltipStyle,
forecastGrid,
forecastXAxes,
forecastYAxis,
} from "./echarts";
import colors, { lighterColor } from "@/colors";
import formatter, { POWER_UNIT } from "@/mixins/formatter";
import chartMixin from "./chartMixin";
import { highestSlotIndexByDay } from "@/utils/forecast";
import type { SolarDetails, TimeseriesEntry } from "./types";
export default defineComponent({
name: "SolarChart",
mixins: [formatter, chartMixin],
props: {
solar: { type: Object as PropType<SolarDetails> },
rawSolar: { type: Object as PropType<SolarDetails> },
},
computed: {
entries(): TimeseriesEntry[] {
return (this.solar?.timeseries || []).filter(
(e) => new Date(e.ts) >= this.startDate && new Date(e.ts) <= this.endDate
);
},
combinedMax(): number {
let max = 0;
for (const src of [this.solar, this.rawSolar]) {
for (const e of src?.timeseries || []) {
if (e.val > max) max = e.val;
}
}
return max;
},
markPoints(): { coord: [string, number]; value: string }[] {
const points: { coord: [string, number]; value: string }[] = [];
const days = [
{ energy: this.solar?.today?.energy, day: 0 },
{ energy: this.solar?.tomorrow?.energy, day: 1 },
{ energy: this.solar?.dayAfterTomorrow?.energy, day: 2 },
];
for (const { energy, day } of days) {
const idx = highestSlotIndexByDay(this.entries, day);
if (idx >= 0 && this.entries[idx] && energy) {
const entry = this.entries[idx]!;
points.push({
coord: [entry.ts, entry.val],
value: this.fmtWh(energy, POWER_UNIT.AUTO),
});
}
}
return points;
},
chartOption(): Record<string, unknown> {
const selfColor = colors.self || "";
const data = this.entries.map((e) => [e.ts, e.val]);
return {
animationDuration: 0,
textStyle: { fontFamily: FONT_FAMILY },
grid: forecastGrid(),
tooltip: {
trigger: "axis",
axisPointer: {
type: "line",
snap: true,
snapThreshold: 50,
lineStyle: { color: "transparent" },
},
...tooltipStyle(selfColor, () => this.chart),
formatter: (params: { value: [string, number] }[]) => {
const p = params[0];
if (!p) return "";
const d = new Date(p.value[0]);
const time = `${this.weekdayShort(d)} ${this.fmtHourMinute(d)}`;
return `${time}<br/>${this.fmtW(p.value[1], POWER_UNIT.AUTO)}`;
},
},
xAxis: forecastXAxes(this.startDate, this.endDate, this.weekdayShort),
yAxis: forecastYAxis({
max: (value: { max: number }) => {
const m = Math.max(value.max, this.combinedMax);
const step = Math.pow(10, Math.floor(Math.log10(m || 1)));
return Math.ceil(m / step) * step;
},
splitNumber: 2,
axisLabel: {
color: colors.muted,
formatter: (value: number) => this.fmtW(value, POWER_UNIT.KW, false, 0),
},
}),
series: [
{
type: "line",
data,
smooth: 0.05,
symbol: "circle",
symbolSize: 6,
showSymbol: false,
lineStyle: { color: selfColor, width: 3 },
areaStyle: { color: lighterColor(selfColor) },
emphasis: {
disabled: false,
scale: false,
lineStyle: { color: selfColor, width: 3 },
areaStyle: { color: lighterColor(selfColor) },
itemStyle: { color: selfColor, borderColor: selfColor, borderWidth: 2 },
},
markPoint: markPointLabel(
selfColor,
this.tooltipVisible ? [] : this.markPoints,
this.startDate,
this.endDate
),
},
],
};
},
},
});
</script>
<style scoped>
.forecast-chart-scroll {
overflow-x: auto;
margin-bottom: 1.5rem;
padding-bottom: 4px;
}
</style>

View file

@ -0,0 +1,67 @@
import { defineComponent, markRaw } from "vue";
import { echarts } from "./echarts";
// chartOption is provided by each consuming component's computed
type WithChartOption = { chartOption: Record<string, unknown> };
export default defineComponent({
props: {
chartWidth: { type: Number, required: true },
endDate: { type: Date, required: true },
scrollLeft: { type: Number, default: 0 },
},
emits: ["scroll"],
data(): {
chart: echarts.ECharts | null;
startDate: Date;
tooltipVisible: boolean;
} {
return {
chart: null,
tooltipVisible: false,
startDate: new Date(),
};
},
watch: {
chartOption: {
handler() {
this.chart?.setOption((this as unknown as WithChartOption).chartOption);
},
deep: true,
},
scrollLeft(val: number) {
const el = this.$refs["scrollEl"] as HTMLElement;
if (el && Math.abs(el.scrollLeft - val) > 1) {
el.scrollLeft = val;
}
},
},
mounted() {
this.updateStartDate();
this.initChart();
},
beforeUnmount() {
this.chart?.dispose();
},
methods: {
updateStartDate() {
const now = new Date();
now.setMinutes(0, 0, 0);
this.startDate = now;
},
onScroll(e: Event) {
this.$emit("scroll", (e.target as HTMLElement).scrollLeft);
},
initChart() {
const el = this.$refs["chartEl"] as HTMLElement;
this.chart = markRaw(echarts.init(el));
this.chart.setOption((this as unknown as WithChartOption).chartOption);
this.chart.on("showTip", () => {
this.tooltipVisible = true;
});
this.chart.on("hideTip", () => {
this.tooltipVisible = false;
});
},
},
});

View file

@ -0,0 +1,202 @@
import * as echarts from "echarts/core";
import colors from "@/colors";
import type { ForecastSlot } from "./types";
import { BarChart, LineChart } from "echarts/charts";
import {
GridComponent,
TooltipComponent,
MarkPointComponent,
AxisPointerComponent,
} from "echarts/components";
import { SVGRenderer } from "echarts/renderers";
echarts.use([
BarChart,
LineChart,
GridComponent,
TooltipComponent,
AxisPointerComponent,
MarkPointComponent,
SVGRenderer,
]);
export const FONT_FAMILY = "Montserrat, sans-serif";
export function markPointLabel(
color: string,
data: { coord: [string, number]; value: string; label?: { offset?: [number, number] } }[],
startDate?: Date,
endDate?: Date
) {
// threshold: points within the first 5% of the time range get shifted right
const threshold =
startDate && endDate
? startDate.getTime() + (endDate.getTime() - startDate.getTime()) * 0.05
: 0;
const adjustedData = data.map((d) => {
const ts = new Date(d.coord[0]).getTime();
if (threshold && ts < threshold) {
const baseOffset = d.label?.offset ?? [0, -2];
return {
...d,
label: {
...d.label,
offset: [baseOffset[0] + 30, baseOffset[1]] as [number, number],
},
};
}
return d;
});
return {
animation: true,
clip: false,
symbol: "rect",
symbolSize: 1,
label: {
show: true,
position: "top",
fontFamily: FONT_FAMILY,
fontSize: 14,
fontWeight: "bold" as const,
color: colors.background,
backgroundColor: color,
borderRadius: 4,
padding: [5, 10],
offset: [0, -2],
formatter: (p: { data: { value: string } }) => p.data.value,
},
data: adjustedData,
};
}
export function tooltipStyle(
color: string,
getChart?: () => { convertToPixel: echarts.ECharts["convertToPixel"] } | null
) {
return {
backgroundColor: color,
borderColor: color,
borderWidth: 0,
padding: [5, 10],
extraCssText: "box-shadow: none; border-radius: 4px; text-align: center;",
position(
point: [number, number],
params: { value: [string, number] }[] | { value: [string, number] },
el: HTMLElement
): [number, number] {
const w = el?.offsetWidth || 0;
const h = el?.offsetHeight || 0;
const margin = 8;
const arr = Array.isArray(params) ? params : [params];
const p = arr[0];
const chart = getChart?.();
if (chart && p?.value) {
const pixelY = chart.convertToPixel({ seriesIndex: 0 }, p.value)?.[1];
if (pixelY != null) {
return [point[0] - w / 2, pixelY - h - margin];
}
}
return [point[0] - w / 2, point[1] - h - margin];
},
textStyle: {
fontFamily: FONT_FAMILY,
fontSize: 14,
fontWeight: "bold" as const,
color: colors.background,
},
};
}
export function forecastGrid() {
return { top: 36, right: 16, bottom: 4, left: 48, borderWidth: 0 };
}
export function forecastXAxes(startDate: Date, endDate: Date, weekdayShort: (d: Date) => string) {
return [
{
type: "time",
min: startDate,
max: endDate,
minInterval: 3600 * 1000,
maxInterval: 3600 * 1000,
axisLabel: {
color: colors.muted,
fontSize: 14,
lineHeight: Math.round(14 * 1.1),
margin: 4,
formatter: (value: number) => {
const date = new Date(value);
const h = date.getHours();
if (h % 2 !== 0) return "";
if (h === 0) return `${h}\n${weekdayShort(date)}`;
return `${h}`;
},
},
splitLine: { show: false },
axisLine: { show: false },
axisTick: { show: false },
},
{
type: "time",
position: "bottom",
min: startDate,
max: endDate,
minInterval: 24 * 3600 * 1000,
maxInterval: 24 * 3600 * 1000,
axisLabel: { show: false },
axisLine: { show: false },
axisTick: { show: false },
splitLine: {
show: true,
showMinLine: false,
showMaxLine: false,
lineStyle: { color: colors.border || "", type: "dashed" },
},
},
];
}
export function forecastYAxis(overrides: Record<string, unknown> = {}) {
const { axisLabel, ...rest } = overrides;
return {
type: "value",
min: 0,
axisLine: { show: false },
axisTick: { show: false },
splitLine: {
showMinLine: false,
showMaxLine: false,
lineStyle: { color: colors.border || "" },
},
axisLabel: {
fontSize: 14,
...(axisLabel as Record<string, unknown>),
},
...rest,
};
}
export function clampStart(ts: string, startDate: Date): string {
return new Date(ts) < startDate ? startDate.toISOString() : ts;
}
export function filterForecastSlots(
slots: ForecastSlot[],
startDate: Date,
endDate: Date
): ForecastSlot[] {
if (!Array.isArray(slots)) return [];
return slots.filter((s) => new Date(s.end) >= startDate && new Date(s.start) <= endDate);
}
export function minSlotIndex(slots: ForecastSlot[]): number {
return slots.reduce((min, s, i) => (s.value < (slots[min]?.value || Infinity) ? i : min), 0);
}
export function maxSlotIndex(slots: ForecastSlot[]): number {
return slots.reduce((max, s, i) => (s.value > (slots[max]?.value || 0) ? i : max), 0);
}
export { echarts };

View file

@ -13,9 +13,9 @@
>
<div class="modal-dialog modal-dialog-centered" :class="sizeClass" role="document">
<div class="modal-content">
<div class="modal-header d-flex justify-content-between align-items-center">
<div class="modal-header d-flex justify-content-between align-items-start">
<h5 class="modal-title">
{{ title }}
<slot name="title">{{ title }}</slot>
</h5>
<div class="d-flex align-items-center gap-1">
<slot name="header-actions"></slot>

View file

@ -238,7 +238,7 @@ export default defineComponent({
}
/* show truncated tiles on breakpoint sm,md */
@media (min-width: 576px) and (max-width: 991.98px) {
@media (--sm-to-lg) {
.container--loadpoint {
max-width: none;
}
@ -259,14 +259,14 @@ export default defineComponent({
}
/* breakpoint sm */
@media (min-width: 576px) and (max-width: 767.98px) {
@media (--sm-to-md) {
.carousel {
--slide-width: 540px;
}
}
/* breakpoint md */
@media (min-width: 768px) and (max-width: 991.98px) {
@media (--md-to-lg) {
.carousel {
--slide-width: 720px;
}

View file

@ -0,0 +1,18 @@
<template>
<svg :style="svgStyle" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M3.75 19.75q-.325-.325-.325-.75t.325-.75L9.8 12.2q.15-.15.325-.213t.375-.062q.2 0 .375.062t.325.213l3.3 3.3l6.4-7.2q.275-.325.713-.325t.737.3q.275.275.287.663t-.262.687L15.2 17.7q-.15.175-.337.263t-.388.087q-.2 0-.387-.075t-.338-.225L10.5 14.5l-5.25 5.25q-.325.325-.75.325t-.75-.325ZM4 13.3q-.125 0-.25-.063t-.2-.212l-.5-1.075l-1.075-.5q-.15-.075-.213-.2T1.7 11q0-.125.063-.25t.212-.2l1.075-.5l.5-1.075q.075-.15.2-.212T4 8.7q.125 0 .25.063t.2.212l.5 1.075l1.075.5q.275.125.275.45t-.275.45l-1.075.5l-.5 1.075q-.075.15-.2.212T4 13.3Zm11-2q-.125 0-.25-.063t-.2-.212l-.5-1.075l-1.075-.5q-.15-.075-.212-.2T12.7 9q0-.125.063-.25t.212-.2l1.075-.5l.5-1.075q.075-.15.2-.212T15 6.7q.125 0 .25.063t.2.212l.5 1.075l1.075.5q.15.075.213.2T17.3 9q0 .125-.063.25t-.212.2l-1.075.5l-.5 1.075q-.075.15-.2.213T15 11.3Zm-6.5-3q-.125 0-.25-.075T8.05 8L7.4 6.6L6 5.95q-.15-.075-.225-.2T5.7 5.5q0-.125.075-.25T6 5.05l1.4-.65l.65-1.4q.075-.15.2-.225T8.5 2.7q.125 0 .25.075t.2.225l.65 1.4l1.4.65q.15.075.225.2t.075.25q0 .125-.075.25t-.225.2l-1.4.65L8.95 8q-.075.15-.2.225T8.5 8.3Z"
/>
</svg>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import icon from "@/mixins/icon";
export default defineComponent({
name: "ForecastGraph",
mixins: [icon],
});
</script>

View file

@ -0,0 +1,18 @@
<template>
<svg :style="svgStyle" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M12 20q-.825 0-1.412-.587T10 18t.588-1.412T12 16t1.413.588T14 18t-.587 1.413T12 20m0-6q-.825 0-1.412-.587T10 12t.588-1.412T12 10t1.413.588T14 12t-.587 1.413T12 14m0-6q-.825 0-1.412-.587T10 6t.588-1.412T12 4t1.413.588T14 6t-.587 1.413T12 8"
/>
</svg>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import icon from "@/mixins/icon";
export default defineComponent({
name: "More",
mixins: [icon],
});
</script>

View file

@ -0,0 +1,18 @@
<template>
<svg :style="svgStyle" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M4 16q-.425 0-.712-.288T3 15t.288-.712T4 14h6q.425 0 .713.288T11 15t-.288.713T10 16zm0-4q-.425 0-.712-.288T3 11t.288-.712T4 10h10q.425 0 .713.288T15 11t-.288.713T14 12zm0-4q-.425 0-.712-.288T3 7t.288-.712T4 6h10q.425 0 .713.288T15 7t-.288.713T14 8zm12.35 10.575q-.2 0-.375-.062t-.325-.213l-2.15-2.15q-.275-.275-.287-.687t.287-.713q.275-.275.688-.288t.712.263l1.45 1.425l3.525-3.525q.3-.3.713-.287t.712.312q.275.3.288.7t-.288.7l-4.25 4.25q-.15.15-.325.213t-.375.062"
/>
</svg>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import icon from "@/mixins/icon";
export default defineComponent({
name: "SessionsIcon",
mixins: [icon],
});
</script>

View file

@ -1,17 +1,26 @@
<template>
<div>
<button
class="btn btn-link pe-0 text-decoration-none evcc-default-text text-nowrap d-flex align-items-end"
class="btn btn-link p-0 text-decoration-none text-nowrap d-flex align-items-center evcc-gray"
data-testid="savings-button"
@click="openModal"
>
<span class="d-inline d-sm-none text-decoration-underline">{{
$t("footer.savings.footerShort", { percent })
}}</span
><span class="d-none d-sm-inline text-decoration-underline">{{
$t("footer.savings.footerLong", { percent })
<span v-if="indicatorValueShort" class="indicator-value d-block d-sm-none">{{
indicatorValueShort
}}</span>
<shopicon-regular-sun class="ms-2 text-evcc"></shopicon-regular-sun>
<span v-if="indicatorValue" class="indicator-value d-none d-sm-block">{{
indicatorValue
}}</span>
<DynamicPriceIcon v-if="indicator === 'price'" class="ms-2" />
<shopicon-regular-sun
v-else-if="indicator === 'solar' || indicator === 'none'"
class="ms-2"
></shopicon-regular-sun>
<shopicon-regular-receivepayment
v-else-if="indicator === 'savings'"
class="ms-2"
></shopicon-regular-receivepayment>
<shopicon-regular-eco1 v-else class="ms-2"></shopicon-regular-eco1>
</button>
<Teleport to="body">
@ -103,7 +112,7 @@
:sub1="
priceConfigured && referenceGrid
? `${fmtMoney(
(referenceGrid - avgPrice) * totalCharged,
moneySaved,
currency,
false
)} ${fmtCurrencySymbol(currency)} ${$t(
@ -123,8 +132,7 @@
:sub1="
region && co2Configured
? `${fmtNumber(
((region.co2 - avgCo2) * totalCharged) /
1000,
co2Saved,
0,
'kilogram'
)} ${$t('footer.savings.co2Saved')}`
@ -132,60 +140,107 @@
"
/>
</div>
<div class="my-3 lh-2">
<div class="d-flex">
<label for="savingsPeriod" class="me-1">
{{ $t("footer.savings.periodLabel") }}
</label>
<CustomSelect
id="savingsPeriod"
:selected="period"
:options="periodOptions"
data-testid="savings-period-select"
@change="selectPeriod($event.target.value)"
>
<span class="text-decoration-underline evcc-gray">
{{ $t(`footer.savings.period.${period}`) }}
</span>
</CustomSelect>
</div>
<div
v-if="region"
class="d-flex flex-wrap"
data-testid="savings-reference"
<table class="mt-3 mb-2 lh-2">
<tbody>
<tr>
<td class="pe-3 align-top">
{{ $t("footer.savings.periodLabel") }}
</td>
<td>
<CustomSelect
id="savingsPeriod"
:selected="period"
:options="periodOptions"
data-testid="savings-period-select"
@change="selectPeriod($event.target.value)"
>
<span
class="text-decoration-underline evcc-gray"
>
{{ $t(`footer.savings.period.${period}`) }}
</span>
</CustomSelect>
</td>
</tr>
<tr>
<td class="pe-3 align-top">
{{ $t("footer.savings.indicatorLabel") }}
</td>
<td>
<CustomSelect
:selected="indicator"
:options="indicatorOptions"
data-testid="savings-indicator-select"
@change="selectIndicator($event.target.value)"
>
<span
class="text-decoration-underline evcc-gray"
>
{{
indicatorValue
? `${indicatorValue} ${$t(`footer.savings.indicator.${indicator}`)}`
: $t(
`footer.savings.indicator.${indicator}`
)
}}
</span>
</CustomSelect>
</td>
</tr>
<tr v-if="region" data-testid="savings-reference">
<td class="pe-3 align-top">
{{ $t("footer.savings.referenceLabel") }}
</td>
<td class="evcc-gray">
<div>
<span v-if="isDynamicPrice">⌀ </span
>{{
priceConfigured
? fmtPricePerKWh(
referenceGrid,
currency
)
: "___"
}}
(<a
href="#"
class="evcc-gray text-decoration-underline"
@click.prevent="navigateToTariffs"
>{{ $t("config.main.title") }}</a
>)
</div>
<div class="d-flex">
<span class="me-1"
>⌀ {{ fmtCo2Medium(region.co2) }}</span
>
<CustomSelect
class="evcc-gray"
:selected="region.name"
:options="regionOptions"
data-testid="savings-region-select"
@change="selectRegion($event.target.value)"
>
(<span class="text-decoration-underline">{{
region.name
}}</span
>)
</CustomSelect>
</div>
</td>
</tr>
</tbody>
</table>
<div v-if="!priceConfigured || !co2Configured">
<a
href="#"
class="evcc-gray"
@click.prevent="navigateToTariffs"
>
<div class="me-1">
{{ $t("footer.savings.referenceLabel") }}
</div>
<div class="evcc-gray me-1">
{{
priceConfigured
? fmtPricePerKWh(referenceGrid, currency)
: "___"
}}
({{ $t("footer.savings.referenceGrid") }}),
</div>
<div class="evcc-gray d-flex">
<div class="me-1">⌀ {{ fmtCo2Medium(region.co2) }}</div>
<CustomSelect
class="me-1 evcc-gray"
:selected="region.name"
:options="regionOptions"
data-testid="savings-region-select"
@change="selectRegion($event.target.value)"
>
(<span class="text-decoration-underline">{{
region.name
}}</span
>)
</CustomSelect>
</div>
</div>
<div v-if="!priceConfigured || !co2Configured">
<a :href="tariffLink" class="evcc-gray" target="_blank">
{{ $t("footer.savings.configurePriceCo2") }}
</a>
</div>
{{ $t("footer.savings.configurePriceCo2") }}
</a>
</div>
<div class="evcc-gray small">
{{ $t("footer.savings.sessionInfo") }}
</div>
</div>
<div v-else class="my-4">
@ -212,17 +267,31 @@ import Tile from "./Tile.vue";
import LiveCommunity from "./LiveCommunity.vue";
import TelemetrySettings from "../TelemetrySettings.vue";
import CustomSelect from "../Helper/CustomSelect.vue";
import DynamicPriceIcon from "../MaterialIcon/DynamicPrice.vue";
import "@h2d2/shopicons/es/regular/receivepayment";
import "@h2d2/shopicons/es/regular/eco1";
import settings from "@/settings.ts";
import api, { allowClientError } from "@/api.ts";
import { docsPrefix } from "@/i18n.ts";
import { defineComponent, type PropType } from "vue";
import type { CURRENCY, Rate, SelectOption, Sponsor as SponsorType } from "@/types/evcc";
import type { Period } from "./types";
import type {
CURRENCY,
Forecast,
StatisticsIndicator,
StatisticsPeriod,
SelectOption,
Sponsor as SponsorType,
} from "@/types/evcc";
import co2Reference from "./co2Reference.ts";
export default defineComponent({
name: "Savings",
components: { Sponsor, SavingsTile: Tile, LiveCommunity, TelemetrySettings, CustomSelect },
components: {
Sponsor,
SavingsTile: Tile,
LiveCommunity,
TelemetrySettings,
CustomSelect,
DynamicPriceIcon,
},
mixins: [formatter],
props: {
statistics: { type: Object, default: () => ({}) },
@ -230,23 +299,21 @@ export default defineComponent({
sponsor: Object as PropType<SponsorType>,
currency: String as PropType<CURRENCY>,
telemetry: Boolean,
forecast: Object as PropType<Forecast>,
tariffGrid: Number,
},
data() {
return {
communityView: false,
telemetryEnabled: false,
period: settings.savingsPeriod || ("30d" as Period),
period: settings.savingsPeriod || ("30d" as StatisticsPeriod),
selectedRegion: settings.savingsRegion || ("Germany" as string),
referenceGrid: undefined as number | undefined,
};
},
computed: {
sponsorActive(): boolean {
return !!this.sponsor?.status?.name;
},
tariffLink() {
return `${docsPrefix()}/docs/reference/configuration/tariffs`;
},
percent() {
return this.fmtPercentage(this.solarPercentage || 0);
},
@ -268,8 +335,8 @@ export default defineComponent({
// first region
return co2Reference.regions[0];
},
periodOptions(): SelectOption<Period>[] {
return (["30d", "365d", "thisYear", "total"] as Period[]).map((p) => ({
periodOptions(): SelectOption<StatisticsPeriod>[] {
return (["30d", "365d", "thisYear", "total"] as StatisticsPeriod[]).map((p) => ({
value: p,
name: this.$t(`footer.savings.period.${p}`),
}));
@ -305,9 +372,51 @@ export default defineComponent({
avgCo2() {
return this.currentStatistics.avgCo2;
},
referenceGrid(): number | undefined {
const grid = this.forecast?.grid;
if (grid?.length) {
return grid.reduce((acc, slot) => acc + slot.value, 0) / grid.length;
}
return this.tariffGrid;
},
isDynamicPrice(): boolean {
const grid = this.forecast?.grid;
if (!grid?.length) return false;
return grid.some((slot) => slot.value !== grid[0].value);
},
priceConfigured() {
return this.referenceGrid !== undefined;
},
moneySaved(): number {
return Math.max(0, ((this.referenceGrid ?? 0) - this.avgPrice) * this.totalCharged);
},
co2Saved(): number {
return Math.max(
0,
(((this.region?.co2 ?? 0) - this.avgCo2) * this.totalCharged) / 1000
);
},
indicator(): StatisticsIndicator {
return (settings.savingsIndicator as StatisticsIndicator) || "solar";
},
indicatorOptions(): SelectOption<StatisticsIndicator>[] {
return (
["none", "solar", "price", "savings", "co2", "co2saved"] as StatisticsIndicator[]
).map((key) => {
const label = this.$t(`footer.savings.indicator.${key}`);
const val = this.indicatorValueFor(key);
return {
value: key,
name: val ? `${val} ${label}` : label,
};
});
},
indicatorValue(): string | undefined {
return this.indicatorValueFor(this.indicator);
},
indicatorValueShort(): string | undefined {
return this.indicatorValueFor(this.indicator, true);
},
},
methods: {
showCommunity() {
@ -317,13 +426,10 @@ export default defineComponent({
this.communityView = false;
},
openModal() {
const modal = Modal.getOrCreateInstance(
document.getElementById("savingsModal") as HTMLElement
);
const modal = Modal.getOrCreateInstance(this.$refs["modal"] as HTMLElement);
modal.show();
this.updateReferenceGrid();
},
selectPeriod(period: Period) {
selectPeriod(period: StatisticsPeriod) {
this.period = period;
settings.savingsPeriod = period;
},
@ -331,19 +437,40 @@ export default defineComponent({
this.selectedRegion = region;
settings.savingsRegion = region;
},
async updateReferenceGrid() {
try {
const res = await api.get(`tariff/grid`, allowClientError);
const { rates } = res.data as { rates: Rate[] };
this.referenceGrid =
rates.reduce((acc, slot) => {
return acc + slot.value;
}, 0) / rates.length;
} catch (e) {
this.referenceGrid = undefined;
console.error(e);
selectIndicator(value: StatisticsIndicator) {
settings.savingsIndicator = value;
},
navigateToTariffs() {
const modal = Modal.getInstance(this.$refs["modal"] as HTMLElement);
modal?.hide();
this.$router.push("/config#tariffs");
},
indicatorValueFor(key: StatisticsIndicator, short = false): string | undefined {
switch (key) {
case "solar":
return this.percent;
case "price":
if (this.avgPrice === undefined || !this.priceConfigured) return undefined;
return this.fmtPricePerKWh(this.avgPrice, this.currency, short);
case "savings":
if (!this.priceConfigured || this.referenceGrid === undefined) return undefined;
return `${this.fmtMoney(this.moneySaved, this.currency)} ${this.fmtCurrencySymbol(this.currency)}`;
case "co2":
if (this.avgCo2 === undefined || !this.co2Configured) return undefined;
return short ? this.fmtCo2Short(this.avgCo2) : this.fmtCo2Medium(this.avgCo2);
case "co2saved":
if (!this.co2Configured || !this.region) return undefined;
return `${this.fmtNumber(this.co2Saved, 0, "kilogram")}`;
default:
return undefined;
}
},
},
});
</script>
<style scoped>
.indicator-value {
font-size: 1rem;
}
</style>

View file

@ -10,4 +10,3 @@ export interface LiveCommunityData {
chargeEnergy: number;
greenEnergy: number;
}
export type Period = "30d" | "365d" | "thisYear" | "total";

View file

@ -1,5 +1,8 @@
<template>
<div class="d-flex flex-column site safe-area-inset">
<div
class="d-flex flex-column site safe-area-inset"
:class="{ 'site--bottomtabs': experimental }"
>
<div class="container px-4 top-area">
<div
class="d-flex justify-content-between align-items-center my-3 my-md-4"
@ -70,18 +73,18 @@
:selectedId="selectedLoadpointId"
@id-changed="selectedLoadpointChanged"
/>
<Footer v-bind="footer"></Footer>
<Footer v-if="!experimental" v-bind="footer" />
</div>
</div>
</template>
<script lang="ts">
import "@h2d2/shopicons/es/regular/arrowup";
import Footer from "../Footer/Footer.vue";
import TopNavigationArea from "../Top/TopNavigationArea.vue";
import Energyflow from "../Energyflow/Energyflow.vue";
import HemsWarning from "../HemsWarning.vue";
import Loadpoints from "../Loadpoints/Loadpoints.vue";
import Footer from "../Footer/Footer.vue";
import formatter from "@/mixins/formatter";
import collector from "@/mixins/collector.ts";
import WelcomeIcons from "./WelcomeIcons.vue";
@ -105,9 +108,9 @@ import type { Grid } from "./types";
export default defineComponent({
name: "Site",
components: {
Footer,
Loadpoints,
Energyflow,
Footer,
HemsWarning,
TopNavigationArea,
WelcomeIcons,
@ -129,7 +132,7 @@ export default defineComponent({
aux: { type: Array as PropType<Meter[]>, default: () => [] },
ext: { type: Array as PropType<Meter[]>, default: () => [] },
batteryDischargeControl: Boolean,
batteryGridChargeLimit: { type: Number, default: null },
batteryGridChargeLimit: { type: [Number, null] as PropType<number | null>, default: null },
batteryGridChargeActive: Boolean,
batteryMode: String,
battery: { type: Object as PropType<Battery> },
@ -202,18 +205,15 @@ export default defineComponent({
installed: window.evcc.version,
commit: window.evcc.commit,
available: this.availableVersion,
releaseNotes: this.releaseNotes,
hasUpdater: this.hasUpdater,
uploadMessage: this.uploadMessage,
uploadProgress: this.uploadProgress,
},
savings: {
sponsor: this.sponsor,
statistics: this.statistics,
co2Configured: this.tariffCo2 !== undefined,
priceConfigured: this.tariffGrid !== undefined,
currency: this.currency,
telemetry: this.telemetry,
forecast: this.forecast,
tariffGrid: this.tariffGrid,
},
};
},
@ -238,6 +238,10 @@ export default defineComponent({
min-height: 100vh;
min-height: 100dvh;
}
.site--bottomtabs {
min-height: calc(100vh - var(--tab-bar-height) - var(--safe-area-inset-bottom));
min-height: calc(100dvh - var(--tab-bar-height) - var(--safe-area-inset-bottom));
}
.content-area {
flex-grow: 1;
z-index: 1;

View file

@ -4,7 +4,7 @@
<p>
{{ description }}
</p>
<div class="row mb-3 align-items-center">
<div class="row mb-3 align-items-center" style="max-width: 1000px">
<label :for="formId" class="col-sm-4 col-form-label pt-0 pt-sm-2">
{{ limitLabel }}
</label>

View file

@ -31,7 +31,7 @@
:class="{ unknown: slot.value === undefined && avgValue }"
></div>
<div class="slot-label">
<span v-if="slot.start.getMinutes() === 0">{{
<span v-if="slot.start.getMinutes() === 0 && slot.start.getHours() % 2 === 0">{{
formatHour(slot.start.getHours())
}}</span>
<br />
@ -258,8 +258,8 @@ export default defineComponent({
line-height: 1.1;
position: absolute;
top: 100%;
left: -100%;
width: 200%;
left: 50%;
transform: translateX(-50%);
text-align: center;
}
.slot.active .slot-bar {

View file

@ -4,20 +4,22 @@
data-testid="header"
>
<h1 class="mb-1 pt-1 d-flex text-nowrap text-truncate">
<router-link class="evcc-default-text" to="/" data-testid="home-link">
<shopicon-regular-home size="s" class="icon"></shopicon-regular-home>
</router-link>
<div v-if="showConfig" class="d-flex">
<div size="s" class="mx-2 flex-grow-0 flex-shrink-0 fw-normal">/</div>
<router-link to="/config" class="evcc-default-text text-decoration-none">
<shopicon-regular-settings
size="s"
class="icon d-block d-sm-none"
></shopicon-regular-settings>
<span class="d-none d-sm-block">{{ $t("config.main.title") }}</span>
<template v-if="!experimental">
<router-link class="evcc-default-text" to="/" data-testid="home-link">
<shopicon-regular-home size="s" class="icon"></shopicon-regular-home>
</router-link>
</div>
<div size="s" class="mx-2 flex-grow-0 flex-shrink-0 fw-normal">/</div>
<div v-if="showConfig" class="d-flex">
<div size="s" class="mx-2 flex-grow-0 flex-shrink-0 fw-normal">/</div>
<router-link to="/config" class="evcc-default-text text-decoration-none">
<shopicon-regular-settings
size="s"
class="icon d-block d-sm-none"
></shopicon-regular-settings>
<span class="d-none d-sm-block">{{ $t("config.main.title") }}</span>
</router-link>
</div>
<div size="s" class="mx-2 flex-grow-0 flex-shrink-0 fw-normal">/</div>
</template>
<span class="text-truncate">{{ title }}</span>
</h1>
<TopNavigationArea ref="navigationArea" :notifications="notifications" />
@ -28,6 +30,7 @@
import "@h2d2/shopicons/es/regular/home";
import "@h2d2/shopicons/es/regular/settings";
import TopNavigationArea from "./TopNavigationArea.vue";
import store from "@/store";
import { defineComponent, type PropType } from "vue";
import type { Notification } from "@/types/evcc";
@ -41,6 +44,11 @@ export default defineComponent({
title: String,
notifications: { type: Array as PropType<Notification[]>, default: () => [] },
},
computed: {
experimental(): boolean {
return store.state.experimental ?? false;
},
},
methods: {
requestAuthProvider(providerId: string) {
const navigationArea = this.$refs["navigationArea"] as

View file

@ -1,7 +1,12 @@
<template>
<div class="d-flex">
<Notifications :notifications="notifications" :loadpoints="loadpoints" class="me-2" />
<TopNavigation v-bind="topNavigation" @auth-required="openAuthModal" />
<div class="d-flex gap-2">
<Notifications
:notifications="notifications"
:loadpoints="loadpoints"
:class="experimental ? 'd-flex align-items-center' : ''"
/>
<TopNavigation v-if="!experimental" v-bind="topNavigation" @auth-required="openAuthModal" />
<Savings v-if="experimental" v-bind="savings" />
<AuthProviderModal :provider="authProvider" />
</div>
</template>
@ -12,6 +17,7 @@ import Modal from "bootstrap/js/dist/modal";
import Navigation from "./Navigation.vue";
import Notifications from "./Notifications.vue";
import AuthProviderModal from "./AuthProviderModal.vue";
import Savings from "../Savings/Savings.vue";
import type { Provider } from "./types";
import type { Notification } from "@/types/evcc";
import collector from "@/mixins/collector";
@ -23,6 +29,7 @@ export default defineComponent({
TopNavigation: Navigation,
Notifications,
AuthProviderModal,
Savings,
},
mixins: [collector],
props: {
@ -35,9 +42,23 @@ export default defineComponent({
};
},
computed: {
experimental(): boolean {
return store.state?.experimental || false;
},
topNavigation(): any {
return this.collectProps(Navigation, store.state);
},
savings() {
return {
sponsor: store.state.sponsor,
statistics: store.state.statistics,
co2Configured: store.state.tariffCo2 !== undefined,
currency: store.state.currency,
telemetry: store.state.telemetry,
forecast: store.state.forecast,
tariffGrid: store.state.tariffGrid,
};
},
loadpoints() {
return store.uiLoadpoints.value || [];
},

View file

@ -13,6 +13,8 @@ import {
isConfigured,
} from "./components/Auth/auth";
import { initConfigModal } from "./configModal";
import { hapticFeedback } from "./utils/haptic";
import store from "./store";
import type { VueI18nInstance } from "vue-i18n";
function hideAllModals() {
@ -62,6 +64,21 @@ export default function setupRouter(i18n: VueI18nInstance) {
const router = createRouter({
history: createWebHashHistory(),
stringifyQuery,
scrollBehavior(to, from) {
if (to.hash) {
return new Promise((resolve) => {
const check = () => {
if (document.querySelector(to.hash)) {
setTimeout(() => resolve({ el: to.hash, behavior: "smooth" }), 200);
} else {
requestAnimationFrame(check);
}
};
check();
});
}
return to.path !== from.path ? { top: 0, behavior: "instant" } : false;
},
routes: [
{
path: "/",
@ -94,8 +111,13 @@ export default function setupRouter(i18n: VueI18nInstance) {
},
},
{
path: "/energy",
component: () => import("./views/Energy.vue"),
path: "/forecast",
component: () => import("./views/Forecast.vue"),
props: true,
},
{
path: "/battery",
component: () => import("./views/Battery.vue"),
props: true,
},
{
@ -131,6 +153,9 @@ export default function setupRouter(i18n: VueI18nInstance) {
// Only hide modals when the actual route path changes, not query parameters
if (to.path !== from.path) {
hideAllModals();
if (store.state.experimental) {
hapticFeedback();
}
}
});
initConfigModal(router);

View file

@ -16,9 +16,11 @@ const LOADPOINTS = "loadpoints";
const SESSION_COLUMNS = "session_columns";
const SAVINGS_PERIOD = "savings_period";
const SAVINGS_REGION = "savings_region";
const SAVINGS_INDICATOR = "savings_indicator";
const SESSIONS_GROUP = "sessions_group";
const SESSIONS_TYPE = "sessions_type";
const SETTINGS_SOLAR_ADJUSTED = "settings_solar_adjusted";
const SETTINGS_PRICE_ZOOM = "settings_price_zoom";
const LAST_BATTERY_SMART_COST_LIMIT = "last_battery_smart_cost_limit";
const LAST_TARGET_TIME = "last_target_time";
const LAST_SOC_GOAL = "last_soc_goal";
@ -117,9 +119,11 @@ export interface Settings {
sessionColumns: string[];
savingsPeriod: string;
savingsRegion: string;
savingsIndicator: string;
sessionsGroup: string;
sessionsType: string;
solarAdjusted: boolean;
priceZoom: boolean;
loadpoints: Record<string, LoadpointSettings>;
lastBatterySmartCostLimit: number | undefined;
lastTargetTime: string | null;
@ -142,9 +146,11 @@ const settings: Settings = reactive({
sessionColumns: readArray(SESSION_COLUMNS),
savingsPeriod: read(SAVINGS_PERIOD),
savingsRegion: read(SAVINGS_REGION),
savingsIndicator: read(SAVINGS_INDICATOR),
sessionsGroup: read(SESSIONS_GROUP),
sessionsType: read(SESSIONS_TYPE),
solarAdjusted: readBool(SETTINGS_SOLAR_ADJUSTED),
priceZoom: readBool(SETTINGS_PRICE_ZOOM),
loadpoints: readJSON(LOADPOINTS),
lastBatterySmartCostLimit: readNumber(LAST_BATTERY_SMART_COST_LIMIT),
lastTargetTime: read(LAST_TARGET_TIME),
@ -166,9 +172,11 @@ watch(() => settings.energyflowConsumers, saveBool(SETTINGS_ENERGYFLOW_CONSUMERS
watch(() => settings.sessionColumns as string[], saveArray(SESSION_COLUMNS));
watch(() => settings.savingsPeriod, save(SAVINGS_PERIOD));
watch(() => settings.savingsRegion, save(SAVINGS_REGION));
watch(() => settings.savingsIndicator, save(SAVINGS_INDICATOR));
watch(() => settings.sessionsGroup, save(SESSIONS_GROUP));
watch(() => settings.sessionsType, save(SESSIONS_TYPE));
watch(() => settings.solarAdjusted, saveBool(SETTINGS_SOLAR_ADJUSTED));
watch(() => settings.priceZoom, saveBool(SETTINGS_PRICE_ZOOM));
watch(() => settings.loadpoints, saveJSON(LOADPOINTS), { deep: true });
watch(() => settings.lastBatterySmartCostLimit, saveNumber(LAST_BATTERY_SMART_COST_LIMIT));
watch(() => settings.lastTargetTime, save(LAST_TARGET_TIME));

View file

@ -50,6 +50,18 @@ export interface FatalError {
device?: string;
}
export type StatisticsPeriod = "30d" | "365d" | "thisYear" | "total";
export type StatisticsIndicator = "none" | "solar" | "price" | "savings" | "co2" | "co2saved";
export interface StatisticsData {
avgCo2: number;
avgPrice: number;
chargedKWh: number;
solarPercentage: number;
}
export type Statistics = Record<StatisticsPeriod, StatisticsData>;
export interface State {
offline: boolean;
telemetry?: boolean;
@ -85,8 +97,16 @@ export interface State {
messagingEvents?: MessagingEvents;
interval?: number;
circuits?: Record<string, Circuit>;
bufferSoc?: number;
prioritySoc?: number;
bufferStartSoc?: number;
batteryDischargeControl?: boolean;
batteryGridChargeLimit?: number | null;
smartCostAvailable?: boolean;
smartCostType?: SMART_COST_TYPE;
siteTitle?: string;
vehicles: Record<string, Vehicle>;
statistics?: Statistics;
authDisabled?: boolean;
config?: string;
database?: string;

View file

@ -0,0 +1,3 @@
export function hapticFeedback(): void {
navigator.vibrate?.(5);
}

View file

@ -0,0 +1,23 @@
export function isDevelopment(version: string): boolean {
return version === "[[.Version]]" || version === "0.0.0";
}
export function isNightly(version: string, commit?: string): boolean {
return !isDevelopment(version) && !!commit;
}
export function getReleaseName(version: string, commit?: string): string {
if (isDevelopment(version)) return "development";
if (isNightly(version, commit)) return "nightly";
return "stable";
}
export function shortCommit(commit?: string): string {
return commit?.substring(0, 7) || "";
}
export function getShortVersion(version: string, commit?: string): string {
if (isDevelopment(version)) return "dev build";
if (isNightly(version, commit)) return `v${version} (${shortCommit(commit)})`;
return version;
}

View file

@ -1,14 +1,17 @@
<template>
<div class="app">
<div class="app" :class="{ 'app--bottomtabs': state.experimental }">
<router-view
v-if="showRoutes"
:notifications="notifications"
:offline="offline"
></router-view>
<BottomTabBar v-if="state.experimental" v-bind="bottomTabBarProps" />
<GlobalSettingsModal v-bind="globalSettingsProps" />
<BatterySettingsModal v-if="batteryModalAvailabe" v-bind="batterySettingsProps" />
<ForecastModal v-bind="forecastModalProps" />
<AboutModal v-bind="aboutModalProps" />
<HelpModal />
<PasswordModal />
<LoginModal v-bind="loginModalProps" />
@ -18,12 +21,14 @@
<script lang="ts">
import store from "../store";
import BottomTabBar from "../components/BottomTabs/Bar.vue";
import GlobalSettingsModal from "../components/GlobalSettings/GlobalSettingsModal.vue";
import BatterySettingsModal from "../components/Battery/BatterySettingsModal.vue";
import ForecastModal from "../components/Forecast/ForecastModal.vue";
import OfflineIndicator from "../components/Footer/OfflineIndicator.vue";
import PasswordModal from "../components/Auth/PasswordModal.vue";
import LoginModal from "../components/Auth/LoginModal.vue";
import AboutModal from "../components/AboutModal.vue";
import HelpModal from "../components/HelpModal.vue";
import collector from "../mixins/collector";
import { defineComponent } from "vue";
@ -44,6 +49,8 @@ setInterval(() => {
export default defineComponent({
name: "App",
components: {
AboutModal,
BottomTabBar,
GlobalSettingsModal,
HelpModal,
BatterySettingsModal,
@ -97,6 +104,20 @@ export default defineComponent({
loginModalProps() {
return this.collectProps(LoginModal, this.state);
},
aboutModalProps() {
return {
installed: window.evcc.version,
commit: window.evcc.commit,
...this.collectProps(AboutModal, this.state),
};
},
bottomTabBarProps() {
return {
installed: window.evcc.version,
commit: window.evcc.commit,
...this.collectProps(BottomTabBar, this.state),
};
},
},
watch: {
version(now, prev) {
@ -253,4 +274,7 @@ export default defineComponent({
min-height: 100vh;
min-height: 100dvh;
}
.app--bottomtabs {
padding-bottom: var(--tab-bar-height);
}
</style>

View file

@ -0,0 +1,80 @@
<template>
<div class="container px-4 safe-area-inset">
<TopHeader :title="$t('batterySettings.modalTitle')" />
<div class="row">
<main class="col-12">
<template v-if="batteryAvailable">
<h3 class="fw-normal my-4">
{{ $t("batterySettings.usageTab") }}
</h3>
<BatteryUsageSettings style="max-width: 950px" v-bind="batteryUsageProps" />
<template v-if="gridChargePossible">
<hr class="my-5" />
<h3 class="fw-normal my-4 mt-5">
{{ $t("batterySettings.gridChargeTab") }}
</h3>
<SmartCostLimit v-bind="smartCostLimitProps" />
</template>
</template>
<p v-else class="my-4 text-muted">
{{ $t("batterySettings.noBattery") }}
</p>
</main>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import Header from "../components/Top/Header.vue";
import BatteryUsageSettings from "../components/Battery/BatteryUsageSettings.vue";
import SmartCostLimit from "../components/Tariff/SmartCostLimit.vue";
import store from "../store";
import settings from "../settings";
import collector from "../mixins/collector";
import { SMART_COST_TYPE } from "../types/evcc";
export default defineComponent({
name: "Battery",
components: {
TopHeader: Header,
BatteryUsageSettings,
SmartCostLimit,
},
mixins: [collector],
head() {
return { title: this.$t("batterySettings.modalTitle") };
},
computed: {
state() {
return store.state;
},
batteryAvailable() {
return (this.state.battery?.devices?.length ?? 0) > 0;
},
batteryUsageProps() {
return this.collectProps(BatteryUsageSettings, this.state);
},
gridChargePossible() {
const devices = this.state.battery?.devices ?? [];
return (
devices.some(({ controllable }) => controllable) && this.state.smartCostAvailable
);
},
gridChargeTariff() {
const { forecast, smartCostType } = this.state;
return smartCostType === SMART_COST_TYPE.CO2 ? forecast?.co2 : forecast?.grid;
},
smartCostLimitProps() {
return {
currentLimit: this.state.batteryGridChargeLimit ?? null,
lastLimit: settings.lastBatterySmartCostLimit,
smartCostType: this.state.smartCostType,
currency: this.state.currency,
tariff: this.gridChargeTariff,
possible: this.gridChargePossible,
};
},
},
});
</script>

View file

@ -151,7 +151,7 @@
/>
</div>
<h2 class="my-4 mt-5">{{ $t("config.tariff.title") }}</h2>
<h2 id="tariffs" class="my-4 mt-5">{{ $t("config.tariff.title") }}</h2>
<div v-if="!!tariffsYamlSource" class="p-0 config-list">
<DeviceCard
:title="$t('config.tariff.title')"

View file

@ -1,106 +0,0 @@
<template>
<div class="container px-4 safe-area-inset">
<TopHeader title="Energy Overview" />
<div class="alert alert-light mb-3">This page is work in progress.</div>
<div class="row">
<main class="col-12">
<div v-if="forecastAvailable">
<h3 class="fw-normal my-4">
{{ $t("forecast.modalTitle") }}
</h3>
<div class="d-flex justify-content-start">
<IconSelectGroup>
<IconSelectItem
v-for="type in types"
:key="type"
:active="selectedType === type"
:label="$t(`forecast.type.${type}`)"
hideLabelOnMobile
@click="updateType(type)"
>
<component :is="typeIcons[type]"></component>
</IconSelectItem>
</IconSelectGroup>
</div>
<ForecastChart
class="my-3"
:grid="forecast.grid"
:solar="forecast.solar"
:co2="forecast.co2"
:currency="currency"
:selected="selectedType"
/>
</div>
<div v-else>
<p>nothing to see here</p>
</div>
</main>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import Header from "../components/Top/Header.vue";
import Chart from "../components/Forecast/Chart.vue";
import IconSelectItem from "../components/Helper/IconSelectItem.vue";
import IconSelectGroup from "../components/Helper/IconSelectGroup.vue";
import DynamicPriceIcon from "../components/MaterialIcon/DynamicPrice.vue";
import formatter from "../mixins/formatter.ts";
import store from "../store.ts";
import type { ForecastSlot, SolarDetails } from "../components/Forecast/types.ts";
import { ForecastType } from "../utils/forecast.ts";
interface Forecast {
grid?: ForecastSlot[];
solar?: SolarDetails;
co2?: ForecastSlot[];
}
export default defineComponent({
name: "Energy",
components: {
TopHeader: Header,
IconSelectGroup,
IconSelectItem,
ForecastChart: Chart,
},
mixins: [formatter],
data() {
return {
selectedType: ForecastType.Price,
types: Object.values(ForecastType),
};
},
head() {
return { title: this.$t("energy.title") };
},
computed: {
typeIcons() {
return {
[ForecastType.Solar]: "shopicon-regular-sun",
[ForecastType.Price]: DynamicPriceIcon,
[ForecastType.Co2]: "shopicon-regular-eco1",
};
},
forecastAvailable() {
const { grid, solar, co2 } = this.forecast;
return grid || solar || co2;
},
forecast(): Forecast {
return store.state?.forecast || {};
},
currency() {
return store.state?.currency;
},
},
methods: {
updateType(type: ForecastType) {
this.selectedType = type;
},
},
});
</script>
<style scoped></style>

View file

@ -0,0 +1,323 @@
<template>
<div
class="container px-4 safe-area-inset d-flex flex-column"
:class="{ 'empty-container': !forecastAvailable }"
>
<TopHeader v-if="forecastAvailable" :title="$t('forecast.modalTitle')" />
<div v-if="!forecastAvailable" class="flex-grow-1 d-flex">
<div class="empty-box d-flex flex-column p-5">
<h2 class="fs-4 mb-4">{{ $t("forecast.empty.title") }}</h2>
<ul class="list-unstyled mb-4">
<li class="d-flex align-items-start gap-2 mb-3">
<shopicon-regular-sun
size="s"
class="text-muted flex-shrink-0"
></shopicon-regular-sun>
<div>
<strong>{{ $t("forecast.type.solar") }}</strong>
<div class="text-muted">{{ $t("forecast.empty.solar") }}</div>
</div>
</li>
<li class="d-flex align-items-start gap-2 mb-3">
<DynamicPriceIcon class="text-muted flex-shrink-0" />
<div>
<strong>{{ $t("forecast.type.price") }}</strong>
<div class="text-muted">{{ $t("forecast.empty.price") }}</div>
</div>
</li>
<li class="d-flex align-items-start gap-2 mb-3">
<shopicon-regular-eco1
size="s"
class="text-muted flex-shrink-0"
></shopicon-regular-eco1>
<div>
<strong>{{ $t("forecast.type.co2") }}</strong>
<div class="text-muted">{{ $t("forecast.empty.co2") }}</div>
</div>
</li>
</ul>
<router-link to="/config#tariffs" class="btn btn-outline-primary">
{{ $t("forecast.empty.setup") }}
</router-link>
</div>
</div>
<div v-else class="row">
<main class="col-12">
<section v-if="forecast.solar" class="mb-5">
<div class="d-flex flex-wrap gap-3 align-items-baseline my-4">
<h3
class="fw-normal mb-0 d-flex gap-3 flex-wrap align-items-baseline overflow-hidden"
>
<span class="d-block text-nowrap text-truncate">{{
$t("forecast.type.solar")
}}</span>
<small v-if="solarSubtitle" class="d-block text-nowrap text-truncate">{{
solarSubtitle
}}</small>
</h3>
<div
v-if="showSolarAdjust"
class="form-check form-switch ms-auto mb-0 text-nowrap"
>
<input
id="solarForecastAdjust"
:checked="solarAdjusted"
class="form-check-input"
type="checkbox"
role="switch"
@change="changeAdjusted"
/>
<label class="form-check-label text-muted" for="solarForecastAdjust">
{{ solarAdjustText }}
</label>
</div>
</div>
<SolarChart
:solar="solar"
:raw-solar="forecast.solar"
:chart-width="chartWidth"
:end-date="chartEndDate"
:scroll-left="scrollLeft"
@scroll="onChartScroll"
/>
</section>
<section v-if="forecast.grid" class="mb-5">
<div class="d-flex flex-wrap gap-3 align-items-baseline my-4">
<h3
class="fw-normal mb-0 d-flex gap-3 flex-wrap align-items-baseline overflow-hidden"
>
<span class="d-block text-nowrap text-truncate">{{
$t("forecast.type.price")
}}</span>
<small v-if="priceSubtitle" class="d-block text-nowrap text-truncate">{{
priceSubtitle
}}</small>
</h3>
<div class="form-check form-switch ms-auto mb-0 text-nowrap">
<input
id="priceZoom"
:checked="priceZoom"
class="form-check-input"
type="checkbox"
role="switch"
@change="togglePriceZoom"
/>
<label class="form-check-label text-muted" for="priceZoom">
{{ $t("forecast.priceZoom") }}
</label>
</div>
</div>
<PriceChart
:grid="forecast.grid"
:currency="currency"
:zoom="priceZoom"
:chart-width="chartWidth"
:end-date="chartEndDate"
:scroll-left="scrollLeft"
@scroll="onChartScroll"
/>
</section>
<section v-if="forecast.co2" class="mb-5">
<h3
class="fw-normal my-4 d-flex gap-3 flex-wrap align-items-baseline overflow-hidden"
>
<span class="d-block text-nowrap text-truncate">{{
$t("forecast.type.co2")
}}</span>
<small v-if="co2Subtitle" class="d-block text-nowrap text-truncate">{{
co2Subtitle
}}</small>
</h3>
<Co2Chart
:co2="forecast.co2"
:chart-width="chartWidth"
:end-date="chartEndDate"
:scroll-left="scrollLeft"
@scroll="onChartScroll"
/>
</section>
</main>
</div>
</div>
</template>
<script lang="ts">
import "@h2d2/shopicons/es/regular/sun";
import "@h2d2/shopicons/es/regular/eco1";
import { defineComponent } from "vue";
import Header from "../components/Top/Header.vue";
import DynamicPriceIcon from "../components/MaterialIcon/DynamicPrice.vue";
import SolarChart from "../components/Forecast/SolarChart.vue";
import PriceChart from "../components/Forecast/PriceChart.vue";
import Co2Chart from "../components/Forecast/Co2Chart.vue";
import formatter, { POWER_UNIT } from "@/mixins/formatter";
import settings from "@/settings";
import store from "../store";
import { adjustedSolar, ForecastType } from "@/utils/forecast";
import type { ForecastSlot } from "../components/Forecast/types";
const MIN_HOURS = 38;
const MAX_HOURS = 96;
const SLOTS_PER_HOUR = 4;
export default defineComponent({
name: "Forecast",
components: {
TopHeader: Header,
DynamicPriceIcon,
SolarChart,
PriceChart,
Co2Chart,
},
mixins: [formatter],
data() {
return { ForecastType, scrollLeft: 0, isScrolling: false };
},
head() {
return { title: this.$t("forecast.modalTitle") };
},
computed: {
forecast() {
return store.state?.forecast || {};
},
forecastAvailable() {
return !!(this.forecast.grid || this.forecast.solar || this.forecast.co2);
},
startDate(): Date {
const now = new Date();
now.setMinutes(0, 0, 0);
return now;
},
maxEndDate(): Date {
const end = new Date(this.startDate);
end.setHours(end.getHours() + MAX_HOURS);
return end;
},
dataEndDate(): Date {
let latest = this.startDate.getTime();
for (const s of this.forecast.grid || []) {
const t = new Date(s.end).getTime();
if (t > latest) latest = t;
}
for (const s of this.forecast.co2 || []) {
const t = new Date(s.end).getTime();
if (t > latest) latest = t;
}
for (const e of this.forecast.solar?.timeseries || []) {
const t = new Date(e.ts).getTime();
if (t > latest) latest = t;
}
const end = new Date(Math.min(latest, this.maxEndDate.getTime()));
return end;
},
chartEndDate(): Date {
const minEnd = new Date(this.startDate);
minEnd.setHours(minEnd.getHours() + MIN_HOURS);
return this.dataEndDate > minEnd ? this.dataEndDate : minEnd;
},
chartWidth(): number {
const ms = this.chartEndDate.getTime() - this.startDate.getTime();
const slots = Math.ceil(ms / (15 * 60 * 1000));
return slots * 8 + 56;
},
currency() {
return store.state?.currency;
},
experimental() {
return store.state?.experimental;
},
solarAdjusted() {
return settings.solarAdjusted;
},
priceZoom() {
return settings.priceZoom;
},
showSolarAdjust() {
return !!this.forecast.solar && this.experimental;
},
solar() {
return this.showSolarAdjust && this.solarAdjusted
? adjustedSolar(this.forecast.solar)
: this.forecast.solar;
},
solarAdjustText() {
return this.$t("forecast.solarAdjustShort");
},
solarSubtitle(): string {
const s = this.solar;
if (!s) return "";
const today = s.today?.energy ? this.fmtWh(s.today.energy, POWER_UNIT.AUTO) : "";
const tomorrow = s.tomorrow?.energy
? this.fmtWh(s.tomorrow.energy, POWER_UNIT.AUTO)
: "";
if (!today && !tomorrow) return "";
const parts = [];
if (today) parts.push(`${today} ${this.$t("forecast.solar.remaining")}`);
if (tomorrow) parts.push(`${tomorrow} ${this.$t("forecast.solar.tomorrow")}`);
return parts.join(" ・ ");
},
priceSubtitle(): string {
const slots = this.upcomingSlots(this.forecast.grid);
if (slots.length === 0) return "";
const values = slots.map((s) => s.value);
const min = Math.min(...values);
const max = Math.max(...values);
const avg = values.reduce((a, b) => a + b, 0) / values.length;
const fmtMin = this.fmtPricePerKWh(min, this.currency, false, false);
const fmtMax = this.fmtPricePerKWh(max, this.currency, false, true);
const fmtAvg = this.fmtPricePerKWh(avg, this.currency, false, true);
return `⌀ ${fmtAvg} ・ ${fmtMin} – ${fmtMax}`;
},
co2Subtitle(): string {
const slots = this.upcomingSlots(this.forecast.co2);
if (slots.length === 0) return "";
const values = slots.map((s) => s.value);
const min = Math.min(...values);
const max = Math.max(...values);
const avg = values.reduce((a, b) => a + b, 0) / values.length;
const fmtMin = this.fmtNumber(min, 0);
const fmtMax = this.fmtCo2Medium(max);
const fmtAvg = this.fmtCo2Medium(avg);
return `⌀ ${fmtAvg} ・ ${fmtMin} – ${fmtMax}`;
},
},
methods: {
changeAdjusted() {
settings.solarAdjusted = !settings.solarAdjusted;
},
togglePriceZoom() {
settings.priceZoom = !settings.priceZoom;
},
onChartScroll(val: number) {
if (this.isScrolling) return;
this.isScrolling = true;
this.scrollLeft = val;
this.$nextTick(() => {
this.isScrolling = false;
});
},
upcomingSlots(slots?: ForecastSlot[]): ForecastSlot[] {
if (!Array.isArray(slots)) return [];
const now = new Date();
return slots
.filter((slot) => new Date(slot.end) > now)
.slice(0, MAX_HOURS * SLOTS_PER_HOUR);
},
},
});
</script>
<style scoped>
.empty-container {
min-height: calc(100dvh - var(--tab-bar-height) - var(--safe-area-inset-bottom));
padding-bottom: calc(var(--tab-bar-height) + var(--safe-area-inset-bottom));
}
.empty-box {
background-color: var(--evcc-box);
margin: auto;
border-radius: 2rem;
max-width: 480px;
}
</style>

View file

@ -40,6 +40,7 @@
"legendTopName": "Batterieunterstütztes Fahrzeugladen",
"legendTopSubline": "wenn Hausbatterie über {soc} ist.",
"modalTitle": "Hausbatterie",
"noBattery": "Keine Batterien konfiguriert.",
"usageTab": "Batterienutzung"
},
"config": {
@ -846,6 +847,15 @@
"configurePriceCo2": "Lerne Preis und CO₂-Emissionen zu konfigurieren.",
"footerLong": "{percent} Sonnenenergie",
"footerShort": "{percent} Sonne",
"indicator": {
"co2": "CO₂-Emissionen",
"co2saved": "CO₂ eingespart",
"none": "keine",
"price": "Energiepreis",
"savings": "Ersparnis",
"solar": "Solarenergie"
},
"indicatorLabel": "Header-Info",
"modalTitle": "Auswertung Ladeenergie",
"moneySaved": "{value} gespart",
"percentGrid": "{grid} kWh Netz",
@ -857,10 +867,11 @@
"thisYear": "dieses Jahr",
"total": "gesamt"
},
"periodLabel": "Zeitraum:",
"periodLabel": "Zeitraum",
"priceTitle": "Energiepreis",
"referenceGrid": "Netz",
"referenceLabel": "Referenzdaten:",
"referenceLabel": "Referenzdaten",
"sessionInfo": "Basierend auf abgeschlossenen Ladevorgängen.",
"tabTitle": "Meine Daten"
},
"sponsor": {
@ -886,15 +897,25 @@
},
"version": {
"availableLong": "neue Version verfügbar",
"community": "evcc Community",
"labelRelease": "Release",
"labelVersion": "Version",
"labelWebsite": "Website",
"latestVersion": "aktuelle Version",
"madeByCommunity": "Entwickelt von der {0}.",
"modalCancel": "Abbrechen",
"modalDownload": "Download",
"modalInstalledVersion": "Installierte Version",
"modalNextRelease": "Was ist neu im nächsten Release",
"modalNoReleaseNotes": "Keine Versionshinweise verfügbar. Mehr Informationen zur neuen Version:",
"modalTitle": "Neue Version verfügbar",
"modalUpdate": "Installieren",
"modalUpdateNow": "Jetzt installieren",
"modalUpdateStarted": "Starte die neue Version von evcc…",
"modalUpdateStatusStart": "Installation gestartet:"
"modalUpdateStatusStart": "Installation gestartet:",
"modalViewOnGitHub": "Auf GitHub ansehen",
"openSource": "Open Source",
"poweredByOpenSource": "Powered by {0}."
}
},
"forecast": {
@ -904,6 +925,13 @@
"lowestHour": "Sauberste Stunde",
"range": "Bereich"
},
"empty": {
"co2": "Erfahre, wann Netzstrom in deiner Region sauber ist. Ladepläne werden auf niedrige Emissionen optimiert und CO₂-Einsparungen berechnet.",
"price": "Dynamischen Stromtarif konfigurieren, um Ladepläne automatisch zu optimieren und Einsparungen zu berechnen.",
"setup": "Tarife und Vorhersagen einrichten",
"solar": "Erwartete Solarproduktion für heute und die nächsten Tage. Wird in Zukunft auch für die automatische Ladeoptimierung verwendet.",
"title": "Tarife & Vorhersagen"
},
"modalTitle": "Vorhersage",
"price": {
"average": "Durchschnitt",
@ -1476,5 +1504,13 @@
"lineErrorLink": "Zeile {0}",
"restartButton": "Neu starten",
"title": "Fehler beim Starten"
},
"tabBar": {
"battery": "Batterie",
"charge": "Laden",
"comingSoon": "Diese Seite ist in Arbeit.",
"forecast": "Vorhersage",
"more": "Mehr",
"sessions": "Ladungen"
}
}

View file

@ -40,6 +40,7 @@
"legendTopName": "Battery-supported vehicle charging",
"legendTopSubline": "when home battery is above {soc}.",
"modalTitle": "Home Battery",
"noBattery": "No batteries configured.",
"usageTab": "Battery usage"
},
"config": {
@ -846,6 +847,15 @@
"configurePriceCo2": "Learn how to configure price and CO₂ data.",
"footerLong": "{percent} solar energy",
"footerShort": "{percent} solar",
"indicator": {
"co2": "CO₂ emissions",
"co2saved": "CO₂ saved",
"none": "none",
"price": "energy price",
"savings": "savings",
"solar": "solar energy"
},
"indicatorLabel": "Header info",
"modalTitle": "Charge Energy Overview",
"moneySaved": "{value} saved",
"percentGrid": "{grid} kWh grid",
@ -857,10 +867,11 @@
"thisYear": "this year",
"total": "all time"
},
"periodLabel": "Period:",
"periodLabel": "Period",
"priceTitle": "Energy Price",
"referenceGrid": "grid",
"referenceLabel": "Reference data:",
"referenceLabel": "Reference data",
"sessionInfo": "Based on completed charging sessions.",
"tabTitle": "My data"
},
"sponsor": {
@ -886,15 +897,26 @@
},
"version": {
"availableLong": "new version available",
"community": "evcc community",
"labelRelease": "Release",
"labelVersion": "Version",
"labelWebsite": "Website",
"latestVersion": "latest version",
"madeByCommunity": "Made by the {0}.",
"modalCancel": "Cancel",
"modalDownload": "Download",
"modalInstalledVersion": "Installed version",
"modalLatest": "You're running the latest version.",
"modalNextRelease": "What's in the next release",
"modalNoReleaseNotes": "No release notes available. More info about the new version:",
"modalTitle": "New version available",
"modalUpdate": "Install",
"modalUpdateNow": "Install now",
"modalUpdateStarted": "Starting the new version of evcc…",
"modalUpdateStatusStart": "Installation started:"
"modalUpdateStatusStart": "Installation started:",
"modalViewOnGitHub": "View on GitHub",
"openSource": "open source",
"poweredByOpenSource": "Powered by {0}."
}
},
"forecast": {
@ -904,6 +926,13 @@
"lowestHour": "Cleanest hour",
"range": "Range"
},
"empty": {
"co2": "See when grid energy in your region is clean. Charging plans will optimize for low emissions and calculate CO₂ savings.",
"price": "Configure your dynamic electricity tariff to automatically optimize charging plans and calculate savings.",
"setup": "Set up tariffs and forecasts",
"solar": "See expected solar production for today and the coming days. Will also be used for automatic charging optimization in the future.",
"title": "Tariffs & Forecasts"
},
"modalTitle": "Forecast",
"price": {
"average": "Average",
@ -911,6 +940,7 @@
"lowestHour": "Cheapest hour",
"range": "Range"
},
"priceZoom": "zoom in",
"solar": {
"dayAfterTomorrow": "Day after tomorrow",
"partly": "partly",
@ -919,10 +949,11 @@
"tomorrow": "Tomorrow"
},
"solarAdjust": "Adjust solar forecast based on real production data{percent}.",
"solarAdjustShort": "real data adjust",
"type": {
"co2": "CO₂",
"price": "Price",
"solar": "Solar"
"co2": "CO₂ Emissions",
"price": "Grid Price",
"solar": "Solar Production"
}
},
"general": {
@ -1476,5 +1507,13 @@
"lineErrorLink": "line {0}",
"restartButton": "Restart",
"title": "Startup Error"
},
"tabBar": {
"battery": "Battery",
"charge": "Charge",
"comingSoon": "This page is under construction.",
"forecast": "Forecast",
"more": "More",
"sessions": "Sessions"
}
}

32
package-lock.json generated
View file

@ -20,6 +20,7 @@
"chartjs-plugin-datalabels": "^2.2.0",
"countup.js": "^2.10.0",
"dayjs": "^1.11.19",
"echarts": "^6.0.0",
"monaco-editor": "0.52.2",
"smoothscroll-polyfill": "^0.4.4",
"snarkdown": "^2.0.0",
@ -5833,6 +5834,22 @@
"dev": true,
"license": "MIT"
},
"node_modules/echarts": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz",
"integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "6.0.0"
}
},
"node_modules/echarts/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/editorconfig": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz",
@ -12830,6 +12847,21 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zrender": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",
"integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
},
"node_modules/zrender/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
}
}
}

View file

@ -15,7 +15,7 @@
"playwright:ci": "cross-env CI=true playwright test",
"simulator": "vite tests/simulator",
"storybook": "storybook dev -p 6006",
"license": "license-compliance --production --report detailed --allow \"MIT;Apache-2.0;ISC;BSD-2-Clause;BSD-3-Clause\""
"license": "license-compliance --production --report detailed --allow \"MIT;Apache-2.0;ISC;BSD-2-Clause;BSD-3-Clause;0BSD\""
},
"type": "module",
"main": "index.js",
@ -33,6 +33,7 @@
"chartjs-plugin-datalabels": "^2.2.0",
"countup.js": "^2.10.0",
"dayjs": "^1.11.19",
"echarts": "^6.0.0",
"monaco-editor": "0.52.2",
"smoothscroll-polyfill": "^0.4.4",
"snarkdown": "^2.0.0",

View file

@ -97,7 +97,7 @@ func (r *Repo) ReleaseNotes(from string) (rendered string, err error) {
return
}
history.WriteString(fmt.Sprintf("<h1>%s</h1>\n", tag))
history.WriteString(fmt.Sprintf("<h1>v%s</h1>\n", tag))
history.WriteString(md)
}
}

104
tests/bottom-nav.spec.ts Normal file
View file

@ -0,0 +1,104 @@
import { test, expect } from "@playwright/test";
import { start, stop, restart, baseUrl } from "./evcc";
import { enableExperimental, expectModalHidden, expectModalVisible } from "./utils";
test.use({ baseURL: baseUrl() });
test.describe("bottom navigation", async () => {
test.afterAll(async () => {
await stop();
});
test("tabs, navigation, and forecast empty state", async ({ page }) => {
// start with blank install
await start();
await page.goto("/");
const bottomNav = page.getByTestId("bottom-tab-bar");
// bottom nav not visible without experimental
await expect(bottomNav).not.toBeVisible();
// enable experimental feature
await enableExperimental(page);
await page.goto("/");
await expect(bottomNav).toBeVisible();
const tabCharge = bottomNav.getByRole("link", { name: "Charge" });
const tabBattery = bottomNav.getByRole("link", { name: "Battery" });
const tabForecast = bottomNav.getByRole("link", { name: "Forecast" });
const tabSessions = bottomNav.getByRole("link", { name: "Sessions" });
const tabMore = bottomNav.getByTestId("tab-more");
// verify bottom nav tabs (no battery)
await expect(tabCharge).toBeVisible();
await expect(tabForecast).toBeVisible();
await expect(tabSessions).toBeVisible();
await expect(tabMore).toBeVisible();
await expect(tabBattery).toHaveCount(0);
// navigate to config via More menu
await tabMore.click();
await tabMore.getByRole("link", { name: "Configuration" }).click();
await expect(page.getByRole("heading", { name: "Configuration" })).toBeVisible();
// create battery meter
await page.getByRole("button", { name: "Add solar or battery" }).click();
const meterModal = page.getByTestId("meter-modal");
await meterModal.getByRole("button", { name: "Add battery meter" }).click();
await meterModal.getByLabel("Title").fill("Demo Battery");
await meterModal.getByLabel("Manufacturer").selectOption("Demo battery");
await meterModal.getByLabel("Power").fill("1000");
await meterModal.getByLabel("Charge").fill("50");
await meterModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(meterModal);
await expect(page.getByTestId("battery")).toBeVisible();
// restart and verify battery tab appears
const restartButton = page
.getByTestId("bottom-banner")
.getByRole("button", { name: "Restart" });
await expect(restartButton).toBeVisible();
await restart();
await expect(restartButton).not.toBeVisible();
await page.goto("/");
await expect(tabBattery).toBeVisible();
// click all tabs, verify headlines
await tabCharge.click();
await expect(page.getByRole("heading", { name: "evcc" })).toBeVisible();
await tabBattery.click();
await expect(page.getByRole("heading", { name: "Home Battery" })).toBeVisible();
await tabForecast.click();
await expect(page.getByRole("heading", { name: "Tariffs & Forecasts" })).toBeVisible();
await tabSessions.click();
await expect(page.getByRole("heading", { name: "Sessions" })).toBeVisible();
// forecast empty state → create solar forecast
await tabForecast.click();
await page.getByRole("link", { name: "Set up tariffs and forecasts" }).click();
await expect(page.getByRole("heading", { name: "Configuration" })).toBeVisible();
const tariffModal = page.getByTestId("tariff-modal");
await page.getByRole("button", { name: "Add forecast" }).click();
await expectModalVisible(tariffModal);
await tariffModal.getByRole("button", { name: "Add solar forecast" }).click();
await tariffModal.getByLabel("Title").fill("Demo Forecast");
await tariffModal.getByLabel("Provider").selectOption("Demo PV Forecast");
await tariffModal.getByRole("button", { name: "Validate & save" }).click();
await expectModalHidden(tariffModal);
await expect(page.getByTestId("tariff-solar")).toBeVisible();
// restart, navigate to forecast, verify headline
await expect(restartButton).toBeVisible();
await restart();
await expect(restartButton).not.toBeVisible();
await tabForecast.click();
await expect(page.getByRole("heading", { name: "Forecast" })).toBeVisible();
});
});

View file

@ -1,5 +1,6 @@
import { test, expect } from "@playwright/test";
import { start, stop, baseUrl } from "./evcc";
import { enableExperimental } from "./utils";
test.use({ baseURL: baseUrl() });
@ -15,11 +16,11 @@ test.describe("footer", async () => {
await page.goto("/");
// last 30 days
await expect(page.getByTestId("savings-button")).toContainText("60% solar energy");
await expect(page.getByTestId("savings-button")).toContainText("60%");
// last 365 days
await page.getByTestId("savings-button").click();
const savingsModal = await page.getByTestId("savings-modal");
const savingsModal = page.getByTestId("savings-modal");
await expect(savingsModal).toBeVisible();
await page
.getByTestId("savings-period-select")
@ -27,7 +28,7 @@ test.describe("footer", async () => {
.selectOption("last 365 days");
await page.getByRole("button", { name: "Close" }).click();
await expect(savingsModal).not.toBeVisible();
await expect(page.getByTestId("savings-button")).toContainText("30% solar energy");
await expect(page.getByTestId("savings-button")).toContainText("30%");
// all time
await page.getByTestId("savings-button").click();
@ -35,14 +36,14 @@ test.describe("footer", async () => {
await page.getByTestId("savings-period-select").getByRole("combobox").selectOption("all time");
await page.getByRole("button", { name: "Close" }).click();
await expect(savingsModal).not.toBeVisible();
await expect(page.getByTestId("savings-button")).toContainText("50% solar energy");
await expect(page.getByTestId("savings-button")).toContainText("50%");
});
});
test.describe("statistics values", async () => {
test("last 30 days", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "60% solar energy" }).click();
await page.getByTestId("savings-button").click();
await expect(page.getByTestId("savings-modal")).toBeVisible();
await page
@ -63,7 +64,7 @@ test.describe("statistics values", async () => {
test("last 365 days", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "60% solar energy" }).click();
await page.getByTestId("savings-button").click();
await expect(page.getByTestId("savings-modal")).toBeVisible();
await page
.getByTestId("savings-period-select")
@ -83,11 +84,11 @@ test.describe("statistics values", async () => {
test("reference data", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "60% solar energy" }).click();
await page.getByTestId("savings-button").click();
await expect(page.getByTestId("savings-modal")).toBeVisible();
await expect(page.getByTestId("savings-reference")).toContainText("Reference data:");
await expect(page.getByTestId("savings-reference")).toContainText("30.0 ct./kWh (grid)");
await expect(page.getByTestId("savings-reference")).toContainText("Reference data");
await expect(page.getByTestId("savings-reference")).toContainText("30.0 ct./kWh");
await expect(page.getByTestId("savings-reference")).toContainText("⌀ 344 g/kWh");
await expect(page.getByTestId("savings-reference")).toContainText("Germany");
@ -104,3 +105,36 @@ test.describe("statistics values", async () => {
await expect(page.getByTestId("savings-tile-co2")).toContainText("1 kg saved");
});
});
test.describe("header savings", async () => {
test("savings info in header and indicator persistence", async ({ page }) => {
await enableExperimental(page);
await page.goto("/");
// savings button visible in header
await expect(page.getByTestId("savings-button")).toBeVisible();
// default indicator is solar
await expect(page.getByTestId("savings-button")).toContainText("60%");
// open modal, verify values still work
await page.getByTestId("savings-button").click();
const savingsModal = page.getByTestId("savings-modal");
await expect(savingsModal).toBeVisible();
await expect(page.getByTestId("savings-tile-solar")).toContainText("60.0%");
await expect(page.getByTestId("savings-tile-price")).toContainText("18.0ct./kWh");
await expect(page.getByTestId("savings-tile-co2")).toContainText("8g/kWh");
// switch indicator to "price"
await page.getByTestId("savings-indicator-select").getByRole("combobox").selectOption("price");
await page.getByRole("button", { name: "Close" }).click();
await expect(savingsModal).not.toBeVisible();
// verify button now shows price
await expect(page.getByTestId("savings-button")).toContainText("18.0");
// reload and verify persistence
await page.reload();
await expect(page.getByTestId("savings-button")).toContainText("18.0");
});
});

View file

@ -3,10 +3,13 @@ import { expect, type Page, type Locator } from "@playwright/test";
export async function enableExperimental(page: Page): Promise<void> {
await page.goto("/#/config");
await page.getByTestId("generalconfig-experimental").click();
await page
.getByTestId("generalconfig-experimental")
.getByRole("button", { name: "edit" })
.click();
const modal = page.getByTestId("experimental-modal");
await expectModalVisible(modal);
await modal.getByLabel("Show experimental UI features.").click();
await modal.getByLabel("Enable experimental features.").click();
await modal.getByRole("button", { name: "Close" }).click();
await expectModalHidden(modal);
await expect(page.locator(".modal-backdrop")).not.toBeVisible();