Introduce new UI design; upgrade to Vue 3 tech stack

This commit is contained in:
Michael Geers 2022-05-01 21:24:45 +02:00 • committed by GitHub
parent 6a6aac16ae
commit 63b7ffd7e7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
117 changed files with 13824 additions and 31353 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

View file

@ -6,9 +6,10 @@ module.exports = {
}, },
extends: [ extends: [
"eslint:recommended", "eslint:recommended",
"plugin:vue/recommended", "plugin:vue/vue3-recommended",
"plugin:prettier/recommended", "plugin:prettier/recommended",
"prettier", "prettier",
"plugin:storybook/recommended",
], ],
rules: { rules: {
"vue/require-default-prop": "off", "vue/require-default-prop": "off",

View file

@ -72,6 +72,11 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Set up Node
uses: actions/setup-node@v2
with:
node-version: "16"
- name: Cache NPM modules - name: Cache NPM modules
uses: actions/cache@v2 uses: actions/cache@v2
with: with:

1
.gitignore vendored
View file

@ -24,3 +24,4 @@ ca-cert.srl
server-key.pem server-key.pem
ca-key.pem ca-key.pem
cmd/wip cmd/wip
fly.toml

View file

@ -1,4 +1,23 @@
const { loadConfigFromFile } = require("vite");
const path = require("path");
module.exports = { module.exports = {
stories: ["../assets/js/**/*.stories.mdx", "../assets/js/**/*.stories.@(js|jsx|ts|tsx)"], stories: ["../assets/js/**/*.stories.mdx", "../assets/js/**/*.stories.@(js|jsx|ts|tsx)"],
addons: ["@storybook/addon-links", "@storybook/addon-essentials", "@storybook/addon-postcss"], addons: ["@storybook/addon-links", "@storybook/addon-essentials", "@storybook/addon-postcss"],
framework: "@storybook/vue3",
core: {
builder: "storybook-builder-vite",
},
staticDirs: ["../assets"],
async viteFinal(config, b) {
const { config: userConfig } = await loadConfigFromFile(
path.resolve(__dirname, "../vite.config.ts")
);
const vuePlugin = userConfig.plugins.find((p) => p.name === "vite:vue");
const vuePluginIndex = config.plugins.findIndex((p) => p.name === "vite:vue");
config.plugins[vuePluginIndex] = vuePlugin;
return config;
},
}; };

View file

@ -1,4 +0,0 @@
<link href="/css/bootstrap.min.css" rel="stylesheet" />
<script src="/js/bootstrap.min.js" async></script>
<link href="/css/app.css" rel="stylesheet" />

View file

@ -1,4 +1,26 @@
import "bootstrap/dist/css/bootstrap.min.css";
import "bootstrap";
import smoothscroll from "smoothscroll-polyfill";
import { app } from "@storybook/vue3";
import i18n from "../assets/js/i18n";
import "../assets/css/app.css";
smoothscroll.polyfill();
app.use(i18n);
export const parameters = { export const parameters = {
actions: { argTypesRegex: "^on[A-Z].*" }, actions: { argTypesRegex: "^on[A-Z].*" },
} backgrounds: {
default: "dark",
values: [
{
name: "light",
value: "#ffffff",
},
{
name: "dark",
value: "#28293e",
},
],
},
};

View file

@ -12,7 +12,7 @@ RUN make install-ui
# build ui # build ui
COPY assets assets COPY assets assets
COPY vue.config.js vue.config.js COPY vite.config.js vite.config.js
COPY .eslintrc.js .eslintrc.js COPY .eslintrc.js .eslintrc.js
RUN make clean ui RUN make clean ui

View file

@ -41,14 +41,51 @@ Evcc is an extensible EV Charge Controller with PV integration implemented in [G
You'll find everything you need in our [documentation](https://docs.evcc.io/) (German). You'll find everything you need in our [documentation](https://docs.evcc.io/) (German).
## Build ## Contribute
To build EVCC from source, [Go][2] 1.18 and [Node][3] 16 are required: To build evcc from source, [Go][2] 1.18 and [Node][3] 16 are required.
Build and run go backend. The UI becomes available at http://127.0.0.1:7070/
```sh ```sh
make make
./evcc
``` ```
For frontend development start the Vue toolchain in dev-mode. Open http://127.0.0.1:7071/ to get to the livelreloading development server. It pulls its data from port 7070 (see above).
```sh
npm install
npm run start
```
### Code formatting
We use linters (golangci-lint, Prettier) to keep a coherent source code formatting. It's recommended to use the format-on-save feature of your editor. For VSCode use the [Go](https://marketplace.visualstudio.com/items?itemName=golang.Go), [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) and [Veture](https://marketplace.visualstudio.com/items?itemName=octref.vetur) extension. You can manually reformat your code by running:
```sh
make lint
make lint-ui
```
### Changing UI code
To ensure reproducability the build frontend artifacts are part of the source code repository. If you've made changes to frontend code, please make sure to rebuild the production assets before you commit.
```sh
make ui
```
### Changing templates
Evcc supports a massive amount of different devices. To keep our documentation and website in sync with the latest software the core project (this repo) generates meta-data that's pushed to the `docs` and `evcc.io` repository. Make sure to update this meta-data every time you make changes to a templates.
```sh
make docs
```
If you miss one of the above steps Gitub Actions will likely trigger a **Porcelain** error.
## Sponsorship ## Sponsorship
Evcc believes in open source software. We're committed to provide best in class EV charging experience. Evcc believes in open source software. We're committed to provide best in class EV charging experience.

BIN
assets/.DS_Store vendored Normal file

Binary file not shown.

View file

@ -1,14 +1,67 @@
@font-face {
font-family: "Montserrat";
src: url("../font/Montserrat-Medium.woff2") format("woff2");
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: "Montserrat";
src: url("../font/Montserrat-Bold.woff2") format("woff2");
font-weight: bold;
font-style: normal;
}
:root { :root {
--evcc-green: #66d85a; --evcc-green: #baffcb;
--evcc-dark-green: #3aba2c; --evcc-dark-green: #0fde41;
--evcc-yellow: #ffe000; --evcc-yellow: #faf000;
--bs-gray-dark: #28293e;
--bs-gray-medium: #93949e;
--evcc-grid: var(--bs-gray-dark); --evcc-grid: var(--bs-gray-dark);
--evcc-self: var(--evcc-dark-green); --evcc-self: var(--evcc-dark-green);
--evcc-export: var(--evcc-yellow); --evcc-export: var(--evcc-yellow);
--evcc-transition-slow: 1000ms;
--evcc-transition-medium: 500ms;
--evcc-transition-fast: 250ms;
--evcc-transition-very-fast: 100ms;
--bs-primary: var(--evcc-dark-green); --bs-primary: var(--evcc-dark-green);
--bs-primary-rgb: 58, 186, 44; --bs-primary-rgb: 58, 186, 44;
--bs-body-font-size: 14px;
--bs-font-sans-serif: Montserrat, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue",
Arial, "Noto Sans", "Liberation Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol", "Noto Color Emoji";
}
html {
/* prevents content jump when scrollbar is activate */
width: 100vw;
overflow-x: hidden;
}
body {
background-color: var(--evcc-gray-dark);
}
h1,
h2,
h3,
h4 {
font-weight: bold;
}
h1,
h2 {
font-size: 1.25rem;
text-transform: uppercase;
}
h3,
h4 {
font-size: 1.25rem;
} }
.bg-primary { .bg-primary {
@ -27,22 +80,33 @@ a {
opacity: 0.25; opacity: 0.25;
} }
.rounded {
border-radius: 10px !important;
}
.btn-primary, .btn-primary,
.btn-primary:focus { .btn-primary:focus {
background-color: var(--bs-gray-dark); background-color: var(--bs-gray-dark);
border-color: var(--bs-gray-dark); border-color: var(--bs-gray-dark);
} }
.btn-primary:hover, .btn-primary:hover,
.btn-primary:active { .btn-primary:active {
background-color: var(--evcc-dark-green); background-color: var(--evcc-dark-green);
border-color: var(--evcc-dark-green); border-color: var(--evcc-dark-green);
} }
.btn-outline-primary { .btn-outline-primary,
.btn-outline-primary:focus {
color: var(--bs-gray-dark); color: var(--bs-gray-dark);
background-color: transparent;
border-width: 2px;
border-color: var(--bs-gray-dark); border-color: var(--bs-gray-dark);
} }
.btn-outline-primary:hover { .btn-outline-primary:hover,
background-color: var(--evcc-dark-green); .btn-outline-primary:active {
color: var(--evcc-dark-green);
background-color: transparent;
border-width: 2px;
border-color: var(--evcc-dark-green); border-color: var(--evcc-dark-green);
} }
.text-evcc { .text-evcc {
@ -52,6 +116,18 @@ a {
.text-grid { .text-grid {
color: var(--evcc-grid); color: var(--evcc-grid);
} }
.text-dark {
color: var(--bs-gray-dark);
}
.text-gray {
color: var(--bs-gray-medium);
}
.text-gray-medium {
color: var(--bs-gray-medium);
}
.bg-dark {
background-color: var(--bs-gray-dark) !important;
}
@media screen and (min-width: 400px) and (max-width: 574px) { @media screen and (min-width: 400px) and (max-width: 574px) {
.d-xs-none { .d-xs-none {
@ -62,6 +138,33 @@ a {
} }
} }
.modal-backdrop.show {
opacity: 0.8;
}
.modal-header {
padding-bottom: 1.5rem;
}
.modal-title {
font-weight: bold;
font-size: 22px;
}
.modal-content {
border-radius: 1rem;
padding: 1rem;
}
.modal-body {
padding-top: 1.5rem;
padding-bottom: 1.5rem;
}
.modal-footer {
padding-top: 1.5rem;
}
.cursor-pointer { .cursor-pointer {
cursor: pointer; cursor: pointer;
} }
@ -69,3 +172,13 @@ a {
.v-popper__inner { .v-popper__inner {
margin: 0 12px !important; margin: 0 12px !important;
} }
.small,
small {
font-size: var(--bs-body-font-size);
color: var(--bs-gray-medium) !important;
}
.btn-close {
opacity: 1;
}

Binary file not shown.

Binary file not shown.

View file

@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square70x70logo src="ico/mstile-70x70.png" />
<square150x150logo src="ico/mstile-150x150.png" />
<square310x310logo src="ico/mstile-310x310.png" />
<TileColor>#18191a</TileColor>
</tile>
</msapplication>
</browserconfig>

View file

@ -3,22 +3,21 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="EV Charge Controller" /> <meta name="description" content="evcc - Sonne tanken ☀️🚘" />
<meta name="author" content="andig" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" /> <meta name="apple-mobile-web-app-status-bar-style" content="default" />
<link rel="apple-touch-icon" sizes="180x180" href="ico/apple-touch-icon.png?[[.Version]]" />
<link rel="icon" type="image/png" sizes="32x32" href="ico/favicon-32x32.png?[[.Version]]" />
<link rel="icon" type="image/png" sizes="16x16" href="ico/favicon-16x16.png?[[.Version]]" />
<link rel="manifest" href="ico/site.webmanifest" />
<link rel="mask-icon" href="ico/safari-pinned-tab.svg?[[.Version]]" color="#18191a" />
<link rel="shortcut icon" href="ico/favicon.ico?[[.Version]]" />
<meta name="apple-mobile-web-app-title" content="evcc" /> <meta name="apple-mobile-web-app-title" content="evcc" />
<meta name="application-name" content="evcc" /> <meta name="application-name" content="evcc" />
<meta name="msapplication-TileColor" content="#18191a" />
<meta name="msapplication-config" content="ico/browserconfig.xml" /> <link rel="apple-touch-icon" sizes="180x180" href="meta/apple-touch-icon.png?[[.Version]]" />
<meta name="theme-color" content="#18191a" /> <link rel="icon" type="image/png" sizes="32x32" href="meta/favicon-32x32.png?[[.Version]]" />
<link rel="icon" type="image/png" sizes="16x16" href="meta/favicon-16x16.png?[[.Version]]" />
<link rel="manifest" href="meta/site.webmanifest?[[.Version]]" />
<link rel="mask-icon" href="meta/safari-pinned-tab.svg?[[.Version]]" color="#28293e" />
<link rel="shortcut icon" href="meta/favicon.ico?[[.Version]]" />
<meta name="msapplication-TileColor" content="#28293e" />
<meta name="msapplication-config" content="meta/browserconfig.xml?[[.Version]]" />
<meta name="theme-color" content="#28293e" />
<title>evcc</title> <title>evcc</title>
</head> </head>
@ -32,5 +31,6 @@
</script> </script>
<div id="app"></div> <div id="app"></div>
<script type="module" src="./js/app.js"></script>
</body> </body>
</html> </html>

View file

@ -1,22 +1,21 @@
import "bootstrap/dist/css/bootstrap.min.css"; import "bootstrap/dist/css/bootstrap.min.css";
import "bootstrap"; import "bootstrap";
import smoothscroll from "smoothscroll-polyfill";
import "../css/app.css"; import "../css/app.css";
import Vue from "vue"; import { createApp, h } from "vue";
import VueMeta from "vue-meta"; import { createMetaManager, plugin as metaPlugin } from "vue-meta";
import api from "./api"; import api from "./api";
import App from "./views/App"; import App from "./views/App.vue";
import router from "./router"; import router from "./router";
import i18n from "./i18n"; import i18n from "./i18n";
import "./tooltip"; import featureflags from "./featureflags";
import store from "./store";
Vue.use(VueMeta); smoothscroll.polyfill();
window.app = new Vue({ const app = createApp({
el: "#app", data() {
router, return { notifications: [] };
i18n, },
data: { store, notifications: [] },
methods: { methods: {
raise: function (msg) { raise: function (msg) {
console[msg.type](msg); console[msg.type](msg);
@ -48,11 +47,18 @@ window.app = new Vue({
this.raise(msg); this.raise(msg);
}, },
}, },
render: function (h) { render: function () {
return h(App, { props: { notifications: this.notifications } }); return h(App, { notifications: this.notifications });
}, },
}); });
app.use(i18n);
app.use(router);
app.use(createMetaManager());
app.use(metaPlugin);
app.use(featureflags);
window.app = app.mount("#app");
window.setInterval(function () { window.setInterval(function () {
api.get("health").catch(function () { api.get("health").catch(function () {
window.app.error({ message: "Server unavailable" }); window.app.error({ message: "Server unavailable" });

View file

@ -1,58 +1,27 @@
<template> <template>
<div :class="{ 'power--in': charge, 'power--out': discharge }"> <component :is="`shopicon-regular-${icon}`"></component>
<fa-icon class="battery" :icon="batteryIcon"></fa-icon
><fa-icon class="arrow" icon="angle-double-right"></fa-icon>
</div>
</template> </template>
<script> <script>
import "../../icons"; import "@h2d2/shopicons/es/regular/batteryfull";
import "@h2d2/shopicons/es/regular/batterythreequarters";
import "@h2d2/shopicons/es/regular/batteryhalf";
import "@h2d2/shopicons/es/regular/batteryquarter";
import "@h2d2/shopicons/es/regular/batteryempty";
export default { export default {
name: "BatteryIcon", name: "BatteryIcon",
props: { props: {
discharge: { type: Boolean },
charge: { type: Boolean },
soc: { type: Number, default: 0 }, soc: { type: Number, default: 0 },
}, },
computed: { computed: {
batteryIcon: function () { icon: function () {
if (this.soc > 80) return "battery-full"; if (this.soc > 80) return "batteryfull";
if (this.soc > 60) return "battery-three-quarters"; if (this.soc > 60) return "batterythreequarters";
if (this.soc > 40) return "battery-half"; if (this.soc > 40) return "batteryhalf";
if (this.soc > 20) return "battery-quarter"; if (this.soc > 20) return "batteryquarter";
return "battery-empty"; return "batteryempty";
}, },
}, },
}; };
</script> </script>
<style scoped>
.battery {
transform: translateX(0.35rem) rotate(-90deg);
transition-property: transform;
transition-duration: 250ms;
transition-timing-function: ease;
}
.power--in .battery {
transform: translateX(0.7rem) rotate(-90deg);
}
.power--out .battery {
transform: translateX(0) rotate(-90deg);
}
.arrow {
margin-left: -0.2rem;
opacity: 0;
transform: translateX(-0.5rem);
transition-property: opacity, transform;
transition-duration: 250ms;
transition-timing-function: ease;
}
.power--in .arrow {
opacity: 1;
transform: translateX(-1rem) scaleX(1);
}
.power--out .arrow {
opacity: 1;
transform: translateX(0rem) scaleX(1);
}
</style>

View file

@ -1,228 +0,0 @@
/* globals describe, it, expect */
import { shallowMount } from "@vue/test-utils";
import Energyflow from "./Energyflow.vue";
const mocks = {
$t: (x) => x,
$tc: (x, n) => `${n} ${x}`,
$n: function (value, options) {
const n = new Intl.NumberFormat("en-EN", options);
return n.format(value);
},
};
describe("Energyflow.vue", () => {
const defaultProps = {
gridConfigured: true,
gridPower: 0,
pvConfigured: true,
pvPower: 0,
homePower: 0,
batteryConfigured: false,
batteryPower: 0,
batterySoC: 0,
};
it("using pv and grid power", async () => {
const wrapper = shallowMount(Energyflow, {
mocks,
propsData: {
...defaultProps,
gridPower: 1000,
pvPower: 4000,
homePower: 1300,
loadpointsPower: 3700,
},
});
await wrapper.find(".energyflow").trigger("click");
expect(wrapper.find("[data-test-grid-import]").text()).toMatch("1.0 kW");
expect(wrapper.find("[data-test-self-consumption]").text()).toMatch("4.0 kW");
expect(wrapper.find("[data-test-pv-export]").text()).toMatch("0.0 kW");
expect(wrapper.find("[data-test-home-power]").text()).toMatch("1.3 kW");
expect(wrapper.find("[data-test-loadpoints]").text()).toMatch("3.7 kW");
expect(wrapper.find("[data-test-pv-production]").text()).toMatch("4.0 kW");
expect(wrapper.find("[data-test-battery]").exists()).toBe(false);
});
it("exporting all pv power, no usage", async () => {
const wrapper = shallowMount(Energyflow, {
mocks,
propsData: { ...defaultProps, gridPower: -4000, pvPower: 5000, loadpointsPower: 1000 },
});
await wrapper.find(".energyflow").trigger("click");
expect(wrapper.find("[data-test-grid-import]").text()).toMatch("0.0 kW");
expect(wrapper.find("[data-test-self-consumption]").text()).toMatch("1.0 kW");
expect(wrapper.find("[data-test-pv-export]").text()).toMatch("4.0 kW");
expect(wrapper.find("[data-test-home-power]").text()).toMatch("0.0 kW");
expect(wrapper.find("[data-test-loadpoints]").text()).toMatch("1.0 kW");
expect(wrapper.find("[data-test-pv-production]").text()).toMatch("5.0 kW");
expect(wrapper.find("[data-test-battery]").exists()).toBe(false);
});
it("more grid export than pv, grid value wins (invalid state)", async () => {
const wrapper = shallowMount(Energyflow, {
mocks,
propsData: { ...defaultProps, gridPower: -4000, pvPower: 3000 },
});
await wrapper.find(".energyflow").trigger("click");
expect(wrapper.find("[data-test-grid-import]").text()).toMatch("0.0 kW");
expect(wrapper.find("[data-test-self-consumption]").text()).toMatch("0.0 kW");
expect(wrapper.find("[data-test-pv-export]").text()).toMatch("4.0 kW");
expect(wrapper.find("[data-test-home-power]").text()).toMatch("0.0 kW");
expect(wrapper.find("[data-test-loadpoints]").text()).toMatch("0.0 kW");
expect(wrapper.find("[data-test-pv-production]").text()).toMatch("3.0 kW");
expect(wrapper.find("[data-test-battery]").exists()).toBe(false);
});
it("more loadpoints power than grid import, loadpoints unchanged, house consumption min 0 (invalid state)", async () => {
const wrapper = shallowMount(Energyflow, {
mocks,
propsData: { ...defaultProps, gridPower: 6000, pvPower: 0, loadpointsPower: 7000 },
});
await wrapper.find(".energyflow").trigger("click");
expect(wrapper.find("[data-test-grid-import]").text()).toMatch("6.0 kW");
expect(wrapper.find("[data-test-self-consumption]").text()).toMatch("0.0 kW");
expect(wrapper.find("[data-test-pv-export]").text()).toMatch("0.0 kW");
expect(wrapper.find("[data-test-home-power]").text()).toMatch("0.0 kW");
expect(wrapper.find("[data-test-loadpoints]").text()).toMatch("7.0 kW");
expect(wrapper.find("[data-test-pv-production]").text()).toMatch("0.0 kW");
expect(wrapper.find("[data-test-battery]").exists()).toBe(false);
});
it("only grid usage, no pv, idleBattery", async () => {
const wrapper = shallowMount(Energyflow, {
mocks,
propsData: {
...defaultProps,
gridPower: 360,
pvPower: 0,
batteryConfigured: true,
batteryPower: 0,
homePower: 360,
},
});
await wrapper.find(".energyflow").trigger("click");
expect(wrapper.find("[data-test-grid-import]").text()).toMatch("360 W");
expect(wrapper.find("[data-test-self-consumption]").text()).toMatch("0 W");
expect(wrapper.find("[data-test-pv-export]").text()).toMatch("0 W");
expect(wrapper.find("[data-test-home-power]").text()).toMatch("360 W");
expect(wrapper.find("[data-test-pv-production]").text()).toMatch("0 W");
expect(wrapper.find("[data-test-battery]").text()).toMatch("main.energyflow.battery");
});
it("grid and battery usage, no pv", async () => {
const wrapper = shallowMount(Energyflow, {
mocks,
propsData: {
...defaultProps,
gridPower: 300,
batteryConfigured: true,
batteryPower: 234,
batterySoC: 77,
pvPower: 0,
homePower: 534,
},
});
await wrapper.find(".energyflow").trigger("click");
expect(wrapper.find("[data-test-grid-import]").text()).toMatch("300 W");
expect(wrapper.find("[data-test-self-consumption]").text()).toMatch("234 W");
expect(wrapper.find("[data-test-pv-export]").text()).toMatch("0 W");
expect(wrapper.find("[data-test-home-power]").text()).toMatch("534 W");
expect(wrapper.find("[data-test-pv-production]").text()).toMatch("0 W");
expect(wrapper.find("[data-test-battery]").text()).toMatch("234 W");
expect(wrapper.find("[data-test-battery]").text()).toMatch("77%");
expect(wrapper.find("[data-test-battery]").text()).toMatch("main.energyflow.batteryDischarge");
});
it("battery charge, pv export", async () => {
const wrapper = shallowMount(Energyflow, {
mocks,
propsData: {
...defaultProps,
gridPower: -2500,
batteryConfigured: true,
batteryPower: -1700,
pvPower: 9000,
homePower: 4800,
},
});
await wrapper.find(".energyflow").trigger("click");
expect(wrapper.find("[data-test-grid-import]").text()).toMatch("0.0 kW");
expect(wrapper.find("[data-test-self-consumption]").text()).toMatch("6.5 kW");
expect(wrapper.find("[data-test-pv-export]").text()).toMatch("2.5 kW");
expect(wrapper.find("[data-test-home-power]").text()).toMatch("4.8 kW");
expect(wrapper.find("[data-test-pv-production]").text()).toMatch("9.0 kW");
expect(wrapper.find("[data-test-battery]").text()).toMatch("1.7 kW");
expect(wrapper.find("[data-test-battery]").text()).toMatch("main.energyflow.batteryCharge");
});
it("thresholds", async () => {
const wrapper = shallowMount(Energyflow, {
mocks,
propsData: {
...defaultProps,
gridPower: 5555,
batteryConfigured: true,
batteryPower: 1234,
pvPower: 378,
homePower: 7200,
},
});
await wrapper.find(".energyflow").trigger("click");
expect(wrapper.find("[data-test-grid-import]").text()).toMatch("5.6 kW");
expect(wrapper.find("[data-test-self-consumption]").text()).toMatch("1.6 kW");
expect(wrapper.find("[data-test-pv-export]").text()).toMatch("0.0 kW");
expect(wrapper.find("[data-test-home-power]").text()).toMatch("7.2 kW");
expect(wrapper.find("[data-test-pv-production]").text()).toMatch("0.4 kW");
expect(wrapper.find("[data-test-battery]").text()).toMatch("1.2 kW");
expect(wrapper.find("[data-test-battery]").text()).toMatch("main.energyflow.batteryDischarge");
});
it("battery charge, grid import", async () => {
const wrapper = shallowMount(Energyflow, {
mocks,
propsData: {
...defaultProps,
gridPower: 1500,
batteryConfigured: true,
batteryPower: -1000,
homePower: 500,
pvPower: 0,
},
});
await wrapper.find(".energyflow").trigger("click");
expect(wrapper.find("[data-test-grid-import]").text()).toMatch("1.5 kW");
expect(wrapper.find("[data-test-self-consumption]").text()).toMatch("0.0 kW");
expect(wrapper.find("[data-test-pv-export]").text()).toMatch("0.0 kW");
expect(wrapper.find("[data-test-home-power]").text()).toMatch("0.5 kW");
expect(wrapper.find("[data-test-pv-production]").text()).toMatch("0.0 kW");
expect(wrapper.find("[data-test-battery]").text()).toMatch("1.0 kW");
expect(wrapper.find("[data-test-battery]").text()).toMatch("main.energyflow.batteryCharge");
});
});

View file

@ -1,5 +1,4 @@
import Energyflow from "./"; import Energyflow from "./";
import i18n from "../../i18n";
export default { export default {
title: "Main/Energyflow", title: "Main/Energyflow",
@ -13,13 +12,19 @@ export default {
batteryPower: { control: { type: "range", min: -4000, max: 4000, step: 100 } }, batteryPower: { control: { type: "range", min: -4000, max: 4000, step: 100 } },
batterySoC: { control: { type: "range", min: 0, max: 100, step: 1 } }, batterySoC: { control: { type: "range", min: 0, max: 100, step: 1 } },
}, },
parameters: {
backgrounds: {
default: "light",
},
},
}; };
const Template = (args, { argTypes }) => ({ const Template = (args) => ({
i18n, setup() {
props: Object.keys(argTypes), return { args };
},
components: { Energyflow }, components: { Energyflow },
template: '<Energyflow v-bind="$props"></Energyflow>', template: '<Energyflow v-bind="args"></Energyflow>',
}); });
export const GridAndPV = Template.bind({}); export const GridAndPV = Template.bind({});

View file

@ -1,12 +1,12 @@
<template> <template>
<div> <div
<div class="energyflow cursor-pointer position-relative"
class="row align-items-start align-items-md-center mt-4 energyflow" :class="{ 'energyflow--open': detailsOpen }"
@click="toggleDetails" @click="toggleDetails"
> >
<div class="row">
<Visualization <Visualization
class="col-12 offset-md-1 col-md-6 offset-lg-1 col-lg-8 offset-xl-1 col-xl-6 offset-xxl-1 col-xl-8 order-md-2" class="col-12 mb-3 mb-md-4"
:showDetails="showDetails"
:gridImport="gridImport" :gridImport="gridImport"
:selfConsumption="selfConsumption" :selfConsumption="selfConsumption"
:loadpoints="loadpointsPower" :loadpoints="loadpointsPower"
@ -18,88 +18,101 @@
:batterySoC="batterySoC" :batterySoC="batterySoC"
:valuesInKw="valuesInKw" :valuesInKw="valuesInKw"
/> />
<div </div>
class="col-12 col-sm-6 col-md-5 col-lg-3 col-xl-3 order-md-1 mt-2 mt-md-0" <div class="indicator position-absolute bottom-0 start-50">
:class="`${showDetails ? 'd-block' : `d-none d-md-block`}`" <shopicon-regular-arrowdown></shopicon-regular-arrowdown>
> </div>
<div class="d-flex justify-content-between" data-test-pv-production> <div class="details" :style="{ height: detailsHeight }">
<span class="details-icon text-muted"><fa-icon icon="sun"></fa-icon></span> <div ref="detailsInner" class="details-inner row">
<span class="text-nowrap flex-grow-1">{{ <div class="col-12 d-flex justify-content-between pt-2 mb-4">
$t("main.energyflow.pvProduction") <div class="d-flex flex-nowrap align-items-center">
}}</span> <span class="color-self me-2"
<span class="text-end text-nowrap ps-1">{{ kw(pvProduction) }}</span> ><shopicon-filled-square></shopicon-filled-square
</div> ></span>
<div class="d-flex justify-content-between" data-test-home-power> <span>{{ $t("main.energyflow.selfConsumption") }}</span>
<span class="details-icon text-muted"><fa-icon icon="home"></fa-icon></span> </div>
<span class="text-nowrap flex-grow-1">{{ <div v-if="gridImport > 0" class="d-flex flex-nowrap align-items-center">
$t("main.energyflow.homePower") <span>{{ $t("main.energyflow.gridImport") }}</span>
}}</span> <span class="color-grid ms-2"
<span class="text-end text-nowrap ps-1">{{ kw(homePower) }}</span> ><shopicon-filled-square></shopicon-filled-square
</div> ></span>
<div class="d-flex justify-content-between" data-test-loadpoints> </div>
<span class="details-icon text-muted"><fa-icon icon="car"></fa-icon></span> <div v-else class="d-flex flex-nowrap align-items-center">
<span class="text-nowrap flex-grow-1">{{ <span>{{ $t("main.energyflow.pvExport") }}</span>
$tc("main.energyflow.loadpoints", activeLoadpointsCount, { <span class="color-export ms-2"
count: activeLoadpointsCount, ><shopicon-filled-square></shopicon-filled-square
}) ></span>
}}</span> </div>
<span class="text-end text-nowrap ps-1">{{ kw(loadpointsPower) }}</span>
</div> </div>
<div <div
v-if="batteryConfigured" class="col-12 col-md-6 pe-md-5 pb-4 d-flex flex-column justify-content-between"
class="d-flex justify-content-between"
data-test-battery
> >
<span class="details-icon text-muted"> <div class="d-flex justify-content-between align-items-end mb-4">
<BatteryIcon <h3 class="m-0">In</h3>
:soc="batterySoC" <span class="fw-bold text-gray-medium">{{ kw(inPower) }}</span>
:charge="batteryCharge > 0" </div>
:discharge="batteryDischarge > 0" <div>
<EnergyflowEntry
:name="$t('main.energyflow.pvProduction')"
icon="sun"
:power="pvProduction"
:valuesInKw="valuesInKw"
/> />
</span> <EnergyflowEntry
<span class="text-nowrap flex-grow-1 text-truncate"> v-if="batteryConfigured"
<span v-if="batteryCharge">{{ $t("main.energyflow.batteryCharge") }}</span> :name="$t('main.energyflow.batteryDischarge')"
<span v-else-if="batteryDischarge">{{ icon="battery"
$t("main.energyflow.batteryDischarge") :soc="batterySoC"
}}</span> :power="batteryDischarge"
<span v-else>{{ $t("main.energyflow.battery") }}</span> :valuesInKw="valuesInKw"
</span> />
<span class="text-end text-nowrap ps-1"> <EnergyflowEntry
{{ batterySoC }}% / :name="$t('main.energyflow.gridImport')"
{{ kw(Math.abs(batteryPower)) }} icon="powersupply"
</span> :power="gridImport"
:valuesInKw="valuesInKw"
/>
</div>
</div> </div>
</div> <div
<div class="col-12 col-md-6 ps-md-5 pb-4 d-flex flex-column justify-content-between"
v-if="showDetails" >
class="col-12 col-sm-6 offset-md-6 col-md-6 offset-lg-4 col-lg-8 d-block d-md-flex order-md-3 justify-content-between mt-2" <div class="d-flex justify-content-between align-items-end mb-4">
> <h3 class="m-0">Out</h3>
<div class="text-nowrap d-flex d-md-block" data-test-grid-import> <span class="fw-bold text-gray-medium">{{ kw(outPower) }}</span>
<span class="color-grid details-icon"><fa-icon icon="square"></fa-icon></span> </div>
<span class="text-nowrap flex-grow-1">{{ <div>
$t("main.energyflow.gridImport") <EnergyflowEntry
}}</span> :name="$t('main.energyflow.homePower')"
<span class="text-end text-nowrap d-md-none"> icon="home"
{{ kw(gridImport) }} :power="homePower"
</span> :valuesInKw="valuesInKw"
</div> />
<div class="text-nowrap d-flex d-md-block" data-test-self-consumption> <EnergyflowEntry
<span class="color-self details-icon"><fa-icon icon="square"></fa-icon></span> :name="
<span class="text-nowrap flex-grow-1">{{ $tc('main.energyflow.loadpoints', activeLoadpointsCount, {
$t("main.energyflow.selfConsumption") count: activeLoadpointsCount,
}}</span> })
<span class="text-end text-nowrap d-md-none"> "
{{ kw(selfConsumption) }} icon="car3"
</span> :power="loadpointsPower"
</div> :valuesInKw="valuesInKw"
<div class="text-nowrap d-flex d-md-block" data-test-pv-export> />
<span class="color-export details-icon"><fa-icon icon="square"></fa-icon></span> <EnergyflowEntry
<span class="text-nowrap flex-grow-1">{{ v-if="batteryConfigured"
$t("main.energyflow.pvExport") :name="$t('main.energyflow.batteryCharge')"
}}</span> icon="battery"
<span class="text-end text-nowrap d-md-none"> :soc="batterySoC"
{{ kw(pvExport) }} :power="batteryCharge"
</span> :valuesInKw="valuesInKw"
/>
<EnergyflowEntry
:name="$t('main.energyflow.pvExport')"
icon="powersupply"
:power="pvExport"
:valuesInKw="valuesInKw"
/>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -107,14 +120,15 @@
</template> </template>
<script> <script>
import "../../icons"; import "@h2d2/shopicons/es/filled/square";
import formatter from "../../mixins/formatter"; import "@h2d2/shopicons/es/regular/arrowdown";
import Visualization from "./Visualization.vue"; import Visualization from "./Visualization.vue";
import BatteryIcon from "./BatteryIcon.vue"; import EnergyflowEntry from "./EnergyflowEntry.vue";
import formatter from "../../mixins/formatter";
export default { export default {
name: "Energyflow", name: "Energyflow",
components: { Visualization, BatteryIcon }, components: { Visualization, EnergyflowEntry },
mixins: [formatter], mixins: [formatter],
props: { props: {
gridConfigured: Boolean, gridConfigured: Boolean,
@ -128,8 +142,8 @@ export default {
batteryPower: { type: Number, default: 0 }, batteryPower: { type: Number, default: 0 },
batterySoC: { type: Number, default: 0 }, batterySoC: { type: Number, default: 0 },
}, },
data: function () { data: () => {
return { showDetails: false }; return { detailsOpen: false, detailsCompleteHeight: null };
}, },
computed: { computed: {
gridImport: function () { gridImport: function () {
@ -143,10 +157,10 @@ export default {
return Math.abs(this.batteryPower) < batteryPowerThreshold ? 0 : this.batteryPower; return Math.abs(this.batteryPower) < batteryPowerThreshold ? 0 : this.batteryPower;
}, },
batteryDischarge: function () { batteryDischarge: function () {
return Math.max(0, this.batteryPowerAdjusted); return Math.abs(Math.max(0, this.batteryPowerAdjusted));
}, },
batteryCharge: function () { batteryCharge: function () {
return Math.min(0, this.batteryPowerAdjusted) * -1; return Math.abs(Math.min(0, this.batteryPowerAdjusted) * -1);
}, },
selfConsumption: function () { selfConsumption: function () {
const ownPower = this.batteryDischarge + this.pvProduction; const ownPower = this.batteryDischarge + this.pvProduction;
@ -159,20 +173,60 @@ export default {
valuesInKw: function () { valuesInKw: function () {
return this.gridImport + this.selfConsumption + this.pvExport > 1000; return this.gridImport + this.selfConsumption + this.pvExport > 1000;
}, },
inPower: function () {
return this.gridImport + this.pvProduction + this.batteryDischarge;
},
outPower: function () {
return this.homePower + this.loadpointsPower + this.pvExport + this.batteryCharge;
},
detailsHeight: function () {
return this.detailsOpen ? this.detailsCompleteHeight + "px" : 0;
},
},
mounted() {
window.addEventListener("resize", this.updateHeight);
},
unmounted() {
window.removeEventListener("resize", this.updateHeight);
}, },
methods: { methods: {
kw: function (watt) { kw: function (watt) {
return this.fmtKw(watt, this.valuesInKw); return this.fmtKw(watt, this.valuesInKw);
}, },
toggleDetails() { toggleDetails: function () {
this.showDetails = !this.showDetails; this.updateHeight();
this.detailsOpen = !this.detailsOpen;
},
updateHeight: function () {
this.detailsCompleteHeight = this.$refs.detailsInner.offsetHeight;
}, },
}, },
}; };
</script> </script>
<style scoped> <style scoped>
.energyflow { .energyflow {
cursor: pointer; background: var(--bs-white);
}
.indicator {
opacity: 0;
transform: translateX(-50%) scaleY(1);
transition: opacity, transform;
transition-duration: var(--evcc-transition-slow);
}
.energyflow--open .indicator {
transform: translateX(-50%) scaleY(-1);
}
@media (hover: hover) and (pointer: fine) {
.energyflow:hover .indicator {
opacity: 0.25;
}
}
.details {
height: 0;
overflow: visible;
transition: height;
transition-duration: var(--evcc-transition-medium);
transition-timing-function: cubic-bezier(0.5, 0.5, 0.5, 1.15);
} }
.color-grid { .color-grid {
color: var(--evcc-grid); color: var(--evcc-grid);
@ -183,11 +237,4 @@ export default {
.color-export { .color-export {
color: var(--evcc-export); color: var(--evcc-export);
} }
.details-icon {
text-align: center;
width: 30px;
margin-right: 0.25rem;
white-space: nowrap;
flex-shrink: 0;
}
</style> </style>

View file

@ -0,0 +1,55 @@
<template>
<div class="d-flex justify-content-between mb-2 entry" :class="{ 'text-gray-medium': !active }">
<span class="d-flex flex-nowrap">
<BatteryIcon v-if="isBattery" :soc="soc" />
<component :is="`shopicon-regular-${icon}`" v-else></component>
</span>
<span class="text-nowrap flex-grow-1 ms-3">{{ name }}</span>
<span class="text-end text-nowrap ps-1 fw-bold"
><span v-if="hasSoC">{{ soc }}% / </span>{{ kw(power) }}</span
>
</div>
</template>
<script>
import "@h2d2/shopicons/es/regular/powersupply";
import "@h2d2/shopicons/es/regular/sun";
import "@h2d2/shopicons/es/regular/home";
import "@h2d2/shopicons/es/regular/car3";
import BatteryIcon from "./BatteryIcon.vue";
import formatter from "../../mixins/formatter";
export default {
name: "EnergyflowEntry",
components: { BatteryIcon },
mixins: [formatter],
props: {
name: { type: String },
icon: { type: String },
power: { type: Number },
soc: { type: Number },
valuesInKw: { type: Boolean },
},
computed: {
active: function () {
return this.power > 10;
},
isBattery: function () {
return this.icon === "battery";
},
hasSoC: function () {
return this.isBattery && !isNaN(this.soc);
},
},
methods: {
kw: function (watt) {
return this.fmtKw(watt, this.valuesInKw);
},
},
};
</script>
<style scoped>
.entry {
transition: color var(--evcc-transition-medium) linear;
}
</style>

View file

@ -1,56 +0,0 @@
<template>
<div :class="{ 'power-import': this.import, 'power-export': this.export }">
<svg
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
xml:space="preserve"
class="grid svg-inline--fa fa-home fa-w-18"
>
<path
fill="currentColor"
d="M217.529 46.629H83.817c-8.83 0-16 7.169-16 16v32c0 .196.004.391.011.586v51.22c0 6.209 5.041 11.25 11.25 11.25h22.5c6.209 0 11.25-5.041 11.25-11.25v-35.806h104.701v84.697H83.577c-8.83 0-16 7.169-16 16v32c0 .196.004.391.011.586v51.22c0 6.209 5.041 11.25 11.25 11.25h22.5c6.209 0 11.25-5.041 11.25-11.25v-35.806h104.941v49.925L176.47 506.932l160 .033-39.179-197.538v-50.101h100.402v35.756c0 6.257 5.08 11.337 11.337 11.337h22.674c6.257 0 11.337-5.08 11.337-11.337v-83.756c0-8.831-7.169-16-16-16h-129.75v-84.697h100.642v35.756c0 6.257 5.08 11.337 11.337 11.337h22.674c6.257 0 11.337-5.08 11.337-11.337V62.629c0-8.831-7.169-16-16-16h-129.99V26.162c-.043-12.777-40.058-18.846-40.159-18.633 0 0-39.66 6.092-39.603 18.633v20.467Z"
/></svg
><fa-icon class="arrow" icon="angle-double-right"></fa-icon>
</div>
</template>
<script>
export default {
name: "GridIcon",
props: {
import: { type: Boolean },
export: { type: Boolean },
},
};
</script>
<style scoped>
.grid {
transform: translateX(0.35rem);
transition-property: transform;
transition-duration: 250ms;
transition-timing-function: ease;
}
.power-export .grid {
transform: translateX(0.9rem);
}
.power-import .grid {
transform: translateX(-0.1rem);
}
.arrow {
margin-left: -0.2rem;
opacity: 0;
transform: translateX(-0.5rem);
transition-property: opacity, transform;
transition-duration: 250ms;
transition-timing-function: ease;
}
.power-export .arrow {
opacity: 1;
transform: translateX(-0.9rem) scaleX(1);
}
.power-import .arrow {
opacity: 1;
transform: translateX(0.1em) scaleX(1);
}
</style>

View file

@ -35,8 +35,7 @@ export default {
.label-bar { .label-bar {
width: 0; width: 0;
margin: 0; margin: 0;
height: 1.7rem; padding: 10px 0;
padding: 0.6rem 0;
opacity: 1; opacity: 1;
overflow: hidden; overflow: hidden;
} }
@ -44,51 +43,50 @@ export default {
opacity: 0; opacity: 0;
} }
.label-bar-scale { .label-bar-scale {
border: 1px solid var(--bs-gray); border: 1px solid var(--bs-gray-medium);
height: 0.5rem; height: 14px;
background: none; background: none;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
white-space: nowrap; white-space: nowrap;
border-radius: 0; border-radius: 0;
transition: border-radius 500ms linear; transition: border-radius var(--evcc-transition-medium) linear;
} }
.label-bar--top .label-bar-scale { .label-bar--top .label-bar-scale {
border-top-left-radius: 10px;
border-top-right-radius: 10px;
border-bottom: none; border-bottom: none;
} }
.label-bar--bottom .label-bar-scale { .label-bar--bottom .label-bar-scale {
border-bottom-left-radius: 10px;
border-bottom-right-radius: 10px;
border-top: none; border-top: none;
} }
.label-bar--top.label-bar--first .label-bar-scale {
border-top-left-radius: 4px;
}
.label-bar--top.label-bar--last .label-bar-scale {
border-top-right-radius: 4px;
}
.label-bar--bottom.label-bar--first .label-bar-scale {
border-bottom-left-radius: 4px;
}
.label-bar--bottom.label-bar--last .label-bar-scale {
border-bottom-right-radius: 4px;
}
.label-bar:not(.label-bar--first) .label-bar-scale {
border-left: none;
}
.label-bar-icon { .label-bar-icon {
background-color: white; background-color: white;
color: var(--bs-gray); transform: scale(1);
padding: 0 0.3rem; color: var(--bs-gray-dark);
opacity: 1; border-radius: 0;
border: 0.25rem solid white;
transition-property: background-color, transform, border-radius, border;
/* will be overwritten by parent component to avoid initial transition */
transition-duration: 0s;
transition-delay: 0s;
transition-timing-function: linear;
} }
.label-bar--top .label-bar-icon { .label-bar--top .label-bar-icon {
margin-top: -6px; margin-top: -12px;
} }
.label-bar--bottom .label-bar-icon { .label-bar--bottom .label-bar-icon {
margin-top: 6px; margin-top: 12px;
} }
.label-bar--hide-icon .label-bar-icon { .label-bar--hide-icon .label-bar-icon {
opacity: 0; background-color: var(--bs-gray-dark);
transform: scale(0.1666666);
border-radius: 100%;
border-width: 1.5rem;
transition-delay: 400ms, 0s;
} }
.label-bar--hidden { .label-bar--hidden {
opacity: 0; opacity: 0;

View file

@ -1,17 +1,18 @@
<template> <template>
<div class="visualization" :class="{ 'visualization--ready': visualizationReady }"> <div class="visualization" :class="{ 'visualization--ready': visualizationReady }">
<div class="label-scale"> <div class="label-scale d-flex">
<div class="d-flex justify-content-start"> <div class="d-flex justify-content-start flex-grow-1">
<LabelBar v-bind="labelBarProps('top', 'pvProduction')"> <LabelBar v-bind="labelBarProps('top', 'pvProduction')">
<fa-icon icon="sun"></fa-icon> <shopicon-regular-sun></shopicon-regular-sun>
</LabelBar> </LabelBar>
<LabelBar v-bind="labelBarProps('top', 'batteryDischarge')"> <LabelBar v-bind="labelBarProps('top', 'batteryDischarge')">
<BatteryIcon :soc="batterySoC" discharge /> <BatteryIcon :soc="batterySoC" />
</LabelBar> </LabelBar>
<LabelBar v-bind="labelBarProps('top', 'gridImport')"> <LabelBar v-bind="labelBarProps('top', 'gridImport')">
<GridIcon import /> <shopicon-regular-powersupply></shopicon-regular-powersupply>
</LabelBar> </LabelBar>
</div> </div>
<div class="label-scale-name">In</div>
</div> </div>
<div ref="site_progress" class="site-progress"> <div ref="site_progress" class="site-progress">
<div <div
@ -51,38 +52,39 @@
<span>{{ $t("main.energyflow.noEnergy") }}</span> <span>{{ $t("main.energyflow.noEnergy") }}</span>
</div> </div>
</div> </div>
<div class="label-scale"> <div class="label-scale d-flex">
<div class="d-flex justify-content-start"> <div class="d-flex justify-content-start flex-grow-1">
<LabelBar v-bind="labelBarProps('bottom', 'homePower')"> <LabelBar v-bind="labelBarProps('bottom', 'homePower')">
<fa-icon icon="home"></fa-icon> <shopicon-regular-home></shopicon-regular-home>
</LabelBar> </LabelBar>
<LabelBar v-bind="labelBarProps('bottom', 'loadpoints')"> <LabelBar v-bind="labelBarProps('bottom', 'loadpoints')">
<fa-icon icon="car"></fa-icon> <shopicon-regular-car3></shopicon-regular-car3>
</LabelBar> </LabelBar>
<LabelBar v-bind="labelBarProps('bottom', 'batteryCharge')"> <LabelBar v-bind="labelBarProps('bottom', 'batteryCharge')">
<BatteryIcon :soc="batterySoC" charge /> <BatteryIcon :soc="batterySoC" />
</LabelBar> </LabelBar>
<LabelBar v-bind="labelBarProps('bottom', 'gridExport')"> <LabelBar v-bind="labelBarProps('bottom', 'gridExport')">
<GridIcon export /> <shopicon-regular-powersupply></shopicon-regular-powersupply>
</LabelBar> </LabelBar>
</div> </div>
<div class="label-scale-name">Out</div>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import "../../icons";
import formatter from "../../mixins/formatter"; import formatter from "../../mixins/formatter";
import BatteryIcon from "./BatteryIcon.vue"; import BatteryIcon from "./BatteryIcon.vue";
import GridIcon from "./GridIcon.vue";
import LabelBar from "./LabelBar.vue"; import LabelBar from "./LabelBar.vue";
import "@h2d2/shopicons/es/regular/car3";
import "@h2d2/shopicons/es/regular/sun";
import "@h2d2/shopicons/es/regular/home";
export default { export default {
name: "Visualization", name: "Visualization",
components: { BatteryIcon, LabelBar, GridIcon }, components: { BatteryIcon, LabelBar },
mixins: [formatter], mixins: [formatter],
props: { props: {
showDetails: Boolean,
gridImport: { type: Number, default: 0 }, gridImport: { type: Number, default: 0 },
selfConsumption: { type: Number, default: 0 }, selfConsumption: { type: Number, default: 0 },
pvExport: { type: Number, default: 0 }, pvExport: { type: Number, default: 0 },
@ -99,7 +101,7 @@ export default {
}, },
computed: { computed: {
gridExport: function () { gridExport: function () {
return this.pvExport; return this.applyThreshold(this.pvExport);
}, },
totalRaw: function () { totalRaw: function () {
return this.gridImport + this.selfConsumption + this.pvExport; return this.gridImport + this.selfConsumption + this.pvExport;
@ -118,9 +120,6 @@ export default {
}, },
}, },
watch: { watch: {
showDetails: function () {
this.$nextTick(() => this.updateElementWidth());
},
totalAdjusted: function () { totalAdjusted: function () {
if (!this.visualizationReady && this.totalAdjusted > 0) if (!this.visualizationReady && this.totalAdjusted > 0)
setTimeout(() => { setTimeout(() => {
@ -134,7 +133,7 @@ export default {
this.updateElementWidth(); this.updateElementWidth();
}); });
}, },
beforeDestroy() { beforeUnmount() {
window.removeEventListener("resize", this.updateElementWidth); window.removeEventListener("resize", this.updateElementWidth);
}, },
methods: { methods: {
@ -171,38 +170,13 @@ export default {
updateElementWidth() { updateElementWidth() {
this.width = this.$refs.site_progress.getBoundingClientRect().width; this.width = this.$refs.site_progress.getBoundingClientRect().width;
}, },
isLabelFirst(position, name) {
return this.isLabel(position, name, false);
},
isLabelLast(position, name) {
return this.isLabel(position, name, true);
},
isLabel(position, name, last) {
const labels = {
top: ["pvProduction", "batteryDischarge", "gridImport"],
bottom: ["homePower", "loadpoints", "batteryCharge", "gridExport"],
};
const entries = [...labels[position]];
if (last) {
entries.reverse();
}
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
if (this[entry] > 0) {
return entry === name;
}
}
return false;
},
labelBarProps(position, name) { labelBarProps(position, name) {
const value = this[name]; const value = this[name];
const minWidth = name.startsWith("battery") || name.startsWith("grid") ? 44 : 32; const minWidth = 40;
return { return {
value, value,
hideIcon: this.hideLabelIcon(value, minWidth), hideIcon: this.hideLabelIcon(value, minWidth),
style: { width: this.widthTotal(value) }, style: { width: this.widthTotal(value) },
first: this.isLabelFirst(position, name),
last: this.isLabelLast(position, name),
[position]: true, [position]: true,
}; };
}, },
@ -211,12 +185,21 @@ export default {
</script> </script>
<style scoped> <style scoped>
.site-progress { .site-progress {
--height: 38px; --height: 2.5rem;
height: var(--height); height: var(--height);
margin: 0.25rem 0; border-radius: 10px;
border-radius: 5px;
display: flex; display: flex;
overflow: hidden; overflow: hidden;
margin-right: 1.2rem;
}
.label-scale-name {
color: var(--bs-gray-medium);
flex-basis: 1.2rem;
flex-grow: 0;
flex-shrink: 0;
writing-mode: tb-rl;
line-height: 1;
text-align: center;
} }
.site-progress-bar { .site-progress-bar {
display: flex; display: flex;
@ -228,7 +211,7 @@ export default {
} }
.visualization--ready .site-progress-bar { .visualization--ready .site-progress-bar {
transition-property: width; transition-property: width;
transition-duration: 500ms; transition-duration: var(--evcc-transition-medium);
transition-timing-function: linear; transition-timing-function: linear;
} }
.grid-import { .grid-import {
@ -249,12 +232,12 @@ export default {
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
} }
.visualization--ready >>> .label-bar { .visualization--ready ::v-deep(.label-bar) {
transition-property: width, opacity; transition-property: width, opacity;
transition-duration: 500ms, 250ms; transition-duration: var(--evcc-transition-medium), var(--evcc-transition-fast);
transition-timing-function: linear, ease; transition-timing-function: linear, ease;
} }
.visualization--ready >>> .label-bar-icon { .visualization--ready ::v-deep(.label-bar-icon) {
transition: opacity 250ms ease-in; transition-duration: var(--evcc-transition-very-fast), 500ms;
} }
</style> </style>

View file

@ -1,5 +1,4 @@
import Footer from "./Footer.vue"; import Footer from "./Footer.vue";
import i18n from "../i18n";
export default { export default {
title: "Main/Footer", title: "Main/Footer",
@ -7,11 +6,12 @@ export default {
argTypes: {}, argTypes: {},
}; };
const Template = (args, { argTypes }) => ({ const Template = (args) => ({
i18n, setup() {
props: Object.keys(argTypes), return { args };
},
components: { Footer }, components: { Footer },
template: '<Footer v-bind="$props"></Footer>', template: '<Footer v-bind="args"></Footer>',
}); });
export const KeinUpdate = Template.bind({}); export const KeinUpdate = Template.bind({});
@ -84,18 +84,3 @@ Upgrade.args = {
selfConsumptionPercent: 0, selfConsumptionPercent: 0,
}, },
}; };
export const Savings = Template.bind({});
Savings.args = {
version: {
installed: "0.36",
available: "0.40",
hasUpdater: true,
},
savings: {
since: 82800,
totalCharged: 15231,
selfConsumptionCharged: 12231,
selfConsumptionPercent: 80.3,
},
};

View file

@ -1,8 +1,16 @@
<template> <template>
<footer class="container"> <footer class="footer">
<div class="py-3 py-md-5 mt-3 mt-md-5 border-top"> <div class="container py-3">
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<Version v-bind="version" /> <Version v-bind="version" />
<!--
<button
class="btn btn-link p-0 flex-shrink-0"
:class="{ 'text-evcc': $hiddenFeatures, 'text-muted': !$hiddenFeatures }"
@click="toggleHiddenFeatures"
>
<shopicon-filled-testtube size="s"></shopicon-filled-testtube>
</button>-->
<!-- Please don't mess with the sponsor status. You risk loosing your mojo. --> <!-- Please don't mess with the sponsor status. You risk loosing your mojo. -->
<Savings v-bind="savings" :sponsor="sponsor" /> <Savings v-bind="savings" :sponsor="sponsor" />
</div> </div>
@ -11,8 +19,10 @@
</template> </template>
<script> <script>
import Version from "./Version"; import "@h2d2/shopicons/es/filled/testtube";
import Savings from "./Savings";
import Version from "./Version.vue";
import Savings from "./Savings.vue";
export default { export default {
name: "Footer", name: "Footer",
@ -22,5 +32,22 @@ export default {
sponsor: String, sponsor: String,
savings: Object, savings: Object,
}, },
methods: {
toggleHiddenFeatures() {
const dialog = `🧪 Experimentelle UI-Elemente ${
this.$hiddenFeatures ? "deaktivieren" : "aktivieren"
}`;
if (window.confirm(dialog)) {
window.localStorage["hidden_features"] = !this.$hiddenFeatures;
window.location.reload();
}
},
},
}; };
</script> </script>
<style scoped>
.footer {
color: var(--bs-white);
}
</style>

View file

@ -0,0 +1,41 @@
<template>
<div class="root">
<div class="mb-2 label">{{ label }}</div>
<slot>
<h3 class="value m-0">
{{ value }}
<span v-if="extraValue" class="extraValue d-block d-sm-inline text-nowrap">
{{ extraValue }}
</span>
</h3>
</slot>
</div>
</template>
<script>
export default {
name: "LabelAndValue",
props: {
label: String,
value: String,
extraValue: String,
},
};
</script>
<style scoped>
.root {
margin-bottom: 1rem;
}
.label {
text-transform: uppercase;
color: var(--bs-gray-medium);
font-size: 14px;
}
.value {
font-size: 18px;
}
.extraValue {
color: var(--bs-gray-medium);
font-size: 14px;
}
</style>

View file

@ -1,5 +1,4 @@
import Loadpoint from "./Loadpoint.vue"; import Loadpoint from "./Loadpoint.vue";
import i18n from "../i18n";
export default { export default {
title: "Main/Loadpoint", title: "Main/Loadpoint",
@ -11,11 +10,12 @@ export default {
}, },
}; };
const Template = (args, { argTypes }) => ({ const Template = (args) => ({
i18n, setup() {
props: Object.keys(argTypes), return { args };
},
components: { Loadpoint }, components: { Loadpoint },
template: '<Loadpoint v-bind="$props"></Loadpoint>', template: '<Loadpoint v-bind="args"></Loadpoint>',
}); });
export const Base = Template.bind({}); export const Base = Template.bind({});
@ -29,9 +29,29 @@ Base.args = {
vehicleTitle: "Mein Auto", vehicleTitle: "Mein Auto",
enabled: true, enabled: true,
connected: true, connected: true,
mode: "pv",
charging: true, charging: true,
vehicleSoC: 66, vehicleSoC: 66,
targetSoC: 90, targetSoC: 90,
chargeCurrent: 7,
minCurrent: 6,
maxCurrent: 16,
activePhases: 2,
};
export const Idle = Template.bind({});
Idle.args = {
id: 0,
chargePower: 0,
vehiclePresent: false,
enabled: false,
connected: false,
mode: "off",
charging: false,
chargeCurrent: 0,
minCurrent: 6,
maxCurrent: 16,
activePhases: 0,
}; };
export const Disabled = Template.bind({}); export const Disabled = Template.bind({});
@ -42,9 +62,16 @@ Disabled.args = {
remoteDisabledSource: "Sunny Home Manager", remoteDisabledSource: "Sunny Home Manager",
vehiclePresent: true, vehiclePresent: true,
vehicleTitle: "Mein Auto", vehicleTitle: "Mein Auto",
chargedEnergy: 31211,
enabled: true, enabled: true,
mode: "now",
connected: true, connected: true,
charging: false, charging: false,
chargePower: 8112,
vehicleSoC: 66, vehicleSoC: 66,
targetSoC: 100, targetSoC: 100,
chargeCurrent: 7,
minCurrent: 6,
maxCurrent: 16,
activePhases: 3,
}; };

View file

@ -1,40 +1,92 @@
<template> <template>
<div> <div class="loadpoint bg-white p-4">
<p class="h3 mb-4 d-sm-block" :class="{ 'd-none': single }"> <div class="d-flex justify-content-between align-items-center mb-3">
{{ title || $t("main.loadpoint.fallbackName") }} <h3 class="mb-3 me-2 text-truncate">
</p> {{ title || $t("main.loadpoint.fallbackName") }}
<div v-if="remoteDisabled == 'soft'" class="alert alert-warning mt-4 mb-2" role="alert"> </h3>
{{ $t("main.loadpoint.remoteDisabledSoft", { source: remoteDisabledSource }) }} <div class="mb-3 d-flex align-items-center">
</div> <Mode :mode="mode" @updated="setTargetMode" />
<div v-if="remoteDisabled == 'hard'" class="alert alert-danger mt-4 mb-2" role="alert"> <button v-if="$hiddenFeatures" class="btn btn-link text-gray p-0 flex-shrink-0">
{{ $t("main.loadpoint.remoteDisabledHard", { source: remoteDisabledSource }) }} <shopicon-filled-options size="s"></shopicon-filled-options>
</button>
</div>
</div> </div>
<div class="row"> <div
<Mode class="col-12 col-md-6 col-lg-4 mb-4" :mode="mode" @updated="setTargetMode" /> v-if="remoteDisabled"
<Vehicle class="alert alert-warning my-4 py-2"
class="col-12 col-md-6 col-lg-8 mb-4" :class="`${remoteDisabled === 'hard' ? 'alert-danger' : 'alert-warning'}`"
v-bind="vehicle" role="alert"
@target-soc-updated="setTargetSoC" >
@target-time-updated="setTargetTime" {{
@target-time-removed="removeTargetTime" $t(
remoteDisabled === "hard"
? "main.loadpoint.remoteDisabledHard"
: "main.loadpoint.remoteDisabledSoft",
{ source: remoteDisabledSource }
)
}}
</div>
<div class="details d-flex align-items-start mb-3">
<div class="d-flex align-items-center">
<div>
<LabelAndValue
:label="$t('main.loadpoint.power')"
:value="fmtKw(chargePower)"
class="mb-2"
/>
<Phases
v-bind="phasesProps"
class="opacity-transiton"
:class="`opacity-${charging ? '100' : '0'}`"
/>
</div>
<shopicon-regular-lightning
class="text-evcc opacity-transiton"
:class="`opacity-${charging ? '100' : '0'}`"
size="m"
></shopicon-regular-lightning>
</div>
<LabelAndValue :label="$t('main.loadpoint.charged')" :value="fmtKWh(chargedEnergy)" />
<LabelAndValue
v-if="chargeRemainingDurationInterpolated"
:label="$t('main.loadpoint.remaining')"
:value="`
${fmtShortDuration(chargeRemainingDurationInterpolated)}
${fmtShortDurationUnit(chargeRemainingDurationInterpolated, true)}`"
/>
<LabelAndValue
v-else
:label="$t('main.loadpoint.duration')"
:value="`
${fmtShortDuration(chargeDurationInterpolated)}
${fmtShortDurationUnit(chargeDurationInterpolated)}`"
/> />
</div> </div>
<LoadpointDetails v-bind="details" /> <Vehicle
v-bind="vehicle"
@target-soc-updated="setTargetSoC"
@target-time-updated="setTargetTime"
@target-time-removed="removeTargetTime"
/>
</div> </div>
</template> </template>
<script> <script>
import "@h2d2/shopicons/es/regular/lightning";
import "@h2d2/shopicons/es/filled/options";
import api from "../api"; import api from "../api";
import Mode from "./Mode"; import Mode from "./Mode.vue";
import Vehicle from "./Vehicle"; import Vehicle from "./Vehicle.vue";
import LoadpointDetails from "./LoadpointDetails"; import Phases from "./Phases.vue";
import LabelAndValue from "./LabelAndValue.vue";
import formatter from "../mixins/formatter"; import formatter from "../mixins/formatter";
import collector from "../mixins/collector"; import collector from "../mixins/collector";
export default { export default {
name: "Loadpoint", name: "Loadpoint",
components: { LoadpointDetails, Mode, Vehicle }, components: { Mode, Vehicle, Phases, LabelAndValue },
mixins: [formatter, collector], mixins: [formatter, collector],
props: { props: {
id: Number, id: Number,
@ -60,6 +112,7 @@ export default {
minSoC: Number, minSoC: Number,
targetTime: String, targetTime: String,
targetTimeActive: Boolean, targetTimeActive: Boolean,
targetTimeProjectedStart: String,
vehicleProviderLoggedIn: Boolean, vehicleProviderLoggedIn: Boolean,
vehicleProviderLoginPath: String, vehicleProviderLoginPath: String,
vehicleProviderLogoutPath: String, vehicleProviderLogoutPath: String,
@ -87,15 +140,58 @@ export default {
pvRemaining: Number, pvRemaining: Number,
pvAction: String, pvAction: String,
}, },
data() {
return {
tickerHandler: null,
phaseRemainingInterpolated: this.phaseRemaining,
pvRemainingInterpolated: this.pvRemaining,
chargeDurationInterpolated: this.chargeDuration,
chargeRemainingDurationInterpolated: this.chargeRemainingDuration,
};
},
computed: { computed: {
details: function () { phasesProps: function () {
return this.collectProps(LoadpointDetails); return this.collectProps(Phases);
}, },
vehicle: function () { vehicle: function () {
return this.collectProps(Vehicle); return this.collectProps(Vehicle);
}, },
}, },
watch: {
phaseRemaining() {
this.phaseRemainingInterpolated = this.phaseRemaining;
},
pvRemaining() {
this.pvRemainingInterpolated = this.pvRemaining;
},
chargeDuration() {
this.chargeDurationInterpolated = this.chargeDuration;
},
chargeRemainingDuration() {
this.chargeRemainingDurationInterpolated = this.chargeRemainingDuration;
},
},
mounted() {
this.tickerHandler = setInterval(this.tick, 1000);
},
unmounted() {
clearInterval(this.tickerHandler);
},
methods: { methods: {
tick() {
if (this.phaseRemainingInterpolated > 0) {
this.phaseRemainingInterpolated--;
}
if (this.pvRemainingInterpolated > 0) {
this.pvRemainingInterpolated--;
}
if (this.chargeDurationInterpolated > 0 && this.charging) {
this.chargeDurationInterpolated++;
}
if (this.chargeRemainingDurationInterpolated > 0 && this.charging) {
this.chargeRemainingDurationInterpolated--;
}
},
apiPath: function (func) { apiPath: function (func) {
return "loadpoints/" + this.id + "/" + func; return "loadpoints/" + this.id + "/" + func;
}, },
@ -115,3 +211,24 @@ export default {
}, },
}; };
</script> </script>
<style scoped>
.loadpoint {
border-radius: 2rem;
color: var(--bs-gray-dark);
}
.details > div {
flex-grow: 1;
flex-basis: 0;
}
.details > div:nth-child(2) {
text-align: center;
}
.details > div:nth-child(3) {
text-align: right;
}
.opacity-transiton {
transition: opacity var(--evcc-transition-slow) ease-in;
}
</style>

View file

@ -1,6 +1,5 @@
import LoadpointDetails from "./LoadpointDetails.vue"; import LoadpointDetails from "./LoadpointDetails.vue";
import i18n from "../i18n"; import i18n from "../i18n";
import "../tooltip";
export default { export default {
title: "Main/LoadpointDetails", title: "Main/LoadpointDetails",

View file

@ -1,201 +0,0 @@
<template>
<div>
<div class="row">
<div class="col-6 col-sm-3 col-lg-2 mt-3 offset-lg-4">
<div class="mb-2 value">
{{ $t("main.loadpointDetails.power") }}
<div
v-if="chargePower && activePhases"
v-tooltip="{ content: phaseTooltip }"
class="badge rounded-pill bg-secondary text-light cursor-pointer d-inline-flex align-items-center"
tabindex="0"
>
<div>{{ activePhases }}P</div>
<WaitingDots
v-if="phaseTimerVisible"
:direction="phaseAction === 'scale1p' ? 'down' : 'up'"
/>
</div>
<fa-icon
v-if="climater == 'heating'"
class="text-primary ms-1"
icon="temperature-low"
></fa-icon>
<fa-icon
v-if="climater == 'cooling'"
class="text-primary ms-1"
icon="temperature-high"
></fa-icon>
<fa-icon
v-if="climater == 'on'"
class="text-primary ms-1"
icon="thermometer-half"
></fa-icon>
</div>
<h3 class="value">
{{ fmt(chargePower) }}
<small class="text-muted">{{ fmtUnit(chargePower) }}W</small
><small
v-if="pvTimerVisible"
v-tooltip="{
content: $t(`main.loadpointDetails.tooltip.pv.${pvAction}`, {
remaining: fmtShortDuration(pvRemainingInterpolated, true),
}),
}"
class="text-muted cursor-pointer d-inline-block align-bottom"
style="margin-bottom: 0.1em"
tabindex="0"
>
<WaitingDots :direction="pvAction === 'disable' ? 'down' : 'up'" />
</small>
</h3>
</div>
<div class="col-6 col-sm-3 col-lg-2 mt-3">
<div class="mb-2 value">{{ $t("main.loadpointDetails.charged") }}</div>
<h3 class="value">
{{ fmt(chargedEnergy) }}
<small class="text-muted">{{ fmtUnit(chargedEnergy) }}Wh</small>
</h3>
</div>
<div v-if="vehicleRange && vehicleRange >= 0" class="col-6 col-sm-3 col-lg-2 mt-3">
<div class="mb-2 value">{{ $t("main.loadpointDetails.vehicleRange") }}</div>
<h3 class="value">
{{ Math.round(vehicleRange) }}
<small class="text-muted">km</small>
</h3>
</div>
<div v-else class="col-6 col-sm-3 col-lg-2 mt-3">
<div class="mb-2 value">{{ $t("main.loadpointDetails.duration") }}</div>
<h3 class="value">
{{ fmtShortDuration(chargeDurationInterpolated) }}
<small class="text-muted">{{
fmtShortDurationUnit(chargeDurationInterpolated)
}}</small>
</h3>
</div>
<div v-if="vehiclePresent" class="col-6 col-sm-3 col-lg-2 mt-3">
<div class="mb-2 value">{{ $t("main.loadpointDetails.remaining") }}</div>
<h3 class="value">
{{ fmtShortDuration(chargeRemainingDurationInterpolated) }}
<small class="text-muted">{{
fmtShortDurationUnit(chargeRemainingDurationInterpolated, true)
}}</small>
</h3>
</div>
</div>
</div>
</template>
<script>
import WaitingDots from "./WaitingDots";
import formatter from "../mixins/formatter";
export default {
name: "LoadpointDetails",
components: { WaitingDots },
mixins: [formatter],
props: {
chargedEnergy: Number,
chargeDuration: Number,
chargeRemainingDuration: Number,
chargePower: Number,
climater: String,
vehiclePresent: Boolean,
vehicleRange: Number,
activePhases: Number,
phaseRemaining: Number,
phaseAction: String,
pvRemaining: Number,
pvAction: String,
charging: Boolean,
},
data() {
return {
tickerHandler: null,
phaseRemainingInterpolated: this.phaseRemaining,
pvRemainingInterpolated: this.pvRemaining,
chargeDurationInterpolated: this.chargeDuration,
chargeRemainingDurationInterpolated: this.chargeRemainingDuration,
};
},
computed: {
phaseTooltip() {
if (["scale1p", "scale3p"].includes(this.phaseAction)) {
return this.$t(`main.loadpointDetails.tooltip.phases.${this.phaseAction}`, {
remaining: this.fmtShortDuration(this.phaseRemainingInterpolated, true),
});
}
return this.$t(`main.loadpointDetails.tooltip.phases.charge${this.activePhases}p`);
},
phaseTimerActive() {
return (
this.phaseRemainingInterpolated > 0 &&
["scale1p", "scale3p"].includes(this.phaseAction)
);
},
pvTimerActive() {
return (
this.pvRemainingInterpolated > 0 && ["enable", "disable"].includes(this.pvAction)
);
},
phaseTimerVisible() {
if (this.phaseTimerActive && !this.pvTimerActive) {
return true;
}
if (this.phaseTimerActive && this.pvTimerActive) {
return this.phaseRemainingInterpolated < this.pvRemainingInterpolated; // only show next timer
}
return false;
},
pvTimerVisible() {
if (this.pvTimerActive && !this.phaseTimerActive) {
return true;
}
if (this.pvTimerActive && this.phaseTimerActive) {
return this.pvRemainingInterpolated < this.phaseRemainingInterpolated; // only show next timer
}
return false;
},
},
watch: {
phaseRemaining() {
this.phaseRemainingInterpolated = this.phaseRemaining;
},
pvRemaining() {
this.pvRemainingInterpolated = this.pvRemaining;
},
chargeDuration() {
this.chargeDurationInterpolated = this.chargeDuration;
},
chargeRemainingDuration() {
this.chargeRemainingDurationInterpolated = this.chargeRemainingDuration;
},
},
mounted() {
this.tickerHandler = setInterval(this.tick, 1000);
},
destroyed() {
clearInterval(this.tickerHandler);
},
methods: {
tick() {
if (this.phaseRemainingInterpolated > 0) {
this.phaseRemainingInterpolated--;
}
if (this.pvRemainingInterpolated > 0) {
this.pvRemainingInterpolated--;
}
if (this.chargeDurationInterpolated > 0 && this.charging) {
this.chargeDurationInterpolated++;
}
if (this.chargeRemainingDurationInterpolated > 0 && this.charging) {
this.chargeRemainingDurationInterpolated--;
}
},
},
};
</script>

View file

@ -0,0 +1,172 @@
<template>
<div
class="container container--loadpoint px-0 mb-3 mb-md-4 d-flex flex-column justify-content-center"
>
<div ref="carousel" class="carousel d-lg-flex flex-wrap">
<div
v-for="(loadpoint, index) in loadpoints"
:key="index"
class="flex-grow-1 mb-3 m-lg-0 p-lg-0"
>
<Loadpoint
v-bind="loadpoint"
:id="index"
class="h-100"
:class="{ 'loadpoint-unselected': !selected(index) }"
@click="scrollTo(index)"
/>
</div>
</div>
<div v-if="loadpoints.length > 1" class="d-flex d-lg-none justify-content-center">
<button
v-for="(loadpoint, index) in loadpoints"
:key="index"
class="btn btn-sm btn-link p-0 mx-1 indicator d-flex justify-content-center align-items-center text-white"
:class="{ 'indicator--selected': selected(index) }"
@click="scrollTo(index)"
>
<shopicon-filled-circle class="indicator-icon"></shopicon-filled-circle>
</button>
</div>
</div>
</template>
<script>
import "@h2d2/shopicons/es/filled/circle";
import Loadpoint from "./Loadpoint.vue";
import collector from "../mixins/collector";
export default {
name: "Site",
components: { Loadpoint },
mixins: [collector],
props: {
loadpoints: Array,
},
data() {
return { selectedIndex: 0, snapTimeout: null };
},
mounted() {
this.$refs.carousel.addEventListener("scroll", this.handleCarouselScroll, false);
},
unmounted() {
this.$refs.carousel.removeEventListener("scroll", this.handleCarouselScroll);
},
methods: {
handleCarouselScroll() {
const { scrollLeft } = this.$refs.carousel;
const { offsetWidth } = this.$refs.carousel.children[0];
this.selectedIndex = Math.round((scrollLeft - 7.5) / offsetWidth);
},
selected(index) {
return this.selectedIndex === index;
},
scrollTo(index) {
if (this.selectedIndex === index) {
return;
}
this.selectedIndex = index;
const $carousel = this.$refs.carousel;
const width = $carousel.children[0].offsetWidth;
$carousel.style.scrollSnapType = "none";
$carousel.scrollTo({ top: 0, left: 7.5 + width * index, behavior: "smooth" });
clearTimeout(this.snapTimeout);
this.snapTimeout = setTimeout(() => {
this.$refs.carousel.style.scrollSnapType = "x mandatory";
}, 1000);
},
},
};
</script>
<style scoped>
.container--loadpoint {
min-height: 300px;
}
@media (max-width: 991.98px) {
.carousel {
scroll-snap-type: x mandatory;
overflow-x: scroll;
display: flex;
flex-wrap: nowrap !important;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE 10+ */
}
.carousel::-webkit-scrollbar {
display: none; /* Blink, Webkit */
}
.carousel > * {
scroll-snap-align: center;
min-width: 100%;
}
.indicator {
width: 32px;
height: 32px;
opacity: 0.3;
transition: opacity var(--evcc-transition-fast) ease-in;
}
.indicator--selected {
opacity: 1;
}
.indicator-icon {
width: 18px;
}
.loadpoint {
opacity: 1;
transform: scale(1);
transition-property: opacity, transform;
transition-duration: var(--evcc-transition-fast);
transition-timing-function: ease-in;
}
.loadpoint-unselected {
transform: scale(0.95);
opacity: 0.5;
}
}
/* show truncated tiles on breakpoind sm,md */
@media (min-width: 576px) and (max-width: 991.98px) {
.container--loadpoint {
max-width: none;
}
.carousel > *:first-child {
margin-left: calc((100vw - var(--slide-width)) / 2);
}
.carousel > *:last-child {
margin-right: calc((100vw - var(--slide-width)) / 2);
}
/* fixes safari issue with end-side padding https://webplatform.news/issues/2019-08-07 */
.carousel::after {
content: "";
padding-right: 0.02px;
}
.carousel > * {
min-width: var(--slide-width);
}
}
/* breakpoind sm */
@media (min-width: 576px) and (max-width: 767.98px) {
.carousel {
--slide-width: 540px;
}
}
/* breakpoind md */
@media (min-width: 768px) and (max-width: 991.98px) {
.carousel {
--slide-width: 720px;
}
}
/* breakpoind lg */
@media (min-width: 992px) {
.carousel {
display: grid !important;
grid-gap: 2rem;
grid-template-columns: repeat(auto-fit, minmax(450px, 1fr));
}
}
</style>

View file

@ -1,27 +1,20 @@
import Mode from "./Mode.vue"; import Mode from "./Mode.vue";
import i18n from "../i18n";
export default { export default {
title: "Main/Mode", title: "Main/Mode",
component: Mode, component: Mode,
argTypes: { argTypes: {
mode: { control: { type: "inline-radio", options: ["off", "now", "minpv", "pv"] } }, mode: { control: { type: "inline-radio" }, options: ["off", "now", "minpv", "pv"] },
}, },
}; };
const Template = (args, { argTypes }) => ({ const Template = (args) => ({
i18n, setup() {
props: Object.keys(argTypes), return { args };
},
components: { Mode }, components: { Mode },
template: '<Mode v-bind="$props"></Mode>', template: '<Mode v-bind="args"></Mode>',
}); });
export const Base = Template.bind({}); export const Base = Template.bind({});
Base.args = {}; Base.args = { mode: "pv" };
export const CaptionAndPV = Template.bind({});
CaptionAndPV.args = {
caption: true,
pvConfigured: true,
mode: "pv",
};

View file

@ -1,42 +1,28 @@
<template> <template>
<div> <div v-if="$hiddenFeatures" class="mode-group border d-inline-flex" role="group">
<div class="mb-3">{{ $t("main.mode.title") }}</div> <button
<div class="btn-group w-100" role="group"> v-for="m in ['fast', 'cheap']"
<button :key="m"
type="button" type="button"
class="btn btn-outline-secondary" class="btn"
:class="{ active: mode == 'off' }" :class="{ active: isActive(m) }"
@click="setTargetMode('off')" @click="setTargetMode(m)"
> >
{{ $t("main.mode.stop") }} {{ $t(`main.mode.${m}`) }}
</button> </button>
<button </div>
type="button" <div v-else class="mode-group border d-inline-flex" role="group">
class="btn btn-outline-secondary" <button
:class="{ active: mode == 'now' }" v-for="m in modes"
@click="setTargetMode('now')" :key="m"
> type="button"
{{ $t("main.mode.now") }} class="btn"
</button> :class="{ active: isActive(m) }"
<button @click="setTargetMode(m)"
type="button" >
class="btn btn-outline-secondary" <span class="d-inline d-sm-none"> {{ $t(`main.mode.${m}Short`) }} </span>
:class="{ active: mode == 'minpv' }" <span class="d-none d-sm-inline"> {{ $t(`main.mode.${m}Long`) }} </span>
@click="setTargetMode('minpv')" </button>
>
<span class="d-inline d-sm-none"> {{ $t("main.mode.minpvShort") }} </span>
<span class="d-none d-sm-inline"> {{ $t("main.mode.minpvLong") }} </span>
</button>
<button
type="button"
class="btn btn-outline-secondary"
:class="{ active: mode == 'pv' }"
@click="setTargetMode('pv')"
>
<span class="d-inline d-sm-none"> {{ $t("main.mode.pvShort") }} </span>
<span class="d-none d-sm-inline"> {{ $t("main.mode.pvLong") }} </span>
</button>
</div>
</div> </div>
</template> </template>
@ -46,18 +32,49 @@ export default {
props: { props: {
mode: String, mode: String,
}, },
emits: ["updated"],
data() {
return {
modes: ["off", "now", "minpv", "pv"],
};
},
methods: { methods: {
isActive: function (mode) {
return this.mode === this.mapToOldModes(mode);
},
setTargetMode: function (mode) { setTargetMode: function (mode) {
this.$emit("updated", mode); this.$emit("updated", this.mapToOldModes(mode));
},
mapToOldModes: function (mode) {
const mapping = { fast: "now", cheap: "pv" };
return mapping[mode] || mode;
}, },
}, },
}; };
</script> </script>
<style scoped></style>
<style scoped> <style scoped>
.mode-group {
border: 2px solid var(--bs-gray-medium);
background-color: var(--bs-white);
border-radius: 20px;
padding: 4px;
}
.btn { .btn {
/* equal width buttons */ /* equal width buttons */
flex-basis: 0; flex-basis: 0;
white-space: nowrap; white-space: nowrap;
border-radius: 18px;
padding: 0.1em 0.8em;
}
.btn.active {
background: var(--bs-gray-dark);
color: var(--bs-white);
}
.btn-group {
border-radius: 16px;
} }
</style> </style>

View file

@ -1,5 +1,4 @@
import Notifications from "./Notifications.vue"; import Notifications from "./Notifications.vue";
import i18n from "../i18n";
export default { export default {
title: "Main/Notifications", title: "Main/Notifications",
@ -7,11 +6,12 @@ export default {
argTypes: {}, argTypes: {},
}; };
const Template = (args, { argTypes }) => ({ const Template = (args) => ({
i18n, setup() {
props: Object.keys(argTypes), return { args };
},
components: { Notifications }, components: { Notifications },
template: '<Notifications v-bind="$props"></Notifications>', template: '<Notifications v-bind="args"></Notifications>',
}); });
function timeAgo(hours = 0, minutes = 0, seconds = 0) { function timeAgo(hours = 0, minutes = 0, seconds = 0) {

View file

@ -5,17 +5,20 @@
href="#" href="#"
data-bs-toggle="modal" data-bs-toggle="modal"
data-bs-target="#notificationModal" data-bs-target="#notificationModal"
class="btn btn-link text-decoration-none link-light text-nowrap" class="btn btn-sm btn-link text-decoration-none link-light text-nowrap"
> >
<fa-icon :class="iconClass" icon="exclamation-triangle"></fa-icon> <shopicon-regular-exclamationtriangle
:class="iconClass"
></shopicon-regular-exclamationtriangle>
</button> </button>
<div <div
id="notificationModal" id="notificationModal"
class="modal fade" class="modal fade text-dark"
tabindex="-1" tabindex="-1"
role="dialog" role="dialog"
aria-hidden="true" aria-hidden="true"
data-bs-backdrop="true"
> >
<div <div
class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable" class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable"
@ -40,14 +43,13 @@
{{ fmtTimeAgo(msg.time - new Date()) }} {{ fmtTimeAgo(msg.time - new Date()) }}
</small> </small>
<p class="d-flex align-items-baseline"> <p class="d-flex align-items-baseline">
<fa-icon <shopicon-regular-exclamationtriangle
:class="{ :class="{
'text-danger': msg.type === 'error', 'text-danger': msg.type === 'error',
'text-warning': msg.type === 'warn', 'text-warning': msg.type === 'warn',
}" }"
class="flex-grow-0 d-block" class="flex-grow-0 d-block"
icon="exclamation-triangle" ></shopicon-regular-exclamationtriangle>
></fa-icon>
<span class="flex-grow-1 px-2 py-1 text-break"> <span class="flex-grow-1 px-2 py-1 text-break">
{{ msg.message }} {{ msg.message }}
</span> </span>
@ -75,7 +77,7 @@
</template> </template>
<script> <script>
import "../icons"; import "@h2d2/shopicons/es/regular/exclamationtriangle";
import formatter from "../mixins/formatter"; import formatter from "../mixins/formatter";
export default { export default {
@ -99,7 +101,7 @@ export default {
this.$forceUpdate(); this.$forceUpdate();
}, 10 * 1000); }, 10 * 1000);
}, },
destroyed: function () { unmounted: function () {
clearTimeout(this.interval); clearTimeout(this.interval);
}, },
methods: { methods: {

View file

@ -0,0 +1,59 @@
import Phases from "./Phases.vue";
export default {
title: "Main/Phases",
component: Phases,
argTypes: {},
parameters: {
backgrounds: {
default: "light",
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { Phases },
template: '<Phases v-bind="$props"></Phases>',
});
export const Base = Template.bind({});
Base.args = {
activePhases: 1,
minCurrent: 6,
maxCurrent: 32,
chargeCurrent: 10,
};
export const OnePhase = Template.bind({});
OnePhase.args = {
activePhases: 1,
minCurrent: 6,
maxCurrent: 16,
chargeCurrent: 6,
};
export const TwoPhase = Template.bind({});
TwoPhase.args = {
activePhases: 2,
minCurrent: 6,
maxCurrent: 16,
chargeCurrent: 8,
};
export const ThreePhases = Template.bind({});
ThreePhases.args = {
activePhases: 3,
minCurrent: 6,
maxCurrent: 16,
chargeCurrent: 12,
};
export const RealCurrents = Template.bind({});
RealCurrents.args = {
activePhases: 3,
minCurrent: 6,
maxCurrent: 32,
chargeCurrent: 16.5,
chargeCurrents: [15.2, 15.76, 14.5],
};

View file

@ -0,0 +1,99 @@
<template>
<div class="phases d-flex justify-content-between">
<div
v-for="num in [1, 2, 3]"
:key="num"
class="phase me-1"
:class="{ inactive: inactive(num) }"
>
<div class="target" :style="{ width: `${targetWidth()}%` }"></div>
<div class="real" :style="{ width: `${realWidth(num)}%` }"></div>
</div>
</div>
</template>
<script>
export default {
name: "Phases",
props: {
chargeCurrent: { type: Number },
chargeCurrents: { type: Object },
activePhases: { type: Number },
minCurrent: { type: Number },
maxCurrent: { type: Number },
phaseAction: { type: String },
phaseRemaining: { type: Number },
},
computed: {
phaseTimerVisible() {
if (this.phaseTimerActive && !this.pvTimerActive) {
return true;
}
if (this.phaseTimerActive && this.pvTimerActive) {
return this.phaseRemainingInterpolated < this.pvRemainingInterpolated; // only show next timer
}
return false;
},
pvTimerVisible() {
if (this.pvTimerActive && !this.phaseTimerActive) {
return true;
}
if (this.pvTimerActive && this.phaseTimerActive) {
return this.pvRemainingInterpolated < this.phaseRemainingInterpolated; // only show next timer
}
return false;
},
},
methods: {
inactive(num) {
return num > this.activePhases;
},
targetWidth() {
let current = Math.min(Math.max(this.minCurrent, this.chargeCurrent), this.maxCurrent);
return (100 / this.maxCurrent) * current;
},
realWidth(num) {
if (this.chargeCurrents) {
const current = this.chargeCurrents[num - 1] || 0;
return (100 / this.maxCurrent) * current;
}
return this.targetWidth();
},
},
};
</script>
<style scoped>
.phases {
width: 68px;
}
.phase {
background-color: var(--bs-gray-200);
height: 4px;
width: 20px;
position: relative;
border-radius: 1px;
overflow: hidden;
}
.target,
.real {
position: absolute;
left: 0;
top: 0;
bottom: 0;
transition-property: width, opacity;
transition-duration: var(--evcc-transition-slow);
transition-timing-function: ease-in;
opacity: 1;
}
.target {
background-color: var(--evcc-green);
}
.real {
background-color: var(--evcc-dark-green);
}
.phase.inactive .target,
.phase.inactive .real {
opacity: 0;
}
</style>

View file

@ -0,0 +1,29 @@
import Savings from "./Savings.vue";
export default {
title: "Main/Footer/Savings",
component: Savings,
argTypes: {},
};
const Template = (args) => ({
setup() {
return { args };
},
components: { Savings },
template: '<div class="d-flex justify-content-end"><Savings v-bind="args"></Savings></div>',
});
export const Default = Template.bind({});
Default.args = {
since: 82800,
totalCharged: 15231,
selfConsumptionCharged: 12231,
selfConsumptionPercent: 80.3,
};
export const NoCharge = Template.bind({});
NoCharge.args = {
since: 82800,
totalCharged: 0,
};

View file

@ -1,7 +1,7 @@
<template> <template>
<div> <div>
<button <button
class="btn btn-link pe-0 text-decoration-none link-dark text-nowrap" class="btn btn-link pe-0 text-decoration-none text-white text-nowrap d-flex align-items-end"
data-bs-toggle="modal" data-bs-toggle="modal"
data-bs-target="#savingsModal" data-bs-target="#savingsModal"
> >
@ -10,154 +10,164 @@
}}</span }}</span
><span class="d-none d-sm-inline">{{ ><span class="d-none d-sm-inline">{{
$t("footer.savings.footerLong", { percent }) $t("footer.savings.footerLong", { percent })
}}</span }}</span>
><fa-icon icon="sun" class="icon ms-2 text-evcc"></fa-icon> <shopicon-regular-sun class="ms-2 text-evcc"></shopicon-regular-sun>
</button> </button>
<div
id="savingsModal" <Teleport to="body">
ref="modal" <div
class="modal fade" id="savingsModal"
tabindex="-1" ref="modal"
role="dialog" class="modal fade text-dark"
aria-hidden="true" data-bs-backdrop="true"
> tabindex="-1"
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable" role="document"> role="dialog"
<div class="modal-content"> aria-hidden="true"
<div class="modal-header"> >
<h5 class="modal-title"> <div
<span class="d-block d-sm-none"> class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable"
{{ role="document"
$t("footer.savings.modalTitleShort", { >
percent, <div class="modal-content">
total: fmtKw(totalCharged * 1000, true, false), <div class="modal-header">
}) <h5 class="modal-title">
}} <span class="text-nowrap">
</span>
<span class="d-none d-sm-block">
{{
$t("footer.savings.modalTitleLong", {
percent,
total: fmtKw(totalCharged * 1000, true, false),
})
}}
</span>
</h5>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
></button>
</div>
<div class="modal-body py-4">
<div class="chart-container mb-3">
<div class="chart-legend d-flex flex-wrap justify-content-between mb-1">
<div class="text-nowrap">
<fa-icon icon="square" class="text-evcc"></fa-icon>
{{ {{
$t("footer.savings.modalChartSelf", { $t("footer.savings.modalTitlePartOne", {
self: fmtKw(selfConsumptionCharged * 1000, true, false), total: fmtKw(totalCharged * 1000, true, false),
}) })
}} }}
</div> ·
<div class="text-nowrap"> </span>
<fa-icon icon="square" class="text-grid"></fa-icon> {{ " " }}
{{ <span class="text-nowrap">
$t("footer.savings.modalChartGrid", { {{ $t("footer.savings.modalTitlePartTwo", { percent }) }}
grid: fmtKw(gridCharged * 1000, true, false), </span>
}) </h5>
}} <button
</div> type="button"
</div> class="btn-close"
<div data-bs-dismiss="modal"
class="chart d-flex justify-content-stretch mb-1 rounded overflow-hidden" aria-label="Close"
> ></button>
<div
v-if="totalCharged > 0"
class="chart-item chart-item--self d-flex justify-content-center text-white flex-shrink-1"
:style="{ width: `${percent}%` }"
>
<span class="text-truncate"> {{ percent }}% </span>
</div>
<div
v-if="totalCharged > 0"
class="chart-item chart-item--grid d-flex justify-content-center text-white flex-shrink-1"
:style="{ width: `${100 - percent}%` }"
>
<span class="text-truncate"> {{ 100 - percent }}% </span>
</div>
<div
v-if="totalCharged === 0"
class="chart-item chart-item--no-data d-flex justify-content-center text-white w-100"
>
<span>{{ $t("footer.savings.modalNoData") }}</span>
</div>
</div>
</div> </div>
<p class="mb-3"> <div class="modal-body py-4">
{{ $t("footer.savings.modalSavingsPrice") }}: <div class="chart-container mb-3">
<strong>{{ fmtPricePerKWh(effectivePrice, currency) }}</strong> <div
<br /> class="chart-legend d-flex flex-wrap justify-content-between mb-1"
{{ $t("footer.savings.modalSavingsTotal") }}: >
<strong>{{ fmtMoney(amount, currency) }}</strong> <div
</p> class="text-nowrap"
:class="{ 'text-gray-medium': noData }"
>
<shopicon-filled-square
class="text-evcc d-inline-block"
:class="{ 'text-gray-medium': noData }"
></shopicon-filled-square>
{{
$t("footer.savings.modalChartSelf", {
self: fmtKw(
selfConsumptionCharged * 1000,
true,
false
),
})
}}
</div>
<div
class="text-nowrap"
:class="{ 'text-gray-medium': noData }"
>
{{
$t("footer.savings.modalChartGrid", {
grid: fmtKw(gridCharged * 1000, true, false),
})
}}
<shopicon-filled-square
class="text-grid d-inline-block"
:class="{ 'text-gray-medium': noData }"
></shopicon-filled-square>
</div>
</div>
<div
class="chart d-flex justify-content-stretch mb-1 rounded overflow-hidden"
>
<div
v-if="!noData"
class="chart-item chart-item--self d-flex justify-content-center align-items-center text-white flex-shrink-1"
:style="{ width: `${percent}%` }"
>
<span class="text-truncate"> {{ percent }}% </span>
</div>
<div
v-if="!noData"
class="chart-item chart-item--grid d-flex justify-content-center align-items-center text-white flex-shrink-1"
:style="{ width: `${100 - percent}%` }"
>
<span class="text-truncate"> {{ 100 - percent }}% </span>
</div>
<div
v-if="noData"
class="chart-item chart-item--no-data d-flex justify-content-center align-items-center text-white w-100"
>
<span>{{ $t("footer.savings.modalNoData") }}</span>
</div>
</div>
</div>
<p class="mb-3">
{{ $t("footer.savings.modalSavingsPrice") }}:
<strong>{{ fmtPricePerKWh(effectivePrice, currency) }}</strong>
<br />
{{ $t("footer.savings.modalSavingsTotal") }}:
<strong>{{ fmtMoney(amount, currency) }}</strong>
</p>
<p class="small text-muted mb-3"> <p class="small text-muted mb-3">
<a <a
href="https://docs.evcc.io/docs/guides/setup/#ersparnisberechnung" href="https://docs.evcc.io/docs/guides/setup/#ersparnisberechnung"
target="_blank" target="_blank"
class="text-muted" class="small"
> >
{{ $t("footer.savings.modalExplaination") }}</a {{ $t("footer.savings.modalExplaination") }}</a
>: >:
<span class="text-nowrap"> <span class="text-nowrap">
{{
$t("footer.savings.modalExplainationGrid", {
gridPrice: fmtPricePerKWh(gridPrice, currency),
})
}}</span
>,
<span class="text-nowrap">
{{
$t("footer.savings.modalExplainationFeedIn", {
feedInPrice: fmtPricePerKWh(feedInPrice, currency),
})
}}
</span>
<br />
{{ {{
$t("footer.savings.modalExplainationGrid", { $t("footer.savings.modalServerStart", {
gridPrice: fmtPricePerKWh(gridPrice, currency), since: fmtTimeAgo(secondsSinceStart()),
})
}}</span
>,
<span class="text-nowrap">
{{
$t("footer.savings.modalExplainationFeedIn", {
feedInPrice: fmtPricePerKWh(feedInPrice, currency),
}) })
}} }}
</span> </p>
<br />
{{
$t("footer.savings.modalServerStart", {
since: fmtTimeAgo(secondsSinceStart()),
})
}}
</p>
<hr class="mb-4" /> <hr class="mb-4" />
<Sponsor :sponsor="sponsor" class="mb-4" /> <Sponsor :sponsor="sponsor" />
</div>
<p class="small text-muted mb-0">
<strong class="text-primary">
<fa-icon icon="flask"></fa-icon>
{{ $t("footer.savings.experimentalLabel") }}:
</strong>
{{ $t("footer.savings.experimentalText") }}
<a
href="https://github.com/evcc-io/evcc/discussions/2104"
target="_blank"
>GitHub Discussions</a
>.
</p>
</div> </div>
</div> </div>
</div> </div>
</div> </Teleport>
</div> </div>
</template> </template>
<script> <script>
import formatter from "../mixins/formatter"; import formatter from "../mixins/formatter";
import Sponsor from "./Sponsor.vue"; import Sponsor from "./Sponsor.vue";
import "@h2d2/shopicons/es/filled/square";
import "@h2d2/shopicons/es/regular/sun";
export default { export default {
name: "Savings", name: "Savings",
@ -180,6 +190,9 @@ export default {
percent() { percent() {
return Math.round(this.selfConsumptionPercent) || 0; return Math.round(this.selfConsumptionPercent) || 0;
}, },
noData() {
return this.totalCharged === 0;
},
}, },
methods: { methods: {
secondsSinceStart() { secondsSinceStart() {
@ -196,7 +209,7 @@ export default {
margin-bottom: 0; margin-bottom: 0;
} }
.modal.fade .modal-dialog { .modal.fade .modal-dialog {
transition: transform 0.4s ease; transition: transform var(--evcc-transition-medium) ease;
transform: translate(0, 150px); transform: translate(0, 150px);
} }
.modal.show .modal-dialog { .modal.show .modal-dialog {
@ -206,12 +219,13 @@ export default {
height: calc(100% - 0.5rem); height: calc(100% - 0.5rem);
} }
.modal-content { .modal-content {
border-radius: 1rem 1rem 0 0; border-end-end-radius: 0;
border-end-start-radius: 0;
} }
} }
.chart { .chart {
height: 1.6rem; height: 2.5rem;
} }
.chart-item--self { .chart-item--self {
@ -221,12 +235,12 @@ export default {
background-color: var(--evcc-grid); background-color: var(--evcc-grid);
} }
.chart-item--no-data { .chart-item--no-data {
background-color: var(--bs-gray); background-color: var(--bs-gray-medium);
} }
.chart-item { .chart-item {
transition-property: width; transition-property: width;
transition-duration: 500ms; transition-duration: var(--evcc-transition-medium);
transition-timing-function: linear; transition-timing-function: linear;
} }
</style> </style>

View file

@ -1,5 +1,4 @@
import Site from "./Site.vue"; import Site from "./Site.vue";
import i18n from "../i18n";
export default { export default {
title: "Main/Site", title: "Main/Site",
@ -7,11 +6,12 @@ export default {
argTypes: {}, argTypes: {},
}; };
const Template = (args, { argTypes }) => ({ const Template = (args) => ({
i18n, setup() {
props: Object.keys(argTypes), return { args };
},
components: { Site }, components: { Site },
template: '<Site v-bind="$props"></Site>', template: '<Site v-bind="args"></Site>',
}); });
export const Base = Template.bind({}); export const Base = Template.bind({});

View file

@ -1,37 +1,45 @@
<template> <template>
<div class="flex-grow-1 d-flex flex-column"> <div class="d-flex flex-column site">
<h3 class="d-none d-md-block my-4"> <div class="container px-4 top-area">
{{ siteTitle || "Home" }} <div class="d-flex justify-content-between align-items-center my-3">
</h3> <h1 class="d-block my-0">
<Energyflow v-bind="energyflow" /> {{ siteTitle || "evcc" }}
<hr class="w-100 my-4" /> </h1>
<div class="flex-grow-1 d-flex justify-content-around flex-column"> <div class="d-flex">
<template v-for="(loadpoint, id) in loadpoints"> <Notifications :notifications="notifications" class="me-2" />
<hr v-if="id > 0" :key="id + '_hr'" class="w-100 my-4" /> <TopNavigation v-bind="topNavigation" />
<Loadpoint </div>
v-bind="loadpoint" </div>
:id="id" <Energyflow v-bind="energyflow" />
:key="id" </div>
:single="loadpoints.length === 1" <div class="d-flex flex-column justify-content-between content-area pt-4">
/> <Loadpoints class="mt-1 mt-sm-2 flex-grow-1" :loadpoints="loadpoints" />
</template> <Vehicles v-if="$hiddenFeatures" />
<Footer v-bind="footer"></Footer>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import Energyflow from "./Energyflow"; import "@h2d2/shopicons/es/regular/arrowup";
import Loadpoint from "./Loadpoint"; import TopNavigation from "./TopNavigation.vue";
import Notifications from "./Notifications.vue";
import Energyflow from "./Energyflow/Energyflow.vue";
import Loadpoints from "./Loadpoints.vue";
import Vehicles from "./Vehicles.vue";
import Footer from "./Footer.vue";
import formatter from "../mixins/formatter"; import formatter from "../mixins/formatter";
import collector from "../mixins/collector"; import collector from "../mixins/collector";
export default { export default {
name: "Site", name: "Site",
components: { Loadpoint, Energyflow }, components: { Loadpoints, Energyflow, Footer, Notifications, TopNavigation, Vehicles },
mixins: [formatter, collector], mixins: [formatter, collector],
props: { props: {
loadpoints: Array, loadpoints: Array,
notifications: Array,
// details // details
gridConfigured: Boolean, gridConfigured: Boolean,
gridPower: Number, gridPower: Number,
@ -44,6 +52,30 @@ export default {
gridCurrents: Array, gridCurrents: Array,
prioritySoC: Number, prioritySoC: Number,
siteTitle: String, siteTitle: String,
auth: Object,
// footer
currency: String,
savingsAmount: Number,
savingsEffectivePrice: Number,
savingsGridCharged: Number,
savingsSelfConsumptionCharged: Number,
savingsSelfConsumptionPercent: Number,
savingsSince: Number,
savingsTotalCharged: Number,
tariffFeedIn: Number,
tariffGrid: Number,
},
data: function () {
return {
availableVersion: null,
releaseNotes: null,
hasUpdater: null,
uploadMessage: null,
uploadProgress: null,
sponsor: null,
};
}, },
computed: { computed: {
energyflow: function () { energyflow: function () {
@ -58,6 +90,46 @@ export default {
return sum; return sum;
}, 0); }, 0);
}, },
topNavigation: function () {
const vehicleLogins = this.auth ? this.auth.vehicles : {};
return { vehicleLogins };
},
footer: function () {
return {
version: {
installed: window.evcc.version,
available: this.availableVersion,
releaseNotes: this.releaseNotes,
hasUpdater: this.hasUpdater,
uploadMessage: this.uploadMessage,
uploadProgress: this.uploadProgress,
},
sponsor: this.sponsor,
savings: {
since: this.savingsSince,
totalCharged: this.savingsTotalCharged,
gridCharged: this.savingsGridCharged,
selfConsumptionCharged: this.savingsSelfConsumptionCharged,
amount: this.savingsAmount,
effectivePrice: this.savingsEffectivePrice,
selfConsumptionPercent: this.savingsSelfConsumptionPercent,
gridPrice: this.tariffGrid,
feedInPrice: this.tariffFeedIn,
currency: this.currency,
},
};
},
}, },
}; };
</script> </script>
<style scoped>
.site {
min-height: 100vh;
}
.content-area {
background-color: var(--bs-gray-dark);
color: var(--bs-white);
flex-grow: 1;
z-index: 1;
}
</style>

View file

@ -1,25 +0,0 @@
import Sponsor from "./Sponsor.vue";
import i18n from "../i18n";
export default {
title: "Main/Footer/Sponsor",
component: Sponsor,
argTypes: {},
};
const Template = (args, { argTypes }) => ({
i18n,
props: Object.keys(argTypes),
components: { Sponsor },
template: '<Sponsor v-bind="$props"></Sponsor>',
});
export const Standard = Template.bind({});
Standard.args = {
sponsor: undefined,
};
export const IsSponsor = Template.bind({});
IsSponsor.args = {
sponsor: "naltatis",
};

View file

@ -1,18 +1,18 @@
<template> <template>
<div v-if="sponsor"> <div v-if="sponsor || true">
<p class="mb-3"> <p class="mb-3">
{{ $t("footer.sponsor.thanks", { sponsor }) }} {{ $t("footer.sponsor.thanks", { sponsor }) }}
</p> </p>
<div class="d-flex justify-content-center flex-column"> <div class="d-flex justify-content-center align-items-center flex-column">
<button <button
ref="confetti" ref="confetti"
class="btn btn-primary mb-2 confetti-button bg-evcc" class="btn btn btn-outline-primary mb-2 confetti-button bg-evcc w-75 rounded"
@click="surprise" @click="surprise"
> >
<fa-icon :icon="['fas', 'heart']" class="icon me-1 solid"></fa-icon> <shopicon-regular-heart class="me-1 d-inline-block"></shopicon-regular-heart>
{{ $t("footer.sponsor.confetti") }} {{ $t("footer.sponsor.confetti") }}
</button> </button>
<a href="https://evcc.io/sticker" target="_blank" class="small text-muted text-center"> <a href="https://evcc.io/sticker" target="_blank" class="small text-muted">
{{ $t("footer.sponsor.sticker") }} {{ $t("footer.sponsor.sticker") }}
</a> </a>
</div> </div>
@ -21,13 +21,13 @@
<p class="mb-3"> <p class="mb-3">
{{ $t("footer.sponsor.supportUs") }} {{ $t("footer.sponsor.supportUs") }}
</p> </p>
<div class="d-flex justify-content-center flex-column"> <div class="d-flex justify-content-center align-items-center flex-column">
<a <a
target="_blank" target="_blank"
href="https://github.com/sponsors/andig" href="https://github.com/sponsors/andig"
class="btn btn-outline-primary mb-2" class="btn btn-outline-primary mb-2 w-75"
> >
<fa-icon :icon="['far', 'heart']" class="icon me-1"></fa-icon> <shopicon-regular-heart class="me-1 d-inline-block"></shopicon-regular-heart>
{{ $t("footer.sponsor.becomeSponsor") }} {{ $t("footer.sponsor.becomeSponsor") }}
</a> </a>
<div class="small text-muted text-center"> <div class="small text-muted text-center">
@ -39,6 +39,8 @@
<script> <script>
import confetti from "canvas-confetti"; import confetti from "canvas-confetti";
import "@h2d2/shopicons/es/regular/heart";
import "@h2d2/shopicons/es/filled/heart";
export default { export default {
name: "Sponsor", name: "Sponsor",

View file

@ -1,111 +1,123 @@
<template> <template>
<div> <div>
<button <LabelAndValue class="flex-grow-1" :label="$t('main.targetCharge.title')">
class="target-time-button btn btn-link btn-sm pe-0" <button
:class="{ class="btn btn-link p-0 value text-end"
invisible: !targetSoC, :class="buttonIsEnabled ? 'text-white' : 'text-gray'"
'text-dark': targetTimeActive, data-bs-toggle="modal"
'text-secondary': !targetTimeActive, :data-bs-target="`#${modalId}`"
}" >
data-bs-toggle="modal" <strong v-if="targetChargeEnabled">{{ targetTimeLabel() }}</strong>
:data-bs-target="`#${modalId}`" <span v-else>{{ $t("main.targetCharge.setTargetTime") }}</span>
> </button>
{{ targetTimeLabel() }}<fa-icon class="ms-1" icon="clock"></fa-icon> </LabelAndValue>
</button>
<div :id="modalId" class="modal fade" tabindex="-1" role="dialog" aria-hidden="true"> <Teleport to="body">
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable" role="document"> <div
<div class="modal-content"> :id="modalId"
<div class="modal-header"> class="modal fade text-dark"
<h5 class="modal-title"> data-bs-backdrop="true"
{{ $t("main.targetCharge.modalTitle") }} tabindex="-1"
</h5> role="dialog"
<button aria-hidden="true"
type="button" >
class="btn-close" <div
data-bs-dismiss="modal" class="modal-dialog modal-dialog-centered modal-dialog-scrollable"
aria-label="Close" role="document"
></button> >
</div> <div class="modal-content">
<form @submit.prevent="setTargetTime"> <div class="modal-header">
<div class="modal-body"> <h5 class="modal-title">
<div class="form-group"> {{ $t("main.targetCharge.modalTitle") }}
<!-- eslint-disable vue/no-v-html --> </h5>
<label
for="targetTimeLabel"
class="mb-3"
v-html="$t('main.targetCharge.description', { targetSoC })"
>
</label>
<!-- eslint-enable vue/no-v-html -->
<div
class="d-flex justify-content-between"
:style="{ 'max-width': '350px' }"
>
<select
v-model="selectedDay"
class="form-select me-2"
:style="{ 'flex-basis': '60%' }"
>
<option
v-for="opt in dayOptions()"
:key="opt.value"
:value="opt.value"
>
{{ opt.name }}
</option>
</select>
<input
v-model="selectedTime"
type="time"
class="form-control ms-2"
:style="{ 'flex-basis': '40%' }"
:step="60 * 5"
required
/>
</div>
</div>
<p v-if="!selectedTargetTimeValid" class="text-danger mb-0">
{{ $t("main.targetCharge.targetIsInThePast") }}
</p>
<p class="small mt-3 text-muted">
<strong class="text-primary">
<fa-icon icon="flask"></fa-icon>
{{ $t("main.targetCharge.experimentalLabel") }}:
</strong>
{{ $t("main.targetCharge.experimentalText") }}
<a
href="https://github.com/evcc-io/evcc/discussions/1433"
target="_blank"
>GitHub Discussions</a
>.
</p>
</div>
<div class="modal-footer d-flex justify-content-between">
<button <button
type="button" type="button"
class="btn btn-outline-secondary" class="btn-close"
data-bs-dismiss="modal" data-bs-dismiss="modal"
@click="removeTargetTime" aria-label="Close"
> ></button>
{{ $t("main.targetCharge.remove") }}
</button>
<button
type="submit"
class="btn btn-primary"
data-bs-dismiss="modal"
:disabled="!selectedTargetTimeValid"
>
{{ $t("main.targetCharge.activate") }}
</button>
</div> </div>
</form> <form @submit.prevent="setTargetTime">
<div class="modal-body">
<div class="form-group">
<!-- eslint-disable vue/no-v-html -->
<label for="targetTimeLabel" class="mb-3">
{{ $t("main.targetCharge.description", { targetSoC }) }}
</label>
<!-- eslint-enable vue/no-v-html -->
<div
class="d-flex justify-content-between"
:style="{ 'max-width': '350px' }"
>
<select
v-model="selectedDay"
class="form-select me-2"
:style="{ 'flex-basis': '60%' }"
>
<option
v-for="opt in dayOptions()"
:key="opt.value"
:value="opt.value"
>
{{ opt.name }}
</option>
</select>
<input
v-model="selectedTime"
type="time"
class="form-control ms-2"
:style="{ 'flex-basis': '40%' }"
:step="60 * 5"
required
/>
</div>
</div>
<p v-if="!selectedTargetTimeValid" class="text-danger mb-0">
{{ $t("main.targetCharge.targetIsInThePast") }}
</p>
<p class="small mt-3 text-muted mb-0">
<strong class="text-evcc">
{{ $t("main.targetCharge.experimentalLabel") }}:
</strong>
{{ $t("main.targetCharge.experimentalText") }}
<a
href="https://github.com/evcc-io/evcc/discussions/1433"
target="_blank"
>GitHub Discussions</a
>.
</p>
</div>
<div class="modal-footer d-flex justify-content-between">
<button
type="button"
class="btn btn-outline-secondary"
data-bs-dismiss="modal"
@click="removeTargetTime"
>
{{ $t("main.targetCharge.remove") }}
</button>
<button
type="submit"
class="btn btn-primary"
data-bs-dismiss="modal"
:disabled="!selectedTargetTimeValid"
>
{{ $t("main.targetCharge.activate") }}
</button>
</div>
</form>
</div>
</div> </div>
</div> </div>
</div> </Teleport>
</div> </div>
</template> </template>
<script> <script>
import "@h2d2/shopicons/es/filled/plus";
import "@h2d2/shopicons/es/filled/edit";
import LabelAndValue from "./LabelAndValue.vue";
import formatter from "../mixins/formatter"; import formatter from "../mixins/formatter";
const DEFAULT_TARGET_TIME = "7:00"; const DEFAULT_TARGET_TIME = "7:00";
@ -113,13 +125,16 @@ const LAST_TARGET_TIME_KEY = "last_target_time";
export default { export default {
name: "TargetCharge", name: "TargetCharge",
components: { LabelAndValue },
mixins: [formatter], mixins: [formatter],
props: { props: {
id: Number, id: [String, Number],
targetTime: String, targetTime: String,
targetTimeActive: Boolean, targetTimeActive: Boolean,
targetSoC: Number, targetSoC: Number,
disabled: Boolean,
}, },
emits: ["target-time-updated", "target-time-removed"],
data: function () { data: function () {
return { selectedDay: null, selectedTime: null }; return { selectedDay: null, selectedTime: null };
}, },
@ -127,6 +142,9 @@ export default {
targetChargeEnabled: function () { targetChargeEnabled: function () {
return this.targetTime; return this.targetTime;
}, },
buttonIsEnabled: function () {
return this.targetChargeEnabled && !this.disabled;
},
selectedTargetTimeValid: function () { selectedTargetTimeValid: function () {
const now = new Date(); const now = new Date();
return now < this.selectedTargetTime; return now < this.selectedTargetTime;
@ -139,6 +157,12 @@ export default {
}, },
}, },
watch: { watch: {
targetTimeLabel: function () {
const targetDate = new Date(this.targetTime);
return this.$t("main.targetCharge.activeLabel", {
time: this.fmtAbsoluteDate(targetDate),
});
},
targetTime() { targetTime() {
this.initInputFields(); this.initInputFields();
}, },
@ -223,3 +247,15 @@ export default {
}, },
}; };
</script> </script>
<style scoped>
.value {
font-size: 18px;
line-height: 1.2;
border: none;
transition: color var(--evcc-transition-medium) linear;
}
.value:hover {
color: var(--bs-color-white);
}
</style>

View file

@ -0,0 +1,53 @@
import "bootstrap";
import TopNavigation from "./TopNavigation.vue";
export default {
title: "Main/TopNavigation",
component: TopNavigation,
parameters: {
backgrounds: {
default: "light",
},
},
};
const Template = (args) => ({
setup() {
return { args };
},
components: { TopNavigation },
template: `<div class="d-flex justify-content-end" style="padding-bottom: 18rem">
<TopNavigation v-bind="args"></TopNavigation>
</div>`,
});
export const Default = Template.bind({});
Default.args = {};
export const VehicleLogins = Template.bind({});
VehicleLogins.args = {
vehicleLogins: {
["Mercedes EQS"]: {
authenticated: true,
uri: "https://login-provider-a.test/",
},
["Nissan Leaf Pro"]: {
authenticated: false,
uri: "https://login-provider-b.test/",
},
},
};
export const PendingVehicleLogins = Template.bind({});
PendingVehicleLogins.args = {
vehicleLogins: {
["Mercedes EQS"]: {
authenticated: true,
uri: "https://login-provider-a.test/",
},
["Nissan Leaf Pro"]: {
authenticated: true,
uri: "https://login-provider-b.test/",
},
},
};

View file

@ -0,0 +1,104 @@
<template>
<div>
<button
type="button"
data-bs-toggle="dropdown"
data-bs-target="#navbarNavAltMarkup"
aria-controls="navbarNavAltMarkup"
aria-expanded="false"
aria-label="Toggle navigation"
class="btn btn-sm btn-outline-secondary position-relative"
>
<span
v-if="logoutCount > 0"
class="position-absolute top-0 start-100 translate-middle p-2 bg-danger border border-light rounded-circle"
>
<span class="visually-hidden">login available</span>
</span>
<shopicon-regular-menu></shopicon-regular-menu>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<a class="dropdown-item" href="https://docs.evcc.io/blog/" target="_blank">
{{ $t("header.blog") }}
</a>
</li>
<li>
<a class="dropdown-item" href="https://docs.evcc.io/docs/Home/" target="_blank">
{{ $t("header.docs") }}
</a>
</li>
<li>
<a class="dropdown-item" href="https://github.com/evcc-io/evcc" target="_blank">
{{ $t("header.github") }}
</a>
</li>
<li>
<a class="dropdown-item" href="https://evcc.io/" target="_blank">
{{ $t("header.about") }}
</a>
</li>
<template v-if="providerLogins.length > 0">
<li><hr class="dropdown-divider" /></li>
<li>
<h6 class="dropdown-header">{{ $t("header.login") }}</h6>
</li>
<li v-for="login in providerLogins" :key="login.title">
<button
type="button"
class="dropdown-item"
@click="handleProviderAuthorization(login)"
>
<span
v-if="!login.loggedIn"
class="d-inline-block p-1 rounded-circle bg-danger border border-light rounded-circle"
></span>
{{ login.title }}
{{ $t(login.loggedIn ? "main.provider.logout" : "main.provider.login") }}
</button>
</li>
</template>
</ul>
</div>
</template>
<script>
import "@h2d2/shopicons/es/regular/menu";
import baseAPI from "../baseapi";
export default {
name: "TopNavigation",
props: {
vehicleLogins: {
type: Object,
default: () => {
return {};
},
},
},
computed: {
logoutCount() {
return this.providerLogins.filter((login) => !login.loggedIn).length;
},
providerLogins() {
return Object.entries(this.vehicleLogins).map(([k, v]) => ({
title: k,
loggedIn: v.authenticated,
loginPath: v.uri + "/login",
logoutPath: v.uri + "/logout",
}));
},
},
methods: {
handleProviderAuthorization: async function (provider) {
if (!provider.loggedIn) {
baseAPI.post(provider.loginPath).then(function (response) {
window.location.href = response.data.loginUri;
});
} else {
baseAPI.post(provider.logoutPath);
}
},
},
};
</script>

View file

@ -1,16 +1,16 @@
import Vehicle from "./Vehicle.vue"; import Vehicle from "./Vehicle.vue";
import i18n from "../i18n";
export default { export default {
title: "Main/Vehicle", title: "Main/Vehicle",
component: Vehicle, component: Vehicle,
}; };
const Template = (args, { argTypes }) => ({ const Template = (args) => ({
i18n, setup() {
props: Object.keys(argTypes), return { args };
},
components: { Vehicle }, components: { Vehicle },
template: '<Vehicle v-bind="$props"></Vehicle>', template: '<Vehicle v-bind="args"></Vehicle>',
}); });
export const Base = Template.bind({}); export const Base = Template.bind({});
@ -20,6 +20,7 @@ Base.args = {
connected: true, connected: true,
vehiclePresent: true, vehiclePresent: true,
vehicleSoC: 42, vehicleSoC: 42,
vehicleRange: 231,
targetSoC: 90, targetSoC: 90,
id: 0, id: 0,
}; };
@ -58,7 +59,6 @@ Charging.args = {
vehicleSoC: 66, vehicleSoC: 66,
targetSoC: 90, targetSoC: 90,
id: 0, id: 0,
targetTimeHourSuggestion: null,
}; };
const hoursFromNow = function (hours) { const hoursFromNow = function (hours) {
@ -110,6 +110,38 @@ MinCharge.args = {
id: 0, id: 0,
}; };
export const TimerPvEnable = Template.bind({});
TimerPvEnable.args = {
vehicleTitle: "Mein Auto",
enabled: false,
connected: true,
vehiclePresent: true,
vehicleSoC: 17,
charging: false,
targetSoC: 90,
phaseAction: "inactive",
phaseRemainingInterpolated: 0,
pvAction: "enable",
pvRemainingInterpolated: 32,
id: 0,
};
export const TimerPvDisable = Template.bind({});
TimerPvDisable.args = {
vehicleTitle: "Mein Auto",
enabled: true,
connected: true,
vehiclePresent: true,
vehicleSoC: 17,
charging: true,
targetSoC: 90,
phaseAction: "inactive",
phaseRemainingInterpolated: 0,
pvAction: "disable",
pvRemainingInterpolated: 155,
id: 0,
};
export const UnknownVehicleConnected = Template.bind({}); export const UnknownVehicleConnected = Template.bind({});
UnknownVehicleConnected.args = { UnknownVehicleConnected.args = {
vehicleTitle: "Mein Auto", vehicleTitle: "Mein Auto",

View file

@ -1,52 +1,118 @@
<template> <template>
<div> <div class="vehicle p-4 pb-3">
<div class="mb-3"> <VehicleTitle v-bind="vehicleTitleProps" />
{{ vehicleTitle || $t("main.vehicle.fallbackName") }} <VehicleStatus v-if="!parked" v-bind="vehicleStatus" class="mb-2" />
</div> <VehicleSoc
<VehicleSoc v-bind="vehicleSocProps" @target-soc-updated="targetSocUpdated" /> v-bind="vehicleSocProps"
<VehicleSubline class="mt-2 mb-4"
v-bind="vehicleSubline" @target-soc-updated="targetSocUpdated"
class="my-1" @target-soc-drag="targetSocDrag"
@target-time-updated="setTargetTime"
@target-time-removed="removeTargetTime"
/> />
<div v-if="vehiclePresent">
<div class="details d-flex flex-wrap justify-content-between">
<LabelAndValue
class="flex-grow-1 text-start"
:label="$t('main.vehicle.vehicleSoC')"
:value="`${vehicleSoC || '--'} %`"
:extraValue="vehicleRange ? `${vehicleRange} km` : null"
/>
<LabelAndValue
class="flex-grow-1 text-center"
:label="$t('main.vehicle.targetSoC')"
:value="`${displayTargetSoC} %`"
:extraValue="estimatedTargetRange"
/>
<TargetCharge
class="flex-grow-1 text-end target-charge"
v-bind="targetCharge"
:disabled="targetChargeDisabled"
@target-time-updated="setTargetTime"
@target-time-removed="removeTargetTime"
/>
</div>
<div v-if="$hiddenFeatures" class="d-flex justify-content-start">
<small>vor 5 Stunden</small>
</div>
</div>
</div> </div>
</template> </template>
<script> <script>
import collector from "../mixins/collector"; import collector from "../mixins/collector";
import LabelAndValue from "./LabelAndValue.vue";
import VehicleSoc from "./VehicleSoc"; import VehicleTitle from "./VehicleTitle.vue";
import VehicleSubline from "./VehicleSubline"; import VehicleSoc from "./VehicleSoc.vue";
import VehicleStatus from "./VehicleStatus.vue";
import TargetCharge from "./TargetCharge.vue";
export default { export default {
name: "Vehicle", name: "Vehicle",
components: { VehicleSoc, VehicleSubline }, components: { VehicleTitle, VehicleSoc, VehicleStatus, LabelAndValue, TargetCharge },
mixins: [collector], mixins: [collector],
props: { props: {
id: Number, id: [String, Number],
connected: Boolean, connected: Boolean,
vehiclePresent: Boolean, vehiclePresent: Boolean,
vehicleSoC: Number, vehicleSoC: Number,
enabled: Boolean, enabled: Boolean,
charging: Boolean, charging: Boolean,
minSoC: Number, minSoC: Number,
vehicleRange: Number,
vehicleTitle: String, vehicleTitle: String,
targetTimeActive: Boolean, targetTimeActive: Boolean,
targetTimeHourSuggestion: Number,
targetTime: String, targetTime: String,
targetTimeProjectedStart: String,
targetSoC: Number, targetSoC: Number,
mode: String,
phaseAction: String,
phaseRemainingInterpolated: Number,
pvAction: String,
pvRemainingInterpolated: Number,
parked: Boolean,
},
emits: ["target-time-removed", "target-time-updated", "target-soc-updated"],
data() {
return {
displayTargetSoC: this.targetSoC,
};
}, },
computed: { computed: {
vehicleSocProps: function () { vehicleSocProps: function () {
return this.collectProps(VehicleSoc); return this.collectProps(VehicleSoc);
}, },
vehicleSubline: function () { vehicleStatus: function () {
return this.collectProps(VehicleSubline); return this.collectProps(VehicleStatus);
},
vehicleTitleProps: function () {
return this.collectProps(VehicleTitle);
},
targetCharge: function () {
return this.collectProps(TargetCharge);
},
estimatedTargetRange: function () {
if (this.vehicleSoC > 10 && this.vehicleRange && this.displayTargetSoC) {
return (
Math.round(this.displayTargetSoC * (this.vehicleRange / this.vehicleSoC)) +
" km"
);
}
return null;
},
targetChargeDisabled: function () {
return !this.connected || !["pv", "minpv"].includes(this.mode);
},
},
watch: {
targetSoC: function () {
this.displayTargetSoC = this.targetSoC;
}, },
}, },
methods: { methods: {
targetSocDrag: function (targetSoC) {
this.displayTargetSoC = targetSoC;
},
targetSocUpdated: function (targetSoC) { targetSocUpdated: function (targetSoC) {
this.displayTargetSoC = targetSoC;
this.$emit("target-soc-updated", targetSoC); this.$emit("target-soc-updated", targetSoC);
}, },
setTargetTime: function (targetTime) { setTargetTime: function (targetTime) {
@ -58,3 +124,18 @@ export default {
}, },
}; };
</script> </script>
<style scoped>
.vehicle {
background-color: var(--bs-gray-dark);
border-radius: 1rem;
color: var(--bs-white);
}
.car-icon {
width: 1.75rem;
}
.details > div {
flex-grow: 1;
flex-basis: 0;
}
</style>

View file

@ -2,39 +2,31 @@
<div class="vehicle-soc"> <div class="vehicle-soc">
<div class="progress"> <div class="progress">
<div <div
v-if="connected || parked"
class="progress-bar" class="progress-bar"
role="progressbar" role="progressbar"
:class="{ :class="{
[progressColor]: true,
'progress-bar-striped': charging, 'progress-bar-striped': charging,
'progress-bar-animated': charging, 'progress-bar-animated': charging,
[progressColor]: true,
}" }"
:style="{ width: `${vehicleSoCDisplayWidth}%` }" :style="{ width: `${vehicleSoCDisplayWidth}%` }"
> ></div>
{{ vehicleSoCDisplayValue }}
</div>
<div <div
v-if="remainingSoCWidth > 0 && enabled" v-if="remainingSoCWidth > 0 && enabled && connected"
class="progress-bar" class="progress-bar bg-muted"
role="progressbar" role="progressbar"
:class="{ :class="progressColor"
[progressColor]: true,
'bg-muted': true,
}"
:style="{ width: `${remainingSoCWidth}%`, transition: 'none' }" :style="{ width: `${remainingSoCWidth}%`, transition: 'none' }"
></div> ></div>
</div> </div>
<div <div class="target">
class="target"
:class="{ 'target--slider-hidden': allowSliderHiding && visibleTargetSoC === 100 }"
>
<div <div
class="target-label d-flex align-items-center justify-content-center" class="target-label d-flex align-items-center justify-content-center"
:style="{ left: `${visibleTargetSoC}%` }" :style="{ left: `${visibleTargetSoC}%` }"
> ></div>
{{ visibleTargetSoC }}%
</div>
<input <input
v-if="vehiclePresent && (connected || parked)"
type="range" type="range"
min="0" min="0"
max="100" max="100"
@ -62,11 +54,12 @@ export default {
charging: Boolean, charging: Boolean,
minSoC: Number, minSoC: Number,
targetSoC: Number, targetSoC: Number,
parked: Boolean,
}, },
emits: ["target-soc-drag", "target-soc-updated"],
data: function () { data: function () {
return { return {
selectedTargetSoC: null, selectedTargetSoC: null,
allowSliderHiding: false,
interactionStartScreenY: null, interactionStartScreenY: null,
}; };
}, },
@ -77,31 +70,7 @@ export default {
} }
return 100; return 100;
}, },
vehicleSoCDisplayValue: function () {
// no soc or no soc value
if (!this.vehiclePresent || !this.vehicleSoC || this.vehicleSoC < 0) {
let chargeStatus = this.$t("main.vehicleSoC.disconnected");
if (this.charging) {
chargeStatus = this.$t("main.vehicleSoC.charging");
} else if (this.enabled) {
chargeStatus = this.$t("main.vehicleSoC.ready");
} else if (this.connected) {
chargeStatus = this.$t("main.vehicleSoC.connected");
}
return chargeStatus;
}
// percent value if enough space
let vehicleSoC = this.vehicleSoC;
if (vehicleSoC >= 10) {
vehicleSoC += "%";
}
return vehicleSoC;
},
progressColor: function () { progressColor: function () {
if (!this.connected) {
return "bg-light border";
}
if (this.minSoCActive) { if (this.minSoCActive) {
return "bg-danger"; return "bg-danger";
} }
@ -131,11 +100,6 @@ export default {
this.selectedTargetSoC = this.targetSoC; this.selectedTargetSoC = this.targetSoC;
}, },
}, },
mounted: function () {
setTimeout(() => {
this.allowSliderHiding = true;
}, 1000);
},
methods: { methods: {
changeTargetSoCStart: function (e) { changeTargetSoCStart: function (e) {
const screenY = e.screenY || e.changedTouches[0].screenY; const screenY = e.screenY || e.changedTouches[0].screenY;
@ -165,6 +129,8 @@ export default {
return false; return false;
} }
this.selectedTargetSoC = e.target.value; this.selectedTargetSoC = e.target.value;
this.$emit("target-soc-drag", this.selectedTargetSoC);
return true; return true;
}, },
}, },
@ -172,10 +138,9 @@ export default {
</script> </script>
<style scoped> <style scoped>
.vehicle-soc { .vehicle-soc {
--height: 38px; --height: 32px;
--thumb-overlap: 3px; --thumb-overlap: 6px;
--thumb-width: 3px; --thumb-width: 12px;
--thumb-horizontal-padding: 15px;
--label-height: 26px; --label-height: 26px;
position: relative; position: relative;
height: var(--height); height: var(--height);
@ -199,16 +164,13 @@ export default {
text-align: center; text-align: center;
color: var(--bs-gray-dark); color: var(--bs-gray-dark);
font-size: 1rem; font-size: 1rem;
opacity: 1;
transition: opacity 0.2s ease 1s;
} }
.target-slider { .target-slider {
-webkit-appearance: none; -webkit-appearance: none;
position: absolute; position: absolute;
top: calc(var(--thumb-overlap) * -1); top: calc(var(--thumb-overlap) * -1);
left: calc(var(--thumb-horizontal-padding) * -1);
height: calc(100% + 2 * var(--thumb-overlap)); height: calc(100% + 2 * var(--thumb-overlap));
width: calc(100% + 2 * var(--thumb-horizontal-padding)); width: 100%;
background: transparent; background: transparent;
} }
.target-slider:focus { .target-slider:focus {
@ -231,50 +193,25 @@ export default {
.target-slider::-webkit-slider-thumb { .target-slider::-webkit-slider-thumb {
-webkit-appearance: none; -webkit-appearance: none;
position: relative; position: relative;
top: calc(var(--label-height) * -1); margin-left: var(--thumb-width) / 2;
height: 100%; height: 100%;
width: var(--thumb-width); width: var(--thumb-width);
padding: var(--label-height) var(--thumb-horizontal-padding) 0; background-color: var(--evcc-dark-green);
box-sizing: content-box;
background-clip: content-box;
background-color: var(--bs-gray-dark);
cursor: grab; cursor: grab;
border: none; border: none;
opacity: 1; opacity: 1;
transition: opacity 0.2s ease 1s; border-radius: var(--thumb-overlap);
box-shadow: none; box-shadow: 0 0 6px var(--bs-gray-dark);
} }
.target-slider::-moz-range-thumb { .target-slider::-moz-range-thumb {
position: relative; position: relative;
top: calc(var(--label-height) * -1);
height: 100%; height: 100%;
width: var(--thumb-width); width: var(--thumb-width);
padding: 0 var(--thumb-horizontal-padding) 0; background-color: var(--evcc-dark-green);
box-sizing: content-box;
background-clip: content-box;
background-color: var(--bs-gray-dark);
cursor: grab; cursor: grab;
border: none; border: none;
opacity: 1; opacity: 1;
transition: opacity 0.5s ease 1s; border-radius: var(--thumb-overlap);
} box-shadow: 0 0 6px var(--bs-gray-dark);
/* auto-hide targetSoC marker at 100% */
.target--slider-hidden .target-slider::-webkit-slider-thumb,
.target--slider-hidden .target-label {
opacity: 0;
}
.target--slider-hidden .target-slider::-moz-range-thumb,
.target--slider-hidden .target-label {
opacity: 0;
}
.target:hover .target-slider::-webkit-slider-thumb,
.target:hover .target-label {
opacity: 1;
transition-delay: 0s;
}
.target:hover .target-slider::-moz-range-thumb,
.target:hover .target-label {
opacity: 1;
transition-delay: 0s;
} }
</style> </style>

View file

@ -0,0 +1,97 @@
<template>
<div class="d-block text-gray">{{ message }}&nbsp;</div>
</template>
<script>
import collector from "../mixins/collector";
import formatter from "../mixins/formatter";
export default {
name: "VehicleStatus",
mixins: [collector, formatter],
props: {
vehicleSoC: Number,
minSoC: Number,
enabled: Boolean,
connected: Boolean,
charging: Boolean,
targetTime: String,
targetTimeProjectedStart: String,
phaseAction: String,
phaseRemainingInterpolated: Number,
pvAction: String,
pvRemainingInterpolated: Number,
},
computed: {
phaseTimerActive() {
return (
this.charging &&
this.phaseRemainingInterpolated > 0 &&
["scale1p", "scale3p"].includes(this.phaseAction)
);
},
pvTimerActive() {
return (
this.pvRemainingInterpolated > 0 && ["enable", "disable"].includes(this.pvAction)
);
},
message: function () {
const t = (key, data) => {
return this.$t(`main.vehicleStatus.${key}`, data);
};
if (!this.connected) {
return t("disconnected");
}
// min charge active
if (this.minSoC > 0 && this.vehicleSoC < this.minSoC) {
return t("minCharge", { soc: this.minSoC });
}
// target charage
if (this.targetTime) {
if (this.charging) {
return t("targetChargeActive");
}
if (this.enabled) {
return t("targetChargeWaitForVehicle");
}
if (this.targetTimeProjectedStart) {
return t("targetChargePlanned", {
time: this.fmtAbsoluteDate(new Date(this.targetTimeProjectedStart)),
});
}
}
if (this.pvTimerActive && !this.enabled && this.pvAction === "enable") {
return t("pvEnable", {
remaining: this.fmtShortDuration(this.pvRemainingInterpolated, true),
});
}
if (this.enabled && !this.charging) {
return t("waitForVehicle");
}
if (this.pvTimerActive && this.charging && this.pvAction === "disable") {
return t("pvDisable", {
remaining: this.fmtShortDuration(this.pvRemainingInterpolated, true),
});
}
if (this.phaseTimerActive) {
return t(this.phaseAction, {
remaining: this.fmtShortDuration(this.phaseRemainingInterpolated, true),
});
}
if (this.charging) {
return t("charging");
}
return t("connected");
},
},
};
</script>

View file

@ -1,51 +0,0 @@
<template>
<div class="d-flex justify-content-between align-items-center">
<small class="text-secondary">
<span v-if="minSoCActive">
<fa-icon class="text-muted me-1" icon="exclamation-circle"></fa-icon>
{{ $t("main.vehicleSubline.mincharge", { soc: minSoC }) }}
</span>
</small>
<TargetCharge
v-bind="targetCharge"
@target-time-updated="setTargetTime"
@target-time-removed="removeTargetTime"
/>
</div>
</template>
<script>
import collector from "../mixins/collector";
import TargetCharge from "./TargetCharge.vue";
export default {
name: "VehicleSubline",
components: { TargetCharge },
mixins: [collector],
props: {
id: Number,
vehicleSoC: Number,
minSoC: Number,
targetTime: String,
targetTimeActive: Boolean,
targetTimeHourSuggestion: Number,
targetSoC: Number,
},
computed: {
minSoCActive: function () {
return this.minSoC > 0 && this.vehicleSoC < this.minSoC;
},
targetCharge: function () {
return this.collectProps(TargetCharge);
},
},
methods: {
setTargetTime: function (targetTime) {
this.$emit("target-time-updated", targetTime);
},
removeTargetTime: function () {
this.$emit("target-time-removed");
},
},
};
</script>

View file

@ -0,0 +1,44 @@
<template>
<div class="d-flex justify-content-between mb-3 align-items-center">
<h4 class="d-flex align-items-center m-0 flex-grow-1 overflow-hidden">
<shopicon-regular-car3
v-if="vehiclePresent || parked"
class="me-2 flex-shrink-0 car-icon"
></shopicon-regular-car3>
<shopicon-regular-cablecharge
v-else
class="me-2 flex-shrink-0 car-icon"
></shopicon-regular-cablecharge>
<span class="flex-grow-1 text-truncate"> {{ name }} </span>
</h4>
<button v-if="$hiddenFeatures" class="btn btn-link text-white p-0 flex-shrink-0">
<shopicon-filled-options size="s"></shopicon-filled-options>
</button>
</div>
</template>
<script>
import "@h2d2/shopicons/es/filled/options";
import "@h2d2/shopicons/es/regular/car3";
import "@h2d2/shopicons/es/regular/cablecharge";
import collector from "../mixins/collector";
export default {
name: "VehicleTitle",
mixins: [collector],
props: {
vehiclePresent: Boolean,
vehicleTitle: String,
parked: Boolean,
},
computed: {
name() {
if (this.vehiclePresent || this.parked) {
return this.vehicleTitle || this.$t("main.vehicle.fallbackName");
}
return this.$t("main.vehicle.noVehicle");
},
},
};
</script>

View file

@ -0,0 +1,104 @@
<template>
<div>
<div class="container px-4 mb-3 mb-sm-4 d-flex justify-content-start align-items-center">
<h2 class="m-0">{{ $t("main.vehicles") }}</h2>
<button
class="btn btn-link d-flex text-white p-2 ms-1 refresh"
:class="{
'refresh--in-progress': refreshing,
}"
:disabled="refreshing"
@click="refresh"
>
<shopicon-regular-refresh class="refresh-icon"></shopicon-regular-refresh>
</button>
</div>
<div class="container vehicles px-0 mb-5" :class="`vehicles-${vehicles.length}`">
<Vehicle
v-for="(vehicle, index) in vehicles"
v-bind="vehicle"
:id="`vehicle_${index}`"
:key="index"
class="vehicle"
parked
/>
</div>
</div>
</template>
<script>
import Vehicle from "./Vehicle.vue";
import collector from "../mixins/collector";
import "@h2d2/shopicons/es/regular/refresh";
export default {
name: "Vehicles",
components: { Vehicle },
mixins: [collector],
data() {
return {
refreshing: false,
// TODO: mock data for development
vehicles: [
{
vehiclePresent: true,
vehicleTitle: "Ford Mustang Mach-E",
vehicleSoC: 46,
vehicleRange: 182,
targetSoC: 100,
},
{
vehiclePresent: true,
vehicleTitle: "Renault Twingo Electric",
vehicleSoC: 77,
vehicleRange: 98,
targetSoC: 90,
},
{
vehiclePresent: true,
vehicleTitle: "Blauer VW ID.4",
vehicleSoC: 16,
vehicleRange: 52,
minSoC: 35,
targetSoC: 60,
},
],
};
},
methods: {
refresh() {
this.refreshing = true;
// TODO: insert real implementation here
window.setTimeout(() => {
this.refreshing = false;
}, 5000);
},
},
};
</script>
<style scoped>
.vehicles {
display: grid;
grid-gap: 2rem;
grid-template-columns: repeat(auto-fit, minmax(310px, 1fr));
}
.vehicle {
border: 4px solid white;
}
.refresh--in-progress {
animation: rotation 1s infinite cubic-bezier(0.37, 0, 0.63, 1);
}
.refresh-icon {
transform: translateY(-2px);
}
@keyframes rotation {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View file

@ -1,5 +1,4 @@
import Version from "./Version.vue"; import Version from "./Version.vue";
import i18n from "../i18n";
export default { export default {
title: "Main/Footer/Version", title: "Main/Footer/Version",
@ -7,11 +6,12 @@ export default {
argTypes: {}, argTypes: {},
}; };
const Template = (args, { argTypes }) => ({ const Template = (args) => ({
i18n, setup() {
props: Object.keys(argTypes), return { args };
},
components: { Version }, components: { Version },
template: '<Version v-bind="$props"></Version>', template: '<Version v-bind="args"></Version>',
}); });
const releaseNotes = const releaseNotes =

View file

@ -4,23 +4,25 @@
v-if="commit" v-if="commit"
:href="githubHashUrl" :href="githubHashUrl"
target="_blank" target="_blank"
class="btn btn-link ps-0 text-decoration-none link-dark text-nowrap" class="btn btn-link ps-0 text-decoration-none text-white text-nowrap d-flex align-items-end"
> >
<fa-icon icon="moon" class="me-2 text-muted"></fa-icon>v{{ installed }} <shopicon-regular-moonstars class="me-2 text-gray-medium"></shopicon-regular-moonstars>
<span class="text-muted">[{{ commit }}]</span> v{{ installed }}
<span class="ms-2 text-gray-medium">[{{ commit }}]</span>
</a> </a>
<button <button
v-else-if="newVersionAvailable" v-else-if="newVersionAvailable"
href="#" href="#"
data-bs-toggle="modal" data-bs-toggle="modal"
data-bs-target="#updateModal" data-bs-target="#updateModal"
class="btn btn-link ps-0 text-decoration-none link-dark text-nowrap" class="btn btn-link ps-0 text-decoration-none text-white text-nowrap d-flex align-items-end"
> >
<fa-icon icon="gift" class="icon me-2"></fa-icon>v{{ installed }} <shopicon-regular-gift class="me-2 text-evcc"></shopicon-regular-gift>
<span class="d-none d-xs-inline d-sm-none text-muted text-decoration-underline"> v{{ installed }}
<span class="ms-2 d-none d-xs-block d-sm-none text-decoration-underline">
{{ $t("footer.version.availableShort") }} {{ $t("footer.version.availableShort") }}
</span> </span>
<span class="d-none d-sm-inline text-muted text-decoration-underline"> <span class="ms-2 d-none d-sm-block text-gray-medium text-decoration-underline">
{{ $t("footer.version.availableLong") }} {{ $t("footer.version.availableLong") }}
</span> </span>
</button> </button>
@ -28,17 +30,19 @@
v-else v-else
:href="releaseNotesUrl(installed)" :href="releaseNotesUrl(installed)"
target="_blank" target="_blank"
class="btn btn-link ps-0 text-decoration-none link-dark text-nowrap" class="btn btn-link text-white ps-0 text-decoration-none text-nowrap d-flex align-items-end"
> >
<span class="d-inline d-xs-none d-sm-none">{{ <Logo class="logo me-2" />
$t("footer.version.versionShort", { installed }) v{{ installed }}
}}</span>
<span class="d-none d-xs-inline d-sm-inline">{{
$t("footer.version.versionLong", { installed })
}}</span>
</a> </a>
<div id="updateModal" class="modal fade" tabindex="-1" role="dialog" aria-hidden="true"> <div
id="updateModal"
class="modal fade text-dark"
tabindex="-1"
role="dialog"
aria-hidden="true"
>
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable" role="document"> <div class="modal-dialog modal-dialog-centered modal-dialog-scrollable" role="document">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
@ -119,10 +123,14 @@
<script> <script>
import api from "../api"; import api from "../api";
import "../icons"; import Logo from "./Logo.vue";
import "@h2d2/shopicons/es/regular/gift";
import "@h2d2/shopicons/es/regular/moonstars";
export default { export default {
name: "Version", name: "Version",
components: { Logo },
props: { props: {
installed: String, installed: String,
available: String, available: String,
@ -172,4 +180,8 @@ export default {
.icon { .icon {
color: var(--evcc-dark-green); color: var(--evcc-dark-green);
} }
.logo {
height: 1.1rem;
margin-bottom: 0.2rem;
}
</style> </style>

View file

@ -50,7 +50,7 @@ export default {
} }
} }
.root >>> svg { .root ::v-deep(svg) {
display: block; display: block;
} }
</style> </style>

View file

@ -0,0 +1,5 @@
export default {
install: (app) => {
app.config.globalProperties.$hiddenFeatures = window.localStorage["hidden_features"] === "true";
},
};

View file

@ -3,12 +3,11 @@ export default {
docs: "Dokumentation", docs: "Dokumentation",
blog: "Blog", blog: "Blog",
github: "GitHub", github: "GitHub",
login: "Anmeldungen", login: "Fahrzeug Logins",
about: "Über evcc",
}, },
footer: { footer: {
version: { version: {
versionShort: "v{installed}",
versionLong: "Version {installed}",
availableShort: "Update", availableShort: "Update",
availableLong: "Update verfügbar", availableLong: "Update verfügbar",
modalTitle: "Update verfügbar", modalTitle: "Update verfügbar",
@ -26,8 +25,8 @@ export default {
savings: { savings: {
footerShort: "{percent}% Sonne", footerShort: "{percent}% Sonne",
footerLong: "{percent}% Sonnenenergie", footerLong: "{percent}% Sonnenenergie",
modalTitleShort: "{total} kWh geladen · {percent}% Sonne", modalTitlePartOne: "{total} kWh geladen",
modalTitleLong: "{total} kWh geladen · {percent}% Sonnenenergie", modalTitlePartTwo: "{percent}% Sonne",
modalChartGrid: "Netz {grid} kWh", modalChartGrid: "Netz {grid} kWh",
modalChartSelf: "Sonne {self} kWh", modalChartSelf: "Sonne {self} kWh",
modalSavingsPrice: "Effektiver Energiepreis", modalSavingsPrice: "Effektiver Energiepreis",
@ -37,8 +36,6 @@ export default {
modalExplainationFeedIn: "Einspeisung {feedInPrice}", modalExplainationFeedIn: "Einspeisung {feedInPrice}",
modalServerStart: "Seit Serverstart {since}.", modalServerStart: "Seit Serverstart {since}.",
modalNoData: "noch nicht geladen", modalNoData: "noch nicht geladen",
experimentalLabel: "experimentell",
experimentalText: "Unplausible Werte? Fragen zur Ansicht? Meld dich gerne in den",
}, },
sponsor: { sponsor: {
thanks: "Danke für deine Unterstützung, {sponsor}! Das hilft uns bei der Weiterentwicklung.", thanks: "Danke für deine Unterstützung, {sponsor}! Das hilft uns bei der Weiterentwicklung.",
@ -54,7 +51,6 @@ export default {
modalTitle: "Meldungen", modalTitle: "Meldungen",
dismissAll: "Meldungen entfernen", dismissAll: "Meldungen entfernen",
}, },
main: { main: {
energyflow: { energyflow: {
noEnergy: "Kein Energiefluss", noEnergy: "Kein Energiefluss",
@ -69,21 +65,32 @@ export default {
pvExport: "Einspeisung", pvExport: "Einspeisung",
}, },
mode: { mode: {
title: "Modus", offShort: "Aus",
stop: "Stop", offLong: "Aus",
now: "Sofort", nowShort: "Sofort",
nowLong: "Sofort",
minpvShort: "Min", minpvShort: "Min",
minpvLong: "Min + PV", minpvLong: "Min + PV",
pvShort: "PV", pvShort: "PV",
pvLong: "Nur PV", pvLong: "PV",
fast: "Schnell",
cheap: "Günstig",
}, },
loadpoint: { loadpoint: {
fallbackName: "Ladepunkt", fallbackName: "Ladepunkt",
remoteDisabledSoft: "{source}: Adaptives PV-Laden deaktiviert", remoteDisabledSoft: "{source}: Adaptives PV-Laden deaktiviert",
remoteDisabledHard: "{source}: Deaktiviert", remoteDisabledHard: "{source}: Deaktiviert",
power: "Leistung",
charged: "Geladen",
duration: "Dauer",
remaining: "Restzeit",
}, },
vehicles: "Parkplatz",
vehicle: { vehicle: {
fallbackName: "Fahrzeug", fallbackName: "Fahrzeug",
vehicleSoC: "Ladestand",
targetSoC: "Ladeziel",
noVehicle: "Kein Fahrzeug",
}, },
vehicleSoC: { vehicleSoC: {
disconnected: "getrennt", disconnected: "getrennt",
@ -91,51 +98,42 @@ export default {
ready: "bereit", ready: "bereit",
connected: "verbunden", connected: "verbunden",
}, },
vehicleSubline: { vehicleStatus: {
mincharge: "Mindestladung bis {soc}%", minCharge: "Mindestladung bis {soc}%.",
waitForVehicle: "Ladebereit. Warte auf Fahrzeug.",
charging: "Ladevorgang aktiv.",
targetChargePlanned: "Zielladen geplant. Ladung startet {time} Uhr.",
targetChargeWaitForVehicle: "Zielladen bereit. Warte auf Fahrzeug.",
targetChargeActive: "Zielladen aktiv.",
connected: "Verbunden.",
pvDisable: "Zu wenig Überschuss. Pausiere in {remaining}.",
pvEnable: "Überschuss verfügbar. Starte in {remaining}.",
scale1p: "Reduziere auf einphasig in {remaining}.",
scale3p: "Erhöhe auf dreiphasig in {remaining}.",
disconnected: "Nicht verbunden.",
unknown: "",
}, },
provider: { provider: {
login: "anmelden", login: "anmelden",
logout: "abmelden", logout: "abmelden",
}, },
targetCharge: { targetCharge: {
title: "Zielzeit",
inactiveLabel: "Zielzeit", inactiveLabel: "Zielzeit",
activeLabel: "bis {time} Uhr", activeLabel: "{time}",
modalTitle: "Zielzeit festlegen", modalTitle: "Zielzeit festlegen",
description: "Wann soll das Fahrzeug auf <strong>{targetSoC}%</strong> geladen sein?", setTargetTime: "keine",
description: "Wann soll das Fahrzeug auf {targetSoC}% geladen sein?",
today: "heute", today: "heute",
tomorrow: "morgen", tomorrow: "morgen",
targetIsInThePast: "Zeitpunkt liegt in der Vergangenheit.", targetIsInThePast: "Zeitpunkt liegt in der Vergangenheit.",
remove: "Keine Zielzeit", remove: "Entfernen",
activate: "Zielzeit aktivieren", activate: "Aktivieren",
experimentalLabel: "experimentell", experimentalLabel: "Experimentell",
experimentalText: ` experimentalText: `
Diese Funktion ist in einem frühen Stadium. Der Algorithmus ist noch Dieses Feature funktioniert, ist aber noch nicht perfekt.
nicht perfekt. Die Zielzeit wird aktuell nicht persistiert - das Bitte melde unerwartetes Verhalten in unseren
heißt sie geht beim Neustart verloren. Verlasse dich also noch nicht
zu sehr auf diese Funktion. Wir freuen uns aber über deine
Erfahrungen und Verbessungsvorschläge in den
`, `,
}, },
loadpointDetails: {
power: "Leistung",
vehicleRange: "Reichweite",
charged: "Geladen",
duration: "Dauer",
remaining: "Restzeit",
tooltip: {
phases: {
scale1p: "Herunterschalten auf einphasig in {remaining}.",
scale3p: "Hochschalten auf dreiphasig in {remaining}.",
charge1p: "Lädt einphasig.",
charge2p: "Lädt zweiphasig.",
charge3p: "Lädt dreiphasig.",
},
pv: {
enable: "Überschuss verfügbar. Ladung startet in {remaining}.",
disable: "Zu wenig Überschuss. Ladung pausiert in {remaining}.",
},
},
},
}, },
}; };

View file

@ -3,12 +3,11 @@ export default {
docs: "Documentation", docs: "Documentation",
blog: "Blog", blog: "Blog",
github: "GitHub", github: "GitHub",
login: "Logins", login: "Vehicle Logins",
about: "About evcc",
}, },
footer: { footer: {
version: { version: {
versionShort: "v{installed}",
versionLong: "Version {installed}",
availableShort: "update", availableShort: "update",
availableLong: "update available", availableLong: "update available",
modalTitle: "Update available", modalTitle: "Update available",
@ -26,10 +25,10 @@ export default {
savings: { savings: {
footerShort: "{percent}% solar", footerShort: "{percent}% solar",
footerLong: "{percent}% solar energy", footerLong: "{percent}% solar energy",
modalTitleShort: "{total} kWh charged · {percent}% solar", modalTitlePartOne: "{total} kWh charged",
modalTitleLong: "{total} kWh charged · {percent}% solar energy", modalTitlePartTwo: "{percent}% solar",
modalChartGrid: "Grid energy {grid} kWh", modalChartGrid: "Grid {grid} kWh",
modalChartSelf: "Solar energy {self} kWh", modalChartSelf: "Solar {self} kWh",
modalSavingsPrice: "Effective energy price", modalSavingsPrice: "Effective energy price",
modalSavingsTotal: "Savings compared to grid", modalSavingsTotal: "Savings compared to grid",
modalExplaination: "Calculation", modalExplaination: "Calculation",
@ -37,8 +36,6 @@ export default {
modalExplainationFeedIn: "feed-in rate {feedInPrice}", modalExplainationFeedIn: "feed-in rate {feedInPrice}",
modalServerStart: "since server start {since}.", modalServerStart: "since server start {since}.",
modalNoData: "nothing charged yet", modalNoData: "nothing charged yet",
experimentalLabel: "experimental",
experimentalText: "Implausible values? Questions about this view? Feel free to join our ",
}, },
sponsor: { sponsor: {
thanks: "Thanks for your support, {sponsor}! It helps us with the further development.", thanks: "Thanks for your support, {sponsor}! It helps us with the further development.",
@ -58,8 +55,8 @@ export default {
energyflow: { energyflow: {
noEnergy: "No Energyflow", noEnergy: "No Energyflow",
homePower: "Consumption", homePower: "Consumption",
loadpoints: "Loadpoint | Loadpoint | {count} Loadpoints",
pvProduction: "Production", pvProduction: "Production",
loadpoints: "Loadpoint | Loadpoint | {count} Loadpoints",
battery: "Battery", battery: "Battery",
batteryCharge: "Battery charge", batteryCharge: "Battery charge",
batteryDischarge: "Battery discharge", batteryDischarge: "Battery discharge",
@ -68,21 +65,32 @@ export default {
pvExport: "Grid export", pvExport: "Grid export",
}, },
mode: { mode: {
title: "Mode", offShort: "Stop",
stop: "Stop", offLong: "Stop",
now: "Now", nowShort: "Now",
nowLong: "Now",
minpvShort: "Min", minpvShort: "Min",
minpvLong: "Min + PV", minpvLong: "Min + PV",
pvShort: "PV", pvShort: "PV",
pvLong: "PV only", pvLong: "PV",
fast: "Fast",
cheap: "Cheap",
}, },
loadpoint: { loadpoint: {
fallbackName: "Loadpoint", fallbackName: "Loadpoint",
remoteDisabledSoft: "{source}: adaptive PV charging disabled", remoteDisabledSoft: "{source}: adaptive PV charging disabled",
remoteDisabledHard: "{source}: disabled", remoteDisabledHard: "{source}: disabled",
power: "Power",
charged: "Charged",
duration: "Duration",
remaining: "Remaining",
}, },
vehicles: "Parking",
vehicle: { vehicle: {
fallbackName: "Vehicle", fallbackName: "Vehicle",
vehicleSoC: "SoC",
targetSoC: "Limit",
noVehicle: "No Vehicle",
}, },
vehicleSoC: { vehicleSoC: {
disconnected: "disconnected", disconnected: "disconnected",
@ -90,51 +98,42 @@ export default {
ready: "ready", ready: "ready",
connected: "connected", connected: "connected",
}, },
vehicleSubline: { vehicleStatus: {
mincharge: "minimum charging to {soc}%", minCharge: "minimum charging to {soc}%.",
waitForVehicle: "Ready. Waiting for vehicle.",
charging: "Charging.",
targetChargePlanned: "Target charge planned. Starting {start}.",
targetChargeWaitForVehicle: "Target charge ready. Wait for vehicle.",
targetChargeActive: "Target charge active.",
connected: "Connected.",
pvDisable: "Not enough surplus. Pausing in {remaining}.",
pvEnable: "Surplus available. Starging in {remaining}.",
scale1p: "Reduce to single phase in {remaining}.",
scale3p: "Increase to three phase in {remaining}.",
disconnected: "Disconnected.",
unknown: "",
}, },
provider: { provider: {
login: "login", login: "login",
logout: "logout", logout: "logout",
}, },
targetCharge: { targetCharge: {
title: "Target Time",
inactiveLabel: "Target time", inactiveLabel: "Target time",
activeLabel: "until {time}", activeLabel: "{time}",
modalTitle: "Set Target Time", modalTitle: "Set Target Time",
description: "When should the vehicle be charged to <strong>{targetSoC}%</strong>?", setTargetTime: "none",
description: "When should the vehicle be charged to {targetSoC}%?",
today: "today", today: "today",
tomorrow: "tomorrow", tomorrow: "tomorrow",
targetIsInThePast: "The chosen time is in the past.", targetIsInThePast: "The chosen time is in the past.",
remove: "Remove", remove: "Remove",
activate: "Activate", activate: "Activate",
experimentalLabel: "experimental", experimentalLabel: "Experimental",
experimentalText: ` experimentalText: `
This function is at an early stage. This feature works but isn't perfect yet.
The algorithm is not perfect yet. Please report unexpected behaviour in our
The target time is currently not persisted - this means, it will be lost when your server restarts.
So do not rely too much on this function.
However, we look forward to your experiences and suggestions for improvement in the
`, `,
}, },
loadpointDetails: {
power: "Power",
vehicleRange: "Range",
charged: "Charged",
duration: "Duration",
remaining: "Remaining",
tooltip: {
phases: {
scale1p: "Switching to single-phase in {remaining}.",
scale3p: "Switching to three-phase in {remaining}.",
charge1p: "Single-phase charging.",
charge2p: "Two-phase charging.",
charge3p: "Three-phase charging.",
},
pv: {
enable: "Solar available. Resume charging in {remaining}.",
disable: "Not enough solar. Pause charging in {remaining}.",
},
},
},
}, },
}; };

View file

@ -1,12 +1,9 @@
import Vue from "vue"; import { createI18n } from "vue-i18n/index";
import VueI18n from "vue-i18n";
import de from "./de"; import de from "./de";
import en from "./en"; import en from "./en";
import it from "./it"; import it from "./it";
import lt from "./lt"; import lt from "./lt";
Vue.use(VueI18n);
const PREFERRED_LOCALE_KEY = "preferred_locale"; const PREFERRED_LOCALE_KEY = "preferred_locale";
function getBrowserLocale() { function getBrowserLocale() {
@ -15,11 +12,10 @@ function getBrowserLocale() {
if (!navigatorLocale) { if (!navigatorLocale) {
return undefined; return undefined;
} }
const trimmedLocale = navigatorLocale.trim().split(/-|_/)[0]; return navigatorLocale.trim().split(/-|_/)[0];
return trimmedLocale;
} }
export default new VueI18n({ export default createI18n({
locale: window.localStorage[PREFERRED_LOCALE_KEY] || getBrowserLocale(), locale: window.localStorage[PREFERRED_LOCALE_KEY] || getBrowserLocale(),
fallbackLocale: "en", fallbackLocale: "en",
messages: { de, en, it, lt }, messages: { de, en, it, lt },

View file

@ -1,17 +1,16 @@
export default { export default {
header: { header: {
docs: "Dokumentacija", docs: "Dokumentacija (Vokiečių k.)",
blog: "Tinklaraštis", blog: "Tinklaraštis",
github: "GitHub", github: "GitHub",
login: "Prisijungimas", login: " Automobilių prisijungimai",
about: "Apie EVCC",
}, },
footer: { footer: {
version: { version: {
versionShort: "v{installed}",
versionLong: "Versija {installed}",
availableShort: "Naujinimas", availableShort: "Naujinimas",
availableLong: "Prieinama naujesnė versija", availableLong: "Yra naujesnė versija",
modalTitle: "Prieinama naujesnė versija", modalTitle: "Yra naujesnė versija",
modalUpdateStarted: "Pasibaigus naujinimui EVCC startuos iš naujo..", modalUpdateStarted: "Pasibaigus naujinimui EVCC startuos iš naujo..",
modalInstalledVersion: "Dabartinė versija", modalInstalledVersion: "Dabartinė versija",
modalNoReleaseNotes: "Naujinimo detalių nėra. Daugiau informacijos rasite čia:", modalNoReleaseNotes: "Naujinimo detalių nėra. Daugiau informacijos rasite čia:",
@ -25,8 +24,8 @@ export default {
savings: { savings: {
footerShort: "{percent}% saulės", footerShort: "{percent}% saulės",
footerLong: "{percent}% saulės energija", footerLong: "{percent}% saulės energija",
modalTitleShort: "{total} kWh įkrauta · {percent}% saulės", modalTitlePartOne: "{total} kWh įkrauta",
modalTitleLong: "{total} kWh įkrauta · {percent}% saulės energija", modalTitlePartTwo: "{percent}% saulės",
modalChartGrid: "Energija iš tinklo {grid} kWh", modalChartGrid: "Energija iš tinklo {grid} kWh",
modalChartSelf: "Saulės energija {self} kWh", modalChartSelf: "Saulės energija {self} kWh",
modalSavingsPrice: "Faktinė energijos kaina", modalSavingsPrice: "Faktinė energijos kaina",
@ -36,18 +35,15 @@ export default {
modalExplainationFeedIn: "Kompensavimas už patiektą į tinklą energiją {feedInPrice}", modalExplainationFeedIn: "Kompensavimas už patiektą į tinklą energiją {feedInPrice}",
modalServerStart: "nuo EVCC starto {since}.", modalServerStart: "nuo EVCC starto {since}.",
modalNoData: "įkrovimo duomenų dar nėra", modalNoData: "įkrovimo duomenų dar nėra",
experimentalLabel: "eksperimentinis",
experimentalText:
"Neteisingi duomenys? Turite klausimų apie šiuos skaičiavimus? Prisijunkite prie mūsų",
}, },
sponsor: { sponsor: {
thanks: "Ačiū, kad mus remiate {sponsor}! Tuo prisidedate prie projekto vystymo.", thanks: "Ačiū, kad mus remiate {sponsor}! Taip prisidedate prie projekto vystymo.",
confetti: "Ar pasiruošę priimti rėmėjo konfeti?", confetti: "Ar pasiruošę priimti rėmėjo konfeti?",
supportUs: supportUs:
"Norime, kad efektyvesnis elektromobilių įkrovimas taptų pasiekiamas kuo daugiau žmonių. Tapdami rėmėjais prisidedate prie nuolatinio projekto vystymo ir palaikymo.", "Norime, kad efektyvesnis elektromobilių įkrovimas saulės energija taptų pasiekiamas kuo daugiau žmonių. Remdami prisidedate prie nuolatinio projekto vystymo ir techninio palaikymo.",
sticker: "... ar evcc lipdukų?", sticker: "... ar EVCC lipdukų?",
confettiPromise: "Gausite skaitmeninių lipdukų ir konfeti ;)", confettiPromise: "Gausite lipdukų ir skaitmeninių konfeti ;)",
becomeSponsor: "Taptkite GitHub rėmėju!", becomeSponsor: "Pradėkite GitHub rėmimą!",
}, },
}, },
notifications: { notifications: {
@ -58,8 +54,8 @@ export default {
energyflow: { energyflow: {
noEnergy: "Energija neteka", noEnergy: "Energija neteka",
homePower: "Namo suvartojimas", homePower: "Namo suvartojimas",
loadpoints: "Įkroviklis | Įkroviklis | {count} Įkrovikliai",
pvProduction: "Gamyba", pvProduction: "Gamyba",
loadpoints: "Įkroviklis | Įkroviklis | {count} Įkrovikliai",
battery: "Baterija", battery: "Baterija",
batteryCharge: "Baterijos įkrovimas", batteryCharge: "Baterijos įkrovimas",
batteryDischarge: "Baterijos iškrovimas", batteryDischarge: "Baterijos iškrovimas",
@ -68,73 +64,75 @@ export default {
pvExport: "Tinklo eksportas", pvExport: "Tinklo eksportas",
}, },
mode: { mode: {
title: "Darbo režimas", offShort: "Stop",
stop: "Stop", offLong: "Stop",
now: "Dabar", nowShort: "Dabar",
nowLong: "Dabar",
minpvShort: "Min", minpvShort: "Min",
minpvLong: "Min + PV", minpvLong: "Min + PV",
pvShort: "PV", pvShort: "PV",
pvLong: "Tik PV", pvLong: "PV",
fast: "Greitai",
cheap: "Pigiai",
}, },
loadpoint: { loadpoint: {
fallbackName: "Įkroviklis", fallbackName: "Įkroviklis",
remoteDisabledSoft: "{source}: adaptyvus PV įkrovimas išjungtas", remoteDisabledSoft: "{source}: adaptyvus PV įkrovimas išjungtas",
remoteDisabledHard: "{source}: Išjungtas", remoteDisabledHard: "{source}: išjungtas",
power: "Galia",
charged: "Įkrauta",
duration: "Trukmė",
remaining: "Liko",
}, },
vehicles: "Autoparkas",
vehicle: { vehicle: {
fallbackName: "Automobilis", fallbackName: "Automobilis",
vehicleSoC: "Įkrova",
targetSoC: "Limitas",
noVehicle: "Nėra automobilio",
}, },
vehicleSoC: { vehicleSoC: {
disconnected: "neprijungtas", disconnected: "neprijungtas",
charging: "vyksta įkrovimas", charging: "vyksta įkrovimas",
ready: "leidžiama įkrauti", ready: "leidžiama įkrauti",
connected: "prijungtas", connected: "automobilis prijungtas",
}, },
vehicleSubline: { vehicleStatus: {
mincharge: "minimalus įkrovimas iki {soc}%", minCharge: "minimalus įkrovimas iki {soc}%.",
waitForVehicle: "Įkrovimas leidžiamas. Laukiama automobilio signalo.",
charging: "Įkraunama.",
targetChargePlanned: "Suplanuotas įkrovimas, prasidės {start}.",
targetChargeWaitForVehicle: "Suplanuotas įkrovimas leidžiamas. Laukiama automobilio signalo.",
targetChargeActive: "Suplanuotas įkrovimas aktyvuotas.",
connected: "Prijungtas.",
pvDisable: "Trūksta saulės, įkrovimo pauzė už {remaining}.",
pvEnable: "Saulės užtenka, įkrovimas prasidės už {remaining}.",
scale1p: "Sumažinti į vienfazį įkrovimą už {remaining}.",
scale3p: "Padidinti į trifazį įkrovimą už {remaining}.",
disconnected: "Neprijungtas.",
unknown: "",
}, },
provider: { provider: {
login: "prisijungti", login: "prisijungti",
logout: "atsijungti", logout: "atsijungti",
}, },
targetCharge: { targetCharge: {
inactiveLabel: "Suplanuota įkrovimo pabaiga", title: "Įkrauti iki",
activeLabel: "įkrauti iki {time}", inactiveLabel: "Įkrauti iki",
activeLabel: "{time}",
modalTitle: "Nustatyti įkrovimo pabaigos laiką", modalTitle: "Nustatyti įkrovimo pabaigos laiką",
description: "Kada automobilis turėtų būti įkrautas iki <strong>{targetSoC}%</strong>?", setTargetTime: "nenustatytas",
description: "Kada automobilis turėtų būti įkrautas iki {targetSoC}%?",
today: "šiandien", today: "šiandien",
tomorrow: "rytoj", tomorrow: "rytoj",
targetIsInThePast: "Pasirinktas laikas yra praeityje.", targetIsInThePast: "Pasirinktas laikas yra praeityje.",
remove: "Panaikinti", remove: "Panaikinti",
activate: "Aktyvuoti", activate: "Aktyvuoti",
experimentalLabel: "eksperimentinis", experimentalLabel: "Eksperimentinis",
experimentalText: ` experimentalText: `
Ši funkcija yra ankstyvoje integravimo stadijoje. Ši funkcija veikia, bet dar nėra tobula.
Algoritmas kol kas nėra tobulas. Apie netikėtą elgesį praneškite mūsų
Nustatytas laikas neišsaugojamas - neišlieka po serverio restarto.
Dėl šių priežasčių pernelyg nepasitikėkite šia eksperimentine funkcija.
Visgi, norėtume gauti atsiliepimų apie jos veikimą bei pasiūlymų tobulinimui mūsų
`, `,
}, },
loadpointDetails: {
power: "Galia",
vehicleRange: "Apytikris nuvažiuojamas atstumas",
charged: "Įkrauta",
duration: "Įkrovimas vyksta",
remaining: "Pabaiga už",
tooltip: {
phases: {
scale1p: "Perjungimas į vienfazį įkrovimą už {remaining}.",
scale3p: "Perjungimas į trifazį įkrovimą už {remaining}.",
charge1p: "Vienfazis įkrovimas.",
charge2p: "Dvifazis įkrovimas.",
charge3p: "Trifazis įkrovimas.",
},
pv: {
enable: "Saulės energijos pakanka, įkrovimas tęsiamas už {remaining}.",
disable: "Nepakanka saulės energijos, įkrovimo pauzė už {remaining}.",
},
},
},
}, },
}; };

View file

@ -1,66 +0,0 @@
import Vue from "vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { library } from "@fortawesome/fontawesome-svg-core";
import { faBatteryEmpty } from "@fortawesome/free-solid-svg-icons/faBatteryEmpty";
import { faBatteryFull } from "@fortawesome/free-solid-svg-icons/faBatteryFull";
import { faBatteryHalf } from "@fortawesome/free-solid-svg-icons/faBatteryHalf";
import { faBatteryQuarter } from "@fortawesome/free-solid-svg-icons/faBatteryQuarter";
import { faBatteryThreeQuarters } from "@fortawesome/free-solid-svg-icons/faBatteryThreeQuarters";
import { faAngleUp } from "@fortawesome/free-solid-svg-icons/faAngleUp";
import { faAngleDown } from "@fortawesome/free-solid-svg-icons/faAngleDown";
import { faClock } from "@fortawesome/free-solid-svg-icons/faClock";
import { faMoon } from "@fortawesome/free-solid-svg-icons/faMoon";
import { faExclamationTriangle } from "@fortawesome/free-solid-svg-icons/faExclamationTriangle";
import { faSun } from "@fortawesome/free-solid-svg-icons/faSun";
import { faSun as farSun } from "@fortawesome/free-regular-svg-icons/faSun";
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons/faInfoCircle";
import { faFlask } from "@fortawesome/free-solid-svg-icons/faFlask";
import { faTemperatureHigh } from "@fortawesome/free-solid-svg-icons/faTemperatureHigh";
import { faTemperatureLow } from "@fortawesome/free-solid-svg-icons/faTemperatureLow";
import { faThermometerHalf } from "@fortawesome/free-solid-svg-icons/faThermometerHalf";
import { faHeart as farHeart } from "@fortawesome/free-regular-svg-icons/faHeart";
import { faHeart as fasHeart } from "@fortawesome/free-solid-svg-icons/faHeart";
import { faGift } from "@fortawesome/free-solid-svg-icons/faGift";
import { faBox } from "@fortawesome/free-solid-svg-icons/faBox";
import { faHome } from "@fortawesome/free-solid-svg-icons/faHome";
import { faWrench } from "@fortawesome/free-solid-svg-icons/faWrench";
import { faCar } from "@fortawesome/free-solid-svg-icons/faCar";
import { faSquare } from "@fortawesome/free-solid-svg-icons/faSquare";
import { faExclamationCircle } from "@fortawesome/free-solid-svg-icons/faExclamationCircle";
import { faAngleDoubleLeft } from "@fortawesome/free-solid-svg-icons/faAngleDoubleLeft";
import { faAngleDoubleRight } from "@fortawesome/free-solid-svg-icons/faAngleDoubleRight";
library.add(
faAngleDown,
faAngleUp,
faBatteryEmpty,
faBatteryFull,
faBatteryHalf,
faBatteryQuarter,
faBatteryThreeQuarters,
faBox,
faCar,
faAngleDoubleLeft,
faAngleDoubleRight,
faClock,
faMoon,
faExclamationCircle,
faExclamationTriangle,
faGift,
faHome,
faWrench,
farHeart,
fasHeart,
faSquare,
faSun,
farSun,
faInfoCircle,
faFlask,
faTemperatureHigh,
faTemperatureLow,
faThermometerHalf
);
// eslint-disable-next-line vue/component-definition-name-casing
Vue.component("fa-icon", FontAwesomeIcon);

View file

@ -4,7 +4,9 @@ export default {
collectProps: function (component) { collectProps: function (component) {
let data = {}; let data = {};
for (var p in component.props) { for (var p in component.props) {
data[p] = this[p]; if (p in this) {
data[p] = this[p];
}
} }
return data; return data;
}, },

View file

@ -17,16 +17,21 @@ export default {
val = Math.abs(val); val = Math.abs(val);
return val >= this.fmtLimit ? this.round(val / 1e3, this.fmtDigits) : this.round(val, 0); return val >= this.fmtLimit ? this.round(val / 1e3, this.fmtDigits) : this.round(val, 0);
}, },
fmtKw: function (watt, kw = true, withUnit = true) { fmtKw: function (watt = 0, kw = true, withUnit = true) {
const digits = kw ? 1 : 0; const digits = kw ? 1 : 0;
const value = kw ? watt / 1000 : watt; const value = kw ? watt / 1000 : watt;
let unit = ""; let unit = "";
if (withUnit) { if (withUnit) {
unit = kw ? " kW" : " W"; unit = kw ? " kW" : " W";
} }
return ( return `${new Intl.NumberFormat(this.$i18n.locale, {
this.$n(value, { minimumFractionDigits: digits, maximumFractionDigits: digits }) + unit style: "decimal",
); minimumFractionDigits: digits,
maximumFractionDigits: digits,
}).format(value)}${unit}`;
},
fmtKWh: function (val) {
return this.fmtKw(val) + "h";
}, },
fmtUnit: function (val) { fmtUnit: function (val) {
return Math.abs(val) >= this.fmtLimit ? "k" : ""; return Math.abs(val) >= this.fmtLimit ? "k" : "";
@ -88,15 +93,42 @@ export default {
const mm = `${date.getMinutes()}`.padStart(2, "0"); const mm = `${date.getMinutes()}`.padStart(2, "0");
return `${HH}:${mm}`; return `${HH}:${mm}`;
}, },
fmtAbsoluteDate: function (date) { isToday: function (date) {
const today = new Date();
return today.toDateString() === date.toDateString();
},
isTomorrow: function (date) {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
return tomorrow.toDateString() === date.toDateString();
},
weekdayPrefix: function (date) {
const rtf = new Intl.RelativeTimeFormat(this.$i18n.locale, { numeric: "auto" });
if (this.isToday(date)) {
return rtf.formatToParts(0, "day")[0].value;
}
if (this.isTomorrow(date)) {
return rtf.formatToParts(1, "day")[0].value;
}
return new Intl.DateTimeFormat(this.$i18n.locale, { return new Intl.DateTimeFormat(this.$i18n.locale, {
weekday: "short", weekday: "short",
}).format(date);
},
fmtAbsoluteDate: function (date) {
const weekday = this.weekdayPrefix(date);
const hour = new Intl.DateTimeFormat(this.$i18n.locale, {
hour: "numeric", hour: "numeric",
minute: "numeric", minute: "numeric",
}).format(date); }).format(date);
return `${weekday} ${hour}`;
}, },
fmtMoney: function (amout = 0, currency = "EUR") { fmtMoney: function (amout = 0, currency = "EUR") {
return this.$n(amout, { style: "currency", currency }); return new Intl.NumberFormat(this.$i18n.locale, {
style: "currency",
currency,
}).format(amout);
}, },
fmtPricePerKWh: function (amout = 0, currency = "EUR") { fmtPricePerKWh: function (amout = 0, currency = "EUR") {
let unit = currency; let unit = currency;
@ -107,7 +139,10 @@ export default {
unit = "ct"; unit = "ct";
maximumFractionDigits = 1; maximumFractionDigits = 1;
} }
return `${this.$n(value, { style: "decimal", maximumFractionDigits })} ${unit}/kWh`; return `${new Intl.NumberFormat(this.$i18n.locale, {
style: "decimal",
maximumFractionDigits,
}).format(value)} ${unit}/kWh`;
}, },
fmtTimeAgo: function (elapsed) { fmtTimeAgo: function (elapsed) {
const units = { const units = {

View file

@ -1,16 +1,8 @@
import VueRouter from "vue-router"; import { createRouter, createWebHistory } from "vue-router";
import Vue from "vue";
import Main from "./views/Main";
import Config from "./views/Config";
Vue.use(VueRouter); import Main from "./views/Main.vue";
const routes = [ export default createRouter({
{ path: "/", component: Main }, history: createWebHistory(),
{ path: "/config", component: Config }, routes: [{ path: "/", component: Main, props: true }],
];
export default new VueRouter({
routes,
linkExactActiveClass: "active", // Bootstrap <nav>
}); });

View file

@ -1,9 +1,9 @@
import Vue from "vue"; import { reactive } from "vue";
function setProperty(obj, props, value) { function setProperty(obj, props, value) {
const prop = props.shift(); const prop = props.shift();
if (!obj[prop]) { if (!obj[prop]) {
Vue.set(obj, prop, {}); obj[prop] = {};
} }
if (!props.length) { if (!props.length) {
@ -18,16 +18,18 @@ function setProperty(obj, props, value) {
setProperty(obj[prop], props, value); setProperty(obj[prop], props, value);
} }
const state = reactive({
loadpoints: [], // ensure array type
});
const store = { const store = {
state: { state,
loadpoints: [], // ensure array type
},
update: function (msg) { update: function (msg) {
Object.keys(msg).forEach(function (k) { Object.keys(msg).forEach(function (k) {
if (typeof window.app[k] === "function") { if (typeof window.app[k] === "function") {
window.app[k]({ message: msg[k] }); window.app[k]({ message: msg[k] });
} else { } else {
setProperty(store.state, k.split("."), msg[k]); setProperty(state, k.split("."), msg[k]);
} }
}); });
}, },

View file

@ -1,7 +0,0 @@
import "v-tooltip/dist/v-tooltip.css";
import Vue from "vue";
import VTooltip from "v-tooltip";
VTooltip.options.offset = [0, 10];
VTooltip.options.themes.tooltip.triggers = ["click", "focus"];
Vue.use(VTooltip);

View file

@ -1,171 +1,20 @@
<template> <template>
<div class="app d-flex flex-column justify-content-between overflow-hidden"> <div class="app overflow-hidden">
<div class="flex-grow-1 d-flex flex-column justify-content-between"> <metainfo>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark"> <template #title="{ content }">{{ content ? `${content} | evcc` : `evcc` }}</template>
<div class="container"> </metainfo>
<a class="navbar-brand" href="https://evcc.io/" target="_blank"> <router-view :notifications="notifications"></router-view>
<Logo class="logo"></Logo>
</a>
<div class="d-flex flex-grow-1 justify-content-end">
<Notifications :notifications="notifications" />
<button
class="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbarNavAltMarkup"
aria-controls="navbarNavAltMarkup"
aria-expanded="false"
aria-label="Toggle navigation"
>
<span class="navbar-toggler-icon"></span>
</button>
</div>
<div id="navbarNavAltMarkup" class="collapse navbar-collapse flex-grow-0">
<ul class="navbar-nav">
<li v-if="providerLogins.length > 0" class="nav-item dropdown">
<a
class="nav-link dropdown-toggle"
data-bs-toggle="dropdown"
href="#"
role="button"
aria-expanded="false"
>{{ $t("header.login") }}
<span v-if="logoutCount > 0" class="badge bg-secondary">{{
logoutCount
}}</span>
</a>
<ul class="dropdown-menu">
<li
v-for="login in providerLogins"
:key="login.title"
class="dropdown-item"
>
<button
class="dropdown-item"
type="button"
@click="handleProviderAuthorization(login)"
>
{{ login.title }}
{{
$t(
login.loggedIn
? "main.provider.logout"
: "main.provider.login"
)
}}
</button>
</li>
</ul>
</li>
<li class="nav-item">
<a
class="nav-link"
href="https://docs.evcc.io/blog/"
target="_blank"
>
{{ $t("header.blog") }}
</a>
</li>
<li class="nav-item">
<a
class="nav-link"
href="https://docs.evcc.io/docs/Home/"
target="_blank"
>
{{ $t("header.docs") }}
</a>
</li>
<li class="nav-item">
<a
class="nav-link"
href="https://github.com/evcc-io/evcc"
target="_blank"
>
{{ $t("header.github") }}
</a>
</li>
</ul>
</div>
</div>
</nav>
<router-view
class="flex-grow-1 d-flex flex-column justify-content-stretch"
></router-view>
</div>
<Footer :version="version" :sponsor="sponsor" :savings="savings"></Footer>
</div> </div>
</template> </template>
<script> <script>
import "../icons";
import Logo from "../components/Logo";
import Footer from "../components/Footer";
import Notifications from "../components/Notifications";
import store from "../store"; import store from "../store";
import baseAPI from "../baseapi";
export default { export default {
name: "App", name: "App",
components: { Logo, Footer, Notifications },
props: { props: {
notifications: Array, notifications: Array,
}, },
data: function () {
return {
compact: false,
store: this.$root.$data.store,
installedVersion: window.evcc.version,
commit: window.evcc.commit,
};
},
computed: {
version: function () {
return {
installed: this.installedVersion,
commit: this.commit,
available: this.store.state.availableVersion,
releaseNotes: this.store.state.releaseNotes,
hasUpdater: this.store.state.hasUpdater,
uploadMessage: this.store.state.uploadMessage,
uploadProgress: this.store.state.uploadProgress,
};
},
title: function () {
return this.store.state.siteTitle;
},
sponsor: function () {
return this.store.state.sponsor;
},
savings: function () {
return {
since: this.store.state.savingsSince,
totalCharged: this.store.state.savingsTotalCharged,
gridCharged: this.store.state.savingsGridCharged,
selfConsumptionCharged: this.store.state.savingsSelfConsumptionCharged,
amount: this.store.state.savingsAmount,
effectivePrice: this.store.state.savingsEffectivePrice,
selfConsumptionPercent: this.store.state.savingsSelfConsumptionPercent,
gridPrice: this.store.state.tariffGrid,
feedInPrice: this.store.state.tariffFeedIn,
currency: this.store.state.currency,
};
},
logoutCount() {
return this.providerLogins.filter((login) => !login.loggedIn).length;
},
providerLogins() {
return this.store.state.auth
? Object.entries(this.store.state.auth.vehicles).map(([k, v]) => ({
title: k,
loggedIn: v.authenticated,
loginPath: v.uri + "/login",
logoutPath: v.uri + "/logout",
}))
: [];
},
},
created: function () { created: function () {
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
this.compact = urlParams.get("compact"); this.compact = urlParams.get("compact");
@ -211,32 +60,15 @@ export default {
reload() { reload() {
window.location.reload(); window.location.reload();
}, },
handleProviderAuthorization: async function (provider) {
if (!provider.loggedIn) {
baseAPI.post(provider.loginPath).then(function (response) {
window.location.href = response.data.loginUri;
});
} else {
baseAPI.post(provider.logoutPath);
}
},
}, },
metaInfo() { metaInfo() {
return { return { title: store.state.siteTitle || "" };
title: this.title ? `evcc | ${this.title}` : "evcc",
};
}, },
}; };
</script> </script>
<style scoped> <style scoped>
.logo {
width: 85px;
}
.app { .app {
min-height: 100vh; min-height: 100vh;
} background-color: white;
.title {
position: relative;
top: 0.1rem;
} }
</style> </style>

View file

@ -1,7 +1,7 @@
<template> <template>
<div class="container"> <div>
<Site v-if="configured" v-bind="state"></Site> <Site v-if="configured" :notifications="notifications" v-bind="state"></Site>
<div v-else> <div v-else class="container">
<div class="row py-5"> <div class="row py-5">
<div class="col12"> <div class="col12">
<p class="h1 pt-5 pb-2 border-bottom">Willkommen bei evcc</p> <p class="h1 pt-5 pb-2 border-bottom">Willkommen bei evcc</p>
@ -80,13 +80,17 @@
</template> </template>
<script> <script>
import Site from "../components/Site"; import Site from "../components/Site.vue";
import store from "../store";
export default { export default {
name: "Main", name: "Main",
components: { Site }, components: { Site },
props: {
notifications: Array,
},
data: function () { data: function () {
return this.$root.$data.store; // global state return store;
}, },
computed: { computed: {
configured: function () { configured: function () {

View file

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 4 KiB

After

Width:  |  Height:  |  Size: 4 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Before After
Before After

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square70x70logo src="meta/mstile-70x70.png" />
<square150x150logo src="meta/mstile-150x150.png" />
<square310x310logo src="meta/mstile-310x310.png" />
<TileColor>#18191a</TileColor>
</tile>
</msapplication>
</browserconfig>

View file

Before

Width:  |  Height:  |  Size: 646 B

After

Width:  |  Height:  |  Size: 646 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 797 B

After

Width:  |  Height:  |  Size: 797 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before After
Before After

View file

@ -9,16 +9,23 @@ javascript:
- vm: shared - vm: shared
script: | script: |
state = { state = {
residualpower: 300, residualpower: 500,
batterypower: 0, pvpower: 5000,
pvpower: 6200, batterypower: -750,
gridpower: 0, batterySoC: 55,
chargepower: 0, gridpower: -1000,
maxcurrent: 0, loadpoints: [
battery: 63, // car { enabled: true, vehicleSoC: 62, maxcurrent: 6, phases: 1, chargepower: 0 },
{ enabled: false, vehicleSoC: 22, maxcurrent: 0, phases: 3, chargepower: 0 }
]
}; };
function log() { console.log("state:", JSON.stringify(state)); } function get() {
function set() { console.log("set:", param+"="+val); } console.log("state:", JSON.stringify(state));
}
function set() {
console.log(param+":", val);
console.log("state:", JSON.stringify(state));
}
meters: meters:
- name: grid - name: grid
@ -27,22 +34,9 @@ meters:
source: js source: js
vm: shared vm: shared
script: | script: |
state.pvpower += 200*(Math.random()-0.5); state.pvpower = 8000+500*Math.random();
state.chargepower = 0; state.batterypower = state.gridpower > 0 ? 1000 * Math.random() : 0;
if (state.enabled) { state.gridpower = state.loadpoints[0].chargepower + state.loadpoints[1].chargepower + state.residualpower - state.batterypower - state.pvpower;
state.chargepower = state.maxcurrent * 3 * 230;
state.batterypower = 0;
}
state.gridpower = -state.pvpower + state.chargepower + state.residualpower + state.batterypower;
// if (state.gridpower < 0) {
// state.batterypower = -state.gridpower;
// if (state.batterypower > 1000) {
// state.batterypower = 1000;
// }
// state.gridpower += state.batterypower;
// } else {
// state.batterypower = 0;
// }
state.gridpower; state.gridpower;
- name: pv - name: pv
@ -50,7 +44,7 @@ meters:
power: power:
source: js source: js
vm: shared vm: shared
script: state.pvpower script: state.pvpower;
- name: battery - name: battery
type: custom type: custom
@ -61,56 +55,148 @@ meters:
soc: soc:
source: js source: js
vm: shared vm: shared
script: "30" script: |
- name: charge if (state.batterypower < 0) state.batterySoC++; else state.batterySoC--;
if (state.batterySoC < 10) state.batterySoC = 90;
if (state.batterySoC > 90) state.batterySoC = 10;
state.batterySoC;
- name: meter_charger_1
type: custom type: custom
power: power:
source: js source: js
vm: shared vm: shared
script: state.chargepower; script: state.loadpoints[0].chargepower;
soc: - name: meter_charger_2
type: custom
power:
source: js source: js
vm: shared vm: shared
script: "30" script: state.loadpoints[1].chargepower;
- name: meter_charger_3
type: custom
power:
source: js
script: 0
chargers: chargers:
- name: demo - name: charger_1
type: custom type: custom
enable: enable:
source: js source: js
vm: shared vm: shared
script: state.enabled = val; script: |
set();
var lp = state.loadpoints[0];
lp.enabled = val;
if (lp.enabled) lp.chargepower = lp.maxcurrent * 230 * lp.phases; else lp.chargepower = 0;
enabled: enabled:
source: js source: js
vm: shared vm: shared
script: | script: |
state.enabled; state.loadpoints[0].enabled;
status: status:
source: js source: js
vm: shared vm: shared
script: | script: |
if (state.enabled) "C"; else "B"; if (state.loadpoints[0].enabled) "C"; else "B";
maxcurrent: maxcurrent:
source: js source: js
vm: shared vm: shared
script: state.maxcurrent = val; script: |
set();
var lp = state.loadpoints[0];
lp.maxcurrent = val;
if (lp.enabled) lp.chargepower = lp.maxcurrent * 230 * lp.phases; else lp.chargepower = 0;
- name: charger_2
type: custom
enable:
source: js
vm: shared
script: |
set();
var lp = state.loadpoints[1];
lp.enabled = val;
if (lp.enabled) lp.chargepower = lp.maxcurrent * 230 * lp.phases; else lp.chargepower = 0;
enabled:
source: js
vm: shared
script: |
state.loadpoints[1].enabled;
status:
source: js
vm: shared
script: |
if (state.loadpoints[1].enabled) "C"; else "B";
maxcurrent:
source: js
vm: shared
script: |
set();
var lp = state.loadpoints[1];
lp.maxcurrent = val;
if (lp.enabled) lp.chargepower = lp.maxcurrent * 230 * lp.phases; else lp.chargepower = 0;
- name: charger_3
type: custom
enable:
source: js
vm: shared
script:
enabled:
source: js
vm: shared
script: false
status:
source: js
vm: shared
script: "'A'"
maxcurrent:
source: js
vm: shared
script: 16
vehicles: vehicles:
- name: demo - name: vehicle_1
title: e-Golf title: blauer e-Golf
type: custom type: custom
capacity: 50
soc: soc:
source: js source: js
vm: shared vm: shared
script: | script: |
if (state.chargepower > 0) state.battery++; else state.battery--; var lp = state.loadpoints[0];
if (state.battery < 15) state.battery = 15; if (lp.chargepower > 0) lp.vehicleSoC++; else lp.vehicleSoC--;
if (state.battery > 100) state.battery = 100; if (lp.vehicleSoC < 15) lp.vehicleSoC = 80;
state.battery; if (lp.vehicleSoC > 80) lp.vehicleSoC = 15;
lp.vehicleSoC;
range:
source: js
vm: shared
script: |
var lp = state.loadpoints[0]
var range = (44 * lp.vehicleSoC) / 15;
range
capacity: 44
- name: vehicle_2
title: weißes Model 3
type: custom
soc:
source: js
vm: shared
script: |
var lp = state.loadpoints[1];
if (lp.chargepower > 0) lp.vehicleSoC++; else lp.vehicleSoC--;
if (lp.vehicleSoC < 15) lp.vehicleSoC = 75;
if (lp.vehicleSoC > 75) lp.vehicleSoC = 15;
lp.vehicleSoC;
range:
source: js
vm: shared
script: |
var lp = state.loadpoints[1]
var range = (80 * lp.vehicleSoC) / 17;
range
capacity: 80
site: site:
title: Demo title: Zuhause
meters: meters:
grid: grid grid: grid
pv: pv pv: pv
@ -118,6 +204,24 @@ site:
loadpoints: loadpoints:
- title: Carport - title: Carport
charger: demo charger: charger_1
meter: charge mode: pv
vehicle: demo phases: 1
soc:
target: 90
meters:
charge: meter_charger_1
vehicle: vehicle_1
- title: Garage
charger: charger_2
soc:
target: 75
mode: "off"
meters:
charge: meter_charger_2
vehicle: vehicle_2
# - title: Garage rechts
# charger: charger_3
# mode: "off"
# meters:
# charge: meter_charger_3

Binary file not shown.

Binary file not shown.

6
dist/assets/index.5a1f45bf.css vendored Normal file

File diff suppressed because one or more lines are too long

93
dist/assets/index.a45f25da.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square70x70logo src="ico/mstile-70x70.png" />
<square150x150logo src="ico/mstile-150x150.png" />
<square310x310logo src="ico/mstile-310x310.png" />
<TileColor>#18191a</TileColor>
</tile>
</msapplication>
</browserconfig>

37
dist/index.html vendored
View file

@ -1,5 +1,38 @@
<!DOCTYPE html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="description" content="EV Charge Controller"><meta name="author" content="andig"><meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-status-bar-style" content="black"><link rel="apple-touch-icon" sizes="180x180" href="ico/apple-touch-icon.png?[[.Version]]"><link rel="icon" type="image/png" sizes="32x32" href="ico/favicon-32x32.png?[[.Version]]"><link rel="icon" type="image/png" sizes="16x16" href="ico/favicon-16x16.png?[[.Version]]"><link rel="manifest" href="ico/site.webmanifest"><link rel="mask-icon" href="ico/safari-pinned-tab.svg?[[.Version]]" color="#18191a"><link rel="shortcut icon" href="ico/favicon.ico?[[.Version]]"><meta name="apple-mobile-web-app-title" content="evcc"><meta name="application-name" content="evcc"><meta name="msapplication-TileColor" content="#18191a"><meta name="msapplication-config" content="ico/browserconfig.xml"><meta name="theme-color" content="#18191a"><title>evcc</title><link href="css/chunk-vendors.38c609a9.css" rel="preload" as="style"><link href="css/index.0a6b5a0e.css" rel="preload" as="style"><link href="js/chunk-vendors.b6901fd4.js" rel="preload" as="script"><link href="js/index.f02d5732.js" rel="preload" as="script"><link href="css/chunk-vendors.38c609a9.css" rel="stylesheet"><link href="css/index.0a6b5a0e.css" rel="stylesheet"></head><body><script>window.evcc = { <!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="evcc - Sonne tanken ☀️🚘" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="evcc" />
<meta name="application-name" content="evcc" />
<link rel="apple-touch-icon" sizes="180x180" href="meta/apple-touch-icon.png?[[.Version]]" />
<link rel="icon" type="image/png" sizes="32x32" href="meta/favicon-32x32.png?[[.Version]]" />
<link rel="icon" type="image/png" sizes="16x16" href="meta/favicon-16x16.png?[[.Version]]" />
<link rel="manifest" href="meta/site.webmanifest?[[.Version]]" />
<link rel="mask-icon" href="meta/safari-pinned-tab.svg?[[.Version]]" color="#28293e" />
<link rel="shortcut icon" href="meta/favicon.ico?[[.Version]]" />
<meta name="msapplication-TileColor" content="#28293e" />
<meta name="msapplication-config" content="meta/browserconfig.xml?[[.Version]]" />
<meta name="theme-color" content="#28293e" />
<title>evcc</title>
<script type="module" crossorigin src="./assets/index.a45f25da.js"></script>
<link rel="stylesheet" href="./assets/index.5a1f45bf.css">
</head>
<body>
<script>
window.evcc = {
version: "[[.Version]]", version: "[[.Version]]",
configured: "[[.Configured]]", configured: "[[.Configured]]",
commit: "[[.Commit]]", commit: "[[.Commit]]",
};</script><div id="app"></div><script src="js/chunk-vendors.b6901fd4.js"></script><script src="js/index.f02d5732.js"></script></body></html> };
</script>
<div id="app"></div>
</body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 4 KiB

After

Width:  |  Height:  |  Size: 4 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Before After
Before After

Some files were not shown because too many files have changed in this diff Show more