UI: New Navigation, Battery & Forecast Page (#27753)
This commit is contained in:
parent
ef365f7907
commit
7b5103df44
49 changed files with 3327 additions and 889 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
106
assets/js/components/AboutModal.stories.ts
Normal file
106
assets/js/components/AboutModal.stories.ts
Normal 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",
|
||||
};
|
||||
249
assets/js/components/AboutModal.vue
Normal file
249
assets/js/components/AboutModal.vue
Normal 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>
|
||||
|
|
@ -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"
|
||||
<BatteryUsageSettings
|
||||
v-show="usageTabActive"
|
||||
:buffer-soc="bufferSoc"
|
||||
:priority-soc="prioritySoc"
|
||||
:buffer-start-soc="bufferStartSoc"
|
||||
:battery-discharge-control="batteryDischargeControl"
|
||||
:battery="battery"
|
||||
/>
|
||||
<div class="form-check-label">
|
||||
<label for="batteryDischargeControl">
|
||||
{{ $t("batterySettings.discharge") }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
|
|
|
|||
511
assets/js/components/Battery/BatteryUsageSettings.vue
Normal file
511
assets/js/components/Battery/BatteryUsageSettings.vue
Normal 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>
|
||||
116
assets/js/components/BottomTabs/Bar.vue
Normal file
116
assets/js/components/BottomTabs/Bar.vue
Normal 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>
|
||||
130
assets/js/components/BottomTabs/Item.vue
Normal file
130
assets/js/components/BottomTabs/Item.vue
Normal 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>
|
||||
86
assets/js/components/BottomTabs/MoreItem.vue
Normal file
86
assets/js/components/BottomTabs/MoreItem.vue
Normal 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>
|
||||
226
assets/js/components/BottomTabs/MoreMenu.vue
Normal file
226
assets/js/components/BottomTabs/MoreMenu.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
|
|
|
|||
128
assets/js/components/Forecast/Co2Chart.vue
Normal file
128
assets/js/components/Forecast/Co2Chart.vue
Normal 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>
|
||||
174
assets/js/components/Forecast/PriceChart.vue
Normal file
174
assets/js/components/Forecast/PriceChart.vue
Normal 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>
|
||||
139
assets/js/components/Forecast/SolarChart.vue
Normal file
139
assets/js/components/Forecast/SolarChart.vue
Normal 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>
|
||||
67
assets/js/components/Forecast/chartMixin.ts
Normal file
67
assets/js/components/Forecast/chartMixin.ts
Normal 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;
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
202
assets/js/components/Forecast/echarts.ts
Normal file
202
assets/js/components/Forecast/echarts.ts
Normal 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 };
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
18
assets/js/components/MaterialIcon/ForecastGraph.vue
Normal file
18
assets/js/components/MaterialIcon/ForecastGraph.vue
Normal 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>
|
||||
18
assets/js/components/MaterialIcon/More.vue
Normal file
18
assets/js/components/MaterialIcon/More.vue
Normal 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>
|
||||
18
assets/js/components/MaterialIcon/Sessions.vue
Normal file
18
assets/js/components/MaterialIcon/Sessions.vue
Normal 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>
|
||||
|
|
@ -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,11 +140,13 @@
|
|||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="my-3 lh-2">
|
||||
<div class="d-flex">
|
||||
<label for="savingsPeriod" class="me-1">
|
||||
<table class="mt-3 mb-2 lh-2">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="pe-3 align-top">
|
||||
{{ $t("footer.savings.periodLabel") }}
|
||||
</label>
|
||||
</td>
|
||||
<td>
|
||||
<CustomSelect
|
||||
id="savingsPeriod"
|
||||
:selected="period"
|
||||
|
|
@ -144,31 +154,67 @@
|
|||
data-testid="savings-period-select"
|
||||
@change="selectPeriod($event.target.value)"
|
||||
>
|
||||
<span class="text-decoration-underline evcc-gray">
|
||||
<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"
|
||||
</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"
|
||||
>
|
||||
<div class="me-1">
|
||||
{{ $t("footer.savings.referenceLabel") }}
|
||||
</div>
|
||||
<div class="evcc-gray me-1">
|
||||
{{
|
||||
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)
|
||||
? fmtPricePerKWh(
|
||||
referenceGrid,
|
||||
currency
|
||||
)
|
||||
: "___"
|
||||
}}
|
||||
({{ $t("footer.savings.referenceGrid") }}),
|
||||
(<a
|
||||
href="#"
|
||||
class="evcc-gray text-decoration-underline"
|
||||
@click.prevent="navigateToTariffs"
|
||||
>{{ $t("config.main.title") }}</a
|
||||
>)
|
||||
</div>
|
||||
<div class="evcc-gray d-flex">
|
||||
<div class="me-1">⌀ {{ fmtCo2Medium(region.co2) }}</div>
|
||||
<div class="d-flex">
|
||||
<span class="me-1"
|
||||
>⌀ {{ fmtCo2Medium(region.co2) }}</span
|
||||
>
|
||||
<CustomSelect
|
||||
class="me-1 evcc-gray"
|
||||
class="evcc-gray"
|
||||
:selected="region.name"
|
||||
:options="regionOptions"
|
||||
data-testid="savings-region-select"
|
||||
|
|
@ -180,12 +226,21 @@
|
|||
>)
|
||||
</CustomSelect>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="!priceConfigured || !co2Configured">
|
||||
<a :href="tariffLink" class="evcc-gray" target="_blank">
|
||||
<a
|
||||
href="#"
|
||||
class="evcc-gray"
|
||||
@click.prevent="navigateToTariffs"
|
||||
>
|
||||
{{ $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>
|
||||
|
|
|
|||
1
assets/js/components/Savings/types.d.ts
vendored
1
assets/js/components/Savings/types.d.ts
vendored
|
|
@ -10,4 +10,3 @@ export interface LiveCommunityData {
|
|||
chargeEnergy: number;
|
||||
greenEnergy: number;
|
||||
}
|
||||
export type Period = "30d" | "365d" | "thisYear" | "total";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
data-testid="header"
|
||||
>
|
||||
<h1 class="mb-1 pt-1 d-flex text-nowrap text-truncate">
|
||||
<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>
|
||||
|
|
@ -18,6 +19,7 @@
|
|||
</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
|
||||
|
|
|
|||
|
|
@ -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 || [];
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
3
assets/js/utils/haptic.ts
Normal file
3
assets/js/utils/haptic.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function hapticFeedback(): void {
|
||||
navigator.vibrate?.(5);
|
||||
}
|
||||
23
assets/js/utils/version.ts
Normal file
23
assets/js/utils/version.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
80
assets/js/views/Battery.vue
Normal file
80
assets/js/views/Battery.vue
Normal 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>
|
||||
|
|
@ -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')"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
323
assets/js/views/Forecast.vue
Normal file
323
assets/js/views/Forecast.vue
Normal 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>
|
||||
42
i18n/de.json
42
i18n/de.json
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
51
i18n/en.json
51
i18n/en.json
|
|
@ -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
32
package-lock.json
generated
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
104
tests/bottom-nav.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue