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: [
"eslint:recommended",
"plugin:vue/recommended",
"plugin:vue/vue3-recommended",
"plugin:prettier/recommended",
"prettier",
"plugin:storybook/recommended",
],
rules: {
"vue/require-default-prop": "off",

View file

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

1
.gitignore vendored
View file

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

View file

@ -1,4 +1,23 @@
const { loadConfigFromFile } = require("vite");
const path = require("path");
module.exports = {
stories: ["../assets/js/**/*.stories.mdx", "../assets/js/**/*.stories.@(js|jsx|ts|tsx)"],
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 = {
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
COPY assets assets
COPY vue.config.js vue.config.js
COPY vite.config.js vite.config.js
COPY .eslintrc.js .eslintrc.js
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).
## 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
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
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 {
--evcc-green: #66d85a;
--evcc-dark-green: #3aba2c;
--evcc-yellow: #ffe000;
--evcc-green: #baffcb;
--evcc-dark-green: #0fde41;
--evcc-yellow: #faf000;
--bs-gray-dark: #28293e;
--bs-gray-medium: #93949e;
--evcc-grid: var(--bs-gray-dark);
--evcc-self: var(--evcc-dark-green);
--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-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 {
@ -27,22 +80,33 @@ a {
opacity: 0.25;
}
.rounded {
border-radius: 10px !important;
}
.btn-primary,
.btn-primary:focus {
background-color: var(--bs-gray-dark);
border-color: var(--bs-gray-dark);
}
.btn-primary:hover,
.btn-primary:active {
background-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);
background-color: transparent;
border-width: 2px;
border-color: var(--bs-gray-dark);
}
.btn-outline-primary:hover {
background-color: var(--evcc-dark-green);
.btn-outline-primary:hover,
.btn-outline-primary:active {
color: var(--evcc-dark-green);
background-color: transparent;
border-width: 2px;
border-color: var(--evcc-dark-green);
}
.text-evcc {
@ -52,6 +116,18 @@ a {
.text-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) {
.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;
}
@ -69,3 +172,13 @@ a {
.v-popper__inner {
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>
<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="description" content="evcc - Sonne tanken ☀️🚘" />
<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-status-bar-style" content="default" />
<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" />
<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>
</head>
@ -32,5 +31,6 @@
</script>
<div id="app"></div>
<script type="module" src="./js/app.js"></script>
</body>
</html>

View file

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

View file

@ -1,58 +1,27 @@
<template>
<div :class="{ 'power--in': charge, 'power--out': discharge }">
<fa-icon class="battery" :icon="batteryIcon"></fa-icon
><fa-icon class="arrow" icon="angle-double-right"></fa-icon>
</div>
<component :is="`shopicon-regular-${icon}`"></component>
</template>
<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 {
name: "BatteryIcon",
props: {
discharge: { type: Boolean },
charge: { type: Boolean },
soc: { type: Number, default: 0 },
},
computed: {
batteryIcon: function () {
if (this.soc > 80) return "battery-full";
if (this.soc > 60) return "battery-three-quarters";
if (this.soc > 40) return "battery-half";
if (this.soc > 20) return "battery-quarter";
return "battery-empty";
icon: function () {
if (this.soc > 80) return "batteryfull";
if (this.soc > 60) return "batterythreequarters";
if (this.soc > 40) return "batteryhalf";
if (this.soc > 20) return "batteryquarter";
return "batteryempty";
},
},
};
</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 i18n from "../../i18n";
export default {
title: "Main/Energyflow",
@ -13,13 +12,19 @@ export default {
batteryPower: { control: { type: "range", min: -4000, max: 4000, step: 100 } },
batterySoC: { control: { type: "range", min: 0, max: 100, step: 1 } },
},
parameters: {
backgrounds: {
default: "light",
},
},
};
const Template = (args, { argTypes }) => ({
i18n,
props: Object.keys(argTypes),
const Template = (args) => ({
setup() {
return { args };
},
components: { Energyflow },
template: '<Energyflow v-bind="$props"></Energyflow>',
template: '<Energyflow v-bind="args"></Energyflow>',
});
export const GridAndPV = Template.bind({});

View file

@ -1,12 +1,12 @@
<template>
<div>
<div
class="row align-items-start align-items-md-center mt-4 energyflow"
class="energyflow cursor-pointer position-relative"
:class="{ 'energyflow--open': detailsOpen }"
@click="toggleDetails"
>
<div class="row">
<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"
:showDetails="showDetails"
class="col-12 mb-3 mb-md-4"
:gridImport="gridImport"
:selfConsumption="selfConsumption"
:loadpoints="loadpointsPower"
@ -18,103 +18,117 @@
:batterySoC="batterySoC"
:valuesInKw="valuesInKw"
/>
</div>
<div class="indicator position-absolute bottom-0 start-50">
<shopicon-regular-arrowdown></shopicon-regular-arrowdown>
</div>
<div class="details" :style="{ height: detailsHeight }">
<div ref="detailsInner" class="details-inner row">
<div class="col-12 d-flex justify-content-between pt-2 mb-4">
<div class="d-flex flex-nowrap align-items-center">
<span class="color-self me-2"
><shopicon-filled-square></shopicon-filled-square
></span>
<span>{{ $t("main.energyflow.selfConsumption") }}</span>
</div>
<div v-if="gridImport > 0" class="d-flex flex-nowrap align-items-center">
<span>{{ $t("main.energyflow.gridImport") }}</span>
<span class="color-grid ms-2"
><shopicon-filled-square></shopicon-filled-square
></span>
</div>
<div v-else class="d-flex flex-nowrap align-items-center">
<span>{{ $t("main.energyflow.pvExport") }}</span>
<span class="color-export ms-2"
><shopicon-filled-square></shopicon-filled-square
></span>
</div>
</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"
:class="`${showDetails ? 'd-block' : `d-none d-md-block`}`"
class="col-12 col-md-6 pe-md-5 pb-4 d-flex flex-column justify-content-between"
>
<div class="d-flex justify-content-between" data-test-pv-production>
<span class="details-icon text-muted"><fa-icon icon="sun"></fa-icon></span>
<span class="text-nowrap flex-grow-1">{{
$t("main.energyflow.pvProduction")
}}</span>
<span class="text-end text-nowrap ps-1">{{ kw(pvProduction) }}</span>
<div class="d-flex justify-content-between align-items-end mb-4">
<h3 class="m-0">In</h3>
<span class="fw-bold text-gray-medium">{{ kw(inPower) }}</span>
</div>
<div class="d-flex justify-content-between" data-test-home-power>
<span class="details-icon text-muted"><fa-icon icon="home"></fa-icon></span>
<span class="text-nowrap flex-grow-1">{{
$t("main.energyflow.homePower")
}}</span>
<span class="text-end text-nowrap ps-1">{{ kw(homePower) }}</span>
<div>
<EnergyflowEntry
:name="$t('main.energyflow.pvProduction')"
icon="sun"
:power="pvProduction"
:valuesInKw="valuesInKw"
/>
<EnergyflowEntry
v-if="batteryConfigured"
:name="$t('main.energyflow.batteryDischarge')"
icon="battery"
:soc="batterySoC"
:power="batteryDischarge"
:valuesInKw="valuesInKw"
/>
<EnergyflowEntry
:name="$t('main.energyflow.gridImport')"
icon="powersupply"
:power="gridImport"
:valuesInKw="valuesInKw"
/>
</div>
<div class="d-flex justify-content-between" data-test-loadpoints>
<span class="details-icon text-muted"><fa-icon icon="car"></fa-icon></span>
<span class="text-nowrap flex-grow-1">{{
$tc("main.energyflow.loadpoints", activeLoadpointsCount, {
</div>
<div
class="col-12 col-md-6 ps-md-5 pb-4 d-flex flex-column justify-content-between"
>
<div class="d-flex justify-content-between align-items-end mb-4">
<h3 class="m-0">Out</h3>
<span class="fw-bold text-gray-medium">{{ kw(outPower) }}</span>
</div>
<div>
<EnergyflowEntry
:name="$t('main.energyflow.homePower')"
icon="home"
:power="homePower"
:valuesInKw="valuesInKw"
/>
<EnergyflowEntry
:name="
$tc('main.energyflow.loadpoints', activeLoadpointsCount, {
count: activeLoadpointsCount,
})
}}</span>
<span class="text-end text-nowrap ps-1">{{ kw(loadpointsPower) }}</span>
</div>
<div
v-if="batteryConfigured"
class="d-flex justify-content-between"
data-test-battery
>
<span class="details-icon text-muted">
<BatteryIcon
:soc="batterySoC"
:charge="batteryCharge > 0"
:discharge="batteryDischarge > 0"
"
icon="car3"
:power="loadpointsPower"
:valuesInKw="valuesInKw"
/>
<EnergyflowEntry
v-if="batteryConfigured"
:name="$t('main.energyflow.batteryCharge')"
icon="battery"
:soc="batterySoC"
:power="batteryCharge"
:valuesInKw="valuesInKw"
/>
<EnergyflowEntry
:name="$t('main.energyflow.pvExport')"
icon="powersupply"
:power="pvExport"
:valuesInKw="valuesInKw"
/>
</span>
<span class="text-nowrap flex-grow-1 text-truncate">
<span v-if="batteryCharge">{{ $t("main.energyflow.batteryCharge") }}</span>
<span v-else-if="batteryDischarge">{{
$t("main.energyflow.batteryDischarge")
}}</span>
<span v-else>{{ $t("main.energyflow.battery") }}</span>
</span>
<span class="text-end text-nowrap ps-1">
{{ batterySoC }}% /
{{ kw(Math.abs(batteryPower)) }}
</span>
</div>
</div>
<div
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="text-nowrap d-flex d-md-block" data-test-grid-import>
<span class="color-grid details-icon"><fa-icon icon="square"></fa-icon></span>
<span class="text-nowrap flex-grow-1">{{
$t("main.energyflow.gridImport")
}}</span>
<span class="text-end text-nowrap d-md-none">
{{ kw(gridImport) }}
</span>
</div>
<div class="text-nowrap d-flex d-md-block" data-test-self-consumption>
<span class="color-self details-icon"><fa-icon icon="square"></fa-icon></span>
<span class="text-nowrap flex-grow-1">{{
$t("main.energyflow.selfConsumption")
}}</span>
<span class="text-end text-nowrap d-md-none">
{{ kw(selfConsumption) }}
</span>
</div>
<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>
<span class="text-nowrap flex-grow-1">{{
$t("main.energyflow.pvExport")
}}</span>
<span class="text-end text-nowrap d-md-none">
{{ kw(pvExport) }}
</span>
</div>
</div>
</div>
</div>
</template>
<script>
import "../../icons";
import formatter from "../../mixins/formatter";
import "@h2d2/shopicons/es/filled/square";
import "@h2d2/shopicons/es/regular/arrowdown";
import Visualization from "./Visualization.vue";
import BatteryIcon from "./BatteryIcon.vue";
import EnergyflowEntry from "./EnergyflowEntry.vue";
import formatter from "../../mixins/formatter";
export default {
name: "Energyflow",
components: { Visualization, BatteryIcon },
components: { Visualization, EnergyflowEntry },
mixins: [formatter],
props: {
gridConfigured: Boolean,
@ -128,8 +142,8 @@ export default {
batteryPower: { type: Number, default: 0 },
batterySoC: { type: Number, default: 0 },
},
data: function () {
return { showDetails: false };
data: () => {
return { detailsOpen: false, detailsCompleteHeight: null };
},
computed: {
gridImport: function () {
@ -143,10 +157,10 @@ export default {
return Math.abs(this.batteryPower) < batteryPowerThreshold ? 0 : this.batteryPower;
},
batteryDischarge: function () {
return Math.max(0, this.batteryPowerAdjusted);
return Math.abs(Math.max(0, this.batteryPowerAdjusted));
},
batteryCharge: function () {
return Math.min(0, this.batteryPowerAdjusted) * -1;
return Math.abs(Math.min(0, this.batteryPowerAdjusted) * -1);
},
selfConsumption: function () {
const ownPower = this.batteryDischarge + this.pvProduction;
@ -159,20 +173,60 @@ export default {
valuesInKw: function () {
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: {
kw: function (watt) {
return this.fmtKw(watt, this.valuesInKw);
},
toggleDetails() {
this.showDetails = !this.showDetails;
toggleDetails: function () {
this.updateHeight();
this.detailsOpen = !this.detailsOpen;
},
updateHeight: function () {
this.detailsCompleteHeight = this.$refs.detailsInner.offsetHeight;
},
},
};
</script>
<style scoped>
.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: var(--evcc-grid);
@ -183,11 +237,4 @@ export default {
.color-export {
color: var(--evcc-export);
}
.details-icon {
text-align: center;
width: 30px;
margin-right: 0.25rem;
white-space: nowrap;
flex-shrink: 0;
}
</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 {
width: 0;
margin: 0;
height: 1.7rem;
padding: 0.6rem 0;
padding: 10px 0;
opacity: 1;
overflow: hidden;
}
@ -44,51 +43,50 @@ export default {
opacity: 0;
}
.label-bar-scale {
border: 1px solid var(--bs-gray);
height: 0.5rem;
border: 1px solid var(--bs-gray-medium);
height: 14px;
background: none;
display: flex;
justify-content: center;
align-items: center;
white-space: nowrap;
border-radius: 0;
transition: border-radius 500ms linear;
transition: border-radius var(--evcc-transition-medium) linear;
}
.label-bar--top .label-bar-scale {
border-top-left-radius: 10px;
border-top-right-radius: 10px;
border-bottom: none;
}
.label-bar--bottom .label-bar-scale {
border-bottom-left-radius: 10px;
border-bottom-right-radius: 10px;
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 {
background-color: white;
color: var(--bs-gray);
padding: 0 0.3rem;
opacity: 1;
transform: scale(1);
color: var(--bs-gray-dark);
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 {
margin-top: -6px;
margin-top: -12px;
}
.label-bar--bottom .label-bar-icon {
margin-top: 6px;
margin-top: 12px;
}
.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 {
opacity: 0;

View file

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

View file

@ -1,5 +1,4 @@
import Footer from "./Footer.vue";
import i18n from "../i18n";
export default {
title: "Main/Footer",
@ -7,11 +6,12 @@ export default {
argTypes: {},
};
const Template = (args, { argTypes }) => ({
i18n,
props: Object.keys(argTypes),
const Template = (args) => ({
setup() {
return { args };
},
components: { Footer },
template: '<Footer v-bind="$props"></Footer>',
template: '<Footer v-bind="args"></Footer>',
});
export const KeinUpdate = Template.bind({});
@ -84,18 +84,3 @@ Upgrade.args = {
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>
<footer class="container">
<div class="py-3 py-md-5 mt-3 mt-md-5 border-top">
<footer class="footer">
<div class="container py-3">
<div class="d-flex justify-content-between">
<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. -->
<Savings v-bind="savings" :sponsor="sponsor" />
</div>
@ -11,8 +19,10 @@
</template>
<script>
import Version from "./Version";
import Savings from "./Savings";
import "@h2d2/shopicons/es/filled/testtube";
import Version from "./Version.vue";
import Savings from "./Savings.vue";
export default {
name: "Footer",
@ -22,5 +32,22 @@ export default {
sponsor: String,
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>
<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 i18n from "../i18n";
export default {
title: "Main/Loadpoint",
@ -11,11 +10,12 @@ export default {
},
};
const Template = (args, { argTypes }) => ({
i18n,
props: Object.keys(argTypes),
const Template = (args) => ({
setup() {
return { args };
},
components: { Loadpoint },
template: '<Loadpoint v-bind="$props"></Loadpoint>',
template: '<Loadpoint v-bind="args"></Loadpoint>',
});
export const Base = Template.bind({});
@ -29,9 +29,29 @@ Base.args = {
vehicleTitle: "Mein Auto",
enabled: true,
connected: true,
mode: "pv",
charging: true,
vehicleSoC: 66,
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({});
@ -42,9 +62,16 @@ Disabled.args = {
remoteDisabledSource: "Sunny Home Manager",
vehiclePresent: true,
vehicleTitle: "Mein Auto",
chargedEnergy: 31211,
enabled: true,
mode: "now",
connected: true,
charging: false,
chargePower: 8112,
vehicleSoC: 66,
targetSoC: 100,
chargeCurrent: 7,
minCurrent: 6,
maxCurrent: 16,
activePhases: 3,
};

View file

@ -1,40 +1,92 @@
<template>
<div>
<p class="h3 mb-4 d-sm-block" :class="{ 'd-none': single }">
<div class="loadpoint bg-white p-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h3 class="mb-3 me-2 text-truncate">
{{ title || $t("main.loadpoint.fallbackName") }}
</p>
<div v-if="remoteDisabled == 'soft'" class="alert alert-warning mt-4 mb-2" role="alert">
{{ $t("main.loadpoint.remoteDisabledSoft", { source: remoteDisabledSource }) }}
</h3>
<div class="mb-3 d-flex align-items-center">
<Mode :mode="mode" @updated="setTargetMode" />
<button v-if="$hiddenFeatures" class="btn btn-link text-gray p-0 flex-shrink-0">
<shopicon-filled-options size="s"></shopicon-filled-options>
</button>
</div>
<div v-if="remoteDisabled == 'hard'" class="alert alert-danger mt-4 mb-2" role="alert">
{{ $t("main.loadpoint.remoteDisabledHard", { source: remoteDisabledSource }) }}
</div>
<div class="row">
<Mode class="col-12 col-md-6 col-lg-4 mb-4" :mode="mode" @updated="setTargetMode" />
<div
v-if="remoteDisabled"
class="alert alert-warning my-4 py-2"
:class="`${remoteDisabled === 'hard' ? 'alert-danger' : 'alert-warning'}`"
role="alert"
>
{{
$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>
<Vehicle
class="col-12 col-md-6 col-lg-8 mb-4"
v-bind="vehicle"
@target-soc-updated="setTargetSoC"
@target-time-updated="setTargetTime"
@target-time-removed="removeTargetTime"
/>
</div>
<LoadpointDetails v-bind="details" />
</div>
</template>
<script>
import "@h2d2/shopicons/es/regular/lightning";
import "@h2d2/shopicons/es/filled/options";
import api from "../api";
import Mode from "./Mode";
import Vehicle from "./Vehicle";
import LoadpointDetails from "./LoadpointDetails";
import Mode from "./Mode.vue";
import Vehicle from "./Vehicle.vue";
import Phases from "./Phases.vue";
import LabelAndValue from "./LabelAndValue.vue";
import formatter from "../mixins/formatter";
import collector from "../mixins/collector";
export default {
name: "Loadpoint",
components: { LoadpointDetails, Mode, Vehicle },
components: { Mode, Vehicle, Phases, LabelAndValue },
mixins: [formatter, collector],
props: {
id: Number,
@ -60,6 +112,7 @@ export default {
minSoC: Number,
targetTime: String,
targetTimeActive: Boolean,
targetTimeProjectedStart: String,
vehicleProviderLoggedIn: Boolean,
vehicleProviderLoginPath: String,
vehicleProviderLogoutPath: String,
@ -87,15 +140,58 @@ export default {
pvRemaining: Number,
pvAction: String,
},
data() {
return {
tickerHandler: null,
phaseRemainingInterpolated: this.phaseRemaining,
pvRemainingInterpolated: this.pvRemaining,
chargeDurationInterpolated: this.chargeDuration,
chargeRemainingDurationInterpolated: this.chargeRemainingDuration,
};
},
computed: {
details: function () {
return this.collectProps(LoadpointDetails);
phasesProps: function () {
return this.collectProps(Phases);
},
vehicle: function () {
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: {
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) {
return "loadpoints/" + this.id + "/" + func;
},
@ -115,3 +211,24 @@ export default {
},
};
</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 i18n from "../i18n";
import "../tooltip";
export default {
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 i18n from "../i18n";
export default {
title: "Main/Mode",
component: Mode,
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 }) => ({
i18n,
props: Object.keys(argTypes),
const Template = (args) => ({
setup() {
return { args };
},
components: { Mode },
template: '<Mode v-bind="$props"></Mode>',
template: '<Mode v-bind="args"></Mode>',
});
export const Base = Template.bind({});
Base.args = {};
export const CaptionAndPV = Template.bind({});
CaptionAndPV.args = {
caption: true,
pvConfigured: true,
mode: "pv",
};
Base.args = { mode: "pv" };

View file

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

View file

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

View file

@ -5,17 +5,20 @@
href="#"
data-bs-toggle="modal"
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>
<div
id="notificationModal"
class="modal fade"
class="modal fade text-dark"
tabindex="-1"
role="dialog"
aria-hidden="true"
data-bs-backdrop="true"
>
<div
class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable"
@ -40,14 +43,13 @@
{{ fmtTimeAgo(msg.time - new Date()) }}
</small>
<p class="d-flex align-items-baseline">
<fa-icon
<shopicon-regular-exclamationtriangle
:class="{
'text-danger': msg.type === 'error',
'text-warning': msg.type === 'warn',
}"
class="flex-grow-0 d-block"
icon="exclamation-triangle"
></fa-icon>
></shopicon-regular-exclamationtriangle>
<span class="flex-grow-1 px-2 py-1 text-break">
{{ msg.message }}
</span>
@ -75,7 +77,7 @@
</template>
<script>
import "../icons";
import "@h2d2/shopicons/es/regular/exclamationtriangle";
import formatter from "../mixins/formatter";
export default {
@ -99,7 +101,7 @@ export default {
this.$forceUpdate();
}, 10 * 1000);
},
destroyed: function () {
unmounted: function () {
clearTimeout(this.interval);
},
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>
<div>
<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-target="#savingsModal"
>
@ -10,36 +10,38 @@
}}</span
><span class="d-none d-sm-inline">{{
$t("footer.savings.footerLong", { percent })
}}</span
><fa-icon icon="sun" class="icon ms-2 text-evcc"></fa-icon>
}}</span>
<shopicon-regular-sun class="ms-2 text-evcc"></shopicon-regular-sun>
</button>
<Teleport to="body">
<div
id="savingsModal"
ref="modal"
class="modal fade"
class="modal fade text-dark"
data-bs-backdrop="true"
tabindex="-1"
role="dialog"
aria-hidden="true"
>
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable" role="document">
<div
class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable"
role="document"
>
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<span class="d-block d-sm-none">
<span class="text-nowrap">
{{
$t("footer.savings.modalTitleShort", {
percent,
$t("footer.savings.modalTitlePartOne", {
total: fmtKw(totalCharged * 1000, true, false),
})
}}
·
</span>
<span class="d-none d-sm-block">
{{
$t("footer.savings.modalTitleLong", {
percent,
total: fmtKw(totalCharged * 1000, true, false),
})
}}
{{ " " }}
<span class="text-nowrap">
{{ $t("footer.savings.modalTitlePartTwo", { percent }) }}
</span>
</h5>
<button
@ -51,44 +53,62 @@
</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>
<div
class="chart-legend d-flex flex-wrap justify-content-between mb-1"
>
<div
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),
self: fmtKw(
selfConsumptionCharged * 1000,
true,
false
),
})
}}
</div>
<div class="text-nowrap">
<fa-icon icon="square" class="text-grid"></fa-icon>
<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="totalCharged > 0"
class="chart-item chart-item--self d-flex justify-content-center text-white flex-shrink-1"
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="totalCharged > 0"
class="chart-item chart-item--grid d-flex justify-content-center text-white flex-shrink-1"
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="totalCharged === 0"
class="chart-item chart-item--no-data d-flex justify-content-center text-white w-100"
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>
@ -106,7 +126,7 @@
<a
href="https://docs.evcc.io/docs/guides/setup/#ersparnisberechnung"
target="_blank"
class="text-muted"
class="small"
>
{{ $t("footer.savings.modalExplaination") }}</a
>:
@ -134,30 +154,20 @@
<hr class="mb-4" />
<Sponsor :sponsor="sponsor" class="mb-4" />
<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>
<Sponsor :sponsor="sponsor" />
</div>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script>
import formatter from "../mixins/formatter";
import Sponsor from "./Sponsor.vue";
import "@h2d2/shopicons/es/filled/square";
import "@h2d2/shopicons/es/regular/sun";
export default {
name: "Savings",
@ -180,6 +190,9 @@ export default {
percent() {
return Math.round(this.selfConsumptionPercent) || 0;
},
noData() {
return this.totalCharged === 0;
},
},
methods: {
secondsSinceStart() {
@ -196,7 +209,7 @@ export default {
margin-bottom: 0;
}
.modal.fade .modal-dialog {
transition: transform 0.4s ease;
transition: transform var(--evcc-transition-medium) ease;
transform: translate(0, 150px);
}
.modal.show .modal-dialog {
@ -206,12 +219,13 @@ export default {
height: calc(100% - 0.5rem);
}
.modal-content {
border-radius: 1rem 1rem 0 0;
border-end-end-radius: 0;
border-end-start-radius: 0;
}
}
.chart {
height: 1.6rem;
height: 2.5rem;
}
.chart-item--self {
@ -221,12 +235,12 @@ export default {
background-color: var(--evcc-grid);
}
.chart-item--no-data {
background-color: var(--bs-gray);
background-color: var(--bs-gray-medium);
}
.chart-item {
transition-property: width;
transition-duration: 500ms;
transition-duration: var(--evcc-transition-medium);
transition-timing-function: linear;
}
</style>

View file

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

View file

@ -1,37 +1,45 @@
<template>
<div class="flex-grow-1 d-flex flex-column">
<h3 class="d-none d-md-block my-4">
{{ siteTitle || "Home" }}
</h3>
<div class="d-flex flex-column site">
<div class="container px-4 top-area">
<div class="d-flex justify-content-between align-items-center my-3">
<h1 class="d-block my-0">
{{ siteTitle || "evcc" }}
</h1>
<div class="d-flex">
<Notifications :notifications="notifications" class="me-2" />
<TopNavigation v-bind="topNavigation" />
</div>
</div>
<Energyflow v-bind="energyflow" />
<hr class="w-100 my-4" />
<div class="flex-grow-1 d-flex justify-content-around flex-column">
<template v-for="(loadpoint, id) in loadpoints">
<hr v-if="id > 0" :key="id + '_hr'" class="w-100 my-4" />
<Loadpoint
v-bind="loadpoint"
:id="id"
:key="id"
:single="loadpoints.length === 1"
/>
</template>
</div>
<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" />
<Vehicles v-if="$hiddenFeatures" />
<Footer v-bind="footer"></Footer>
</div>
</div>
</template>
<script>
import Energyflow from "./Energyflow";
import Loadpoint from "./Loadpoint";
import "@h2d2/shopicons/es/regular/arrowup";
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 collector from "../mixins/collector";
export default {
name: "Site",
components: { Loadpoint, Energyflow },
components: { Loadpoints, Energyflow, Footer, Notifications, TopNavigation, Vehicles },
mixins: [formatter, collector],
props: {
loadpoints: Array,
notifications: Array,
// details
gridConfigured: Boolean,
gridPower: Number,
@ -44,6 +52,30 @@ export default {
gridCurrents: Array,
prioritySoC: Number,
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: {
energyflow: function () {
@ -58,6 +90,46 @@ export default {
return sum;
}, 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>
<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>
<div v-if="sponsor">
<div v-if="sponsor || true">
<p class="mb-3">
{{ $t("footer.sponsor.thanks", { sponsor }) }}
</p>
<div class="d-flex justify-content-center flex-column">
<div class="d-flex justify-content-center align-items-center flex-column">
<button
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"
>
<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") }}
</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") }}
</a>
</div>
@ -21,13 +21,13 @@
<p class="mb-3">
{{ $t("footer.sponsor.supportUs") }}
</p>
<div class="d-flex justify-content-center flex-column">
<div class="d-flex justify-content-center align-items-center flex-column">
<a
target="_blank"
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") }}
</a>
<div class="small text-muted text-center">
@ -39,6 +39,8 @@
<script>
import confetti from "canvas-confetti";
import "@h2d2/shopicons/es/regular/heart";
import "@h2d2/shopicons/es/filled/heart";
export default {
name: "Sponsor",

View file

@ -1,19 +1,30 @@
<template>
<div>
<LabelAndValue class="flex-grow-1" :label="$t('main.targetCharge.title')">
<button
class="target-time-button btn btn-link btn-sm pe-0"
:class="{
invisible: !targetSoC,
'text-dark': targetTimeActive,
'text-secondary': !targetTimeActive,
}"
class="btn btn-link p-0 value text-end"
:class="buttonIsEnabled ? 'text-white' : 'text-gray'"
data-bs-toggle="modal"
:data-bs-target="`#${modalId}`"
>
{{ targetTimeLabel() }}<fa-icon class="ms-1" icon="clock"></fa-icon>
<strong v-if="targetChargeEnabled">{{ targetTimeLabel() }}</strong>
<span v-else>{{ $t("main.targetCharge.setTargetTime") }}</span>
</button>
<div :id="modalId" class="modal fade" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable" role="document">
</LabelAndValue>
<Teleport to="body">
<div
:id="modalId"
class="modal fade text-dark"
data-bs-backdrop="true"
tabindex="-1"
role="dialog"
aria-hidden="true"
>
<div
class="modal-dialog modal-dialog-centered modal-dialog-scrollable"
role="document"
>
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
@ -30,11 +41,8 @@
<div class="modal-body">
<div class="form-group">
<!-- eslint-disable vue/no-v-html -->
<label
for="targetTimeLabel"
class="mb-3"
v-html="$t('main.targetCharge.description', { targetSoC })"
>
<label for="targetTimeLabel" class="mb-3">
{{ $t("main.targetCharge.description", { targetSoC }) }}
</label>
<!-- eslint-enable vue/no-v-html -->
<div
@ -67,9 +75,8 @@
<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>
<p class="small mt-3 text-muted mb-0">
<strong class="text-evcc">
{{ $t("main.targetCharge.experimentalLabel") }}:
</strong>
{{ $t("main.targetCharge.experimentalText") }}
@ -102,10 +109,15 @@
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script>
import "@h2d2/shopicons/es/filled/plus";
import "@h2d2/shopicons/es/filled/edit";
import LabelAndValue from "./LabelAndValue.vue";
import formatter from "../mixins/formatter";
const DEFAULT_TARGET_TIME = "7:00";
@ -113,13 +125,16 @@ const LAST_TARGET_TIME_KEY = "last_target_time";
export default {
name: "TargetCharge",
components: { LabelAndValue },
mixins: [formatter],
props: {
id: Number,
id: [String, Number],
targetTime: String,
targetTimeActive: Boolean,
targetSoC: Number,
disabled: Boolean,
},
emits: ["target-time-updated", "target-time-removed"],
data: function () {
return { selectedDay: null, selectedTime: null };
},
@ -127,6 +142,9 @@ export default {
targetChargeEnabled: function () {
return this.targetTime;
},
buttonIsEnabled: function () {
return this.targetChargeEnabled && !this.disabled;
},
selectedTargetTimeValid: function () {
const now = new Date();
return now < this.selectedTargetTime;
@ -139,6 +157,12 @@ export default {
},
},
watch: {
targetTimeLabel: function () {
const targetDate = new Date(this.targetTime);
return this.$t("main.targetCharge.activeLabel", {
time: this.fmtAbsoluteDate(targetDate),
});
},
targetTime() {
this.initInputFields();
},
@ -223,3 +247,15 @@ export default {
},
};
</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 i18n from "../i18n";
export default {
title: "Main/Vehicle",
component: Vehicle,
};
const Template = (args, { argTypes }) => ({
i18n,
props: Object.keys(argTypes),
const Template = (args) => ({
setup() {
return { args };
},
components: { Vehicle },
template: '<Vehicle v-bind="$props"></Vehicle>',
template: '<Vehicle v-bind="args"></Vehicle>',
});
export const Base = Template.bind({});
@ -20,6 +20,7 @@ Base.args = {
connected: true,
vehiclePresent: true,
vehicleSoC: 42,
vehicleRange: 231,
targetSoC: 90,
id: 0,
};
@ -58,7 +59,6 @@ Charging.args = {
vehicleSoC: 66,
targetSoC: 90,
id: 0,
targetTimeHourSuggestion: null,
};
const hoursFromNow = function (hours) {
@ -110,6 +110,38 @@ MinCharge.args = {
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({});
UnknownVehicleConnected.args = {
vehicleTitle: "Mein Auto",

View file

@ -1,52 +1,118 @@
<template>
<div>
<div class="mb-3">
{{ vehicleTitle || $t("main.vehicle.fallbackName") }}
</div>
<VehicleSoc v-bind="vehicleSocProps" @target-soc-updated="targetSocUpdated" />
<VehicleSubline
v-bind="vehicleSubline"
class="my-1"
<div class="vehicle p-4 pb-3">
<VehicleTitle v-bind="vehicleTitleProps" />
<VehicleStatus v-if="!parked" v-bind="vehicleStatus" class="mb-2" />
<VehicleSoc
v-bind="vehicleSocProps"
class="mt-2 mb-4"
@target-soc-updated="targetSocUpdated"
@target-soc-drag="targetSocDrag"
/>
<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>
</template>
<script>
import collector from "../mixins/collector";
import VehicleSoc from "./VehicleSoc";
import VehicleSubline from "./VehicleSubline";
import LabelAndValue from "./LabelAndValue.vue";
import VehicleTitle from "./VehicleTitle.vue";
import VehicleSoc from "./VehicleSoc.vue";
import VehicleStatus from "./VehicleStatus.vue";
import TargetCharge from "./TargetCharge.vue";
export default {
name: "Vehicle",
components: { VehicleSoc, VehicleSubline },
components: { VehicleTitle, VehicleSoc, VehicleStatus, LabelAndValue, TargetCharge },
mixins: [collector],
props: {
id: Number,
id: [String, Number],
connected: Boolean,
vehiclePresent: Boolean,
vehicleSoC: Number,
enabled: Boolean,
charging: Boolean,
minSoC: Number,
vehicleRange: Number,
vehicleTitle: String,
targetTimeActive: Boolean,
targetTimeHourSuggestion: Number,
targetTime: String,
targetTimeProjectedStart: String,
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: {
vehicleSocProps: function () {
return this.collectProps(VehicleSoc);
},
vehicleSubline: function () {
return this.collectProps(VehicleSubline);
vehicleStatus: function () {
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: {
targetSocDrag: function (targetSoC) {
this.displayTargetSoC = targetSoC;
},
targetSocUpdated: function (targetSoC) {
this.displayTargetSoC = targetSoC;
this.$emit("target-soc-updated", targetSoC);
},
setTargetTime: function (targetTime) {
@ -58,3 +124,18 @@ export default {
},
};
</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="progress">
<div
v-if="connected || parked"
class="progress-bar"
role="progressbar"
:class="{
[progressColor]: true,
'progress-bar-striped': charging,
'progress-bar-animated': charging,
[progressColor]: true,
}"
:style="{ width: `${vehicleSoCDisplayWidth}%` }"
>
{{ vehicleSoCDisplayValue }}
</div>
></div>
<div
v-if="remainingSoCWidth > 0 && enabled"
class="progress-bar"
v-if="remainingSoCWidth > 0 && enabled && connected"
class="progress-bar bg-muted"
role="progressbar"
:class="{
[progressColor]: true,
'bg-muted': true,
}"
:class="progressColor"
:style="{ width: `${remainingSoCWidth}%`, transition: 'none' }"
></div>
</div>
<div
class="target"
:class="{ 'target--slider-hidden': allowSliderHiding && visibleTargetSoC === 100 }"
>
<div class="target">
<div
class="target-label d-flex align-items-center justify-content-center"
:style="{ left: `${visibleTargetSoC}%` }"
>
{{ visibleTargetSoC }}%
</div>
></div>
<input
v-if="vehiclePresent && (connected || parked)"
type="range"
min="0"
max="100"
@ -62,11 +54,12 @@ export default {
charging: Boolean,
minSoC: Number,
targetSoC: Number,
parked: Boolean,
},
emits: ["target-soc-drag", "target-soc-updated"],
data: function () {
return {
selectedTargetSoC: null,
allowSliderHiding: false,
interactionStartScreenY: null,
};
},
@ -77,31 +70,7 @@ export default {
}
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 () {
if (!this.connected) {
return "bg-light border";
}
if (this.minSoCActive) {
return "bg-danger";
}
@ -131,11 +100,6 @@ export default {
this.selectedTargetSoC = this.targetSoC;
},
},
mounted: function () {
setTimeout(() => {
this.allowSliderHiding = true;
}, 1000);
},
methods: {
changeTargetSoCStart: function (e) {
const screenY = e.screenY || e.changedTouches[0].screenY;
@ -165,6 +129,8 @@ export default {
return false;
}
this.selectedTargetSoC = e.target.value;
this.$emit("target-soc-drag", this.selectedTargetSoC);
return true;
},
},
@ -172,10 +138,9 @@ export default {
</script>
<style scoped>
.vehicle-soc {
--height: 38px;
--thumb-overlap: 3px;
--thumb-width: 3px;
--thumb-horizontal-padding: 15px;
--height: 32px;
--thumb-overlap: 6px;
--thumb-width: 12px;
--label-height: 26px;
position: relative;
height: var(--height);
@ -199,16 +164,13 @@ export default {
text-align: center;
color: var(--bs-gray-dark);
font-size: 1rem;
opacity: 1;
transition: opacity 0.2s ease 1s;
}
.target-slider {
-webkit-appearance: none;
position: absolute;
top: calc(var(--thumb-overlap) * -1);
left: calc(var(--thumb-horizontal-padding) * -1);
height: calc(100% + 2 * var(--thumb-overlap));
width: calc(100% + 2 * var(--thumb-horizontal-padding));
width: 100%;
background: transparent;
}
.target-slider:focus {
@ -231,50 +193,25 @@ export default {
.target-slider::-webkit-slider-thumb {
-webkit-appearance: none;
position: relative;
top: calc(var(--label-height) * -1);
margin-left: var(--thumb-width) / 2;
height: 100%;
width: var(--thumb-width);
padding: var(--label-height) var(--thumb-horizontal-padding) 0;
box-sizing: content-box;
background-clip: content-box;
background-color: var(--bs-gray-dark);
background-color: var(--evcc-dark-green);
cursor: grab;
border: none;
opacity: 1;
transition: opacity 0.2s ease 1s;
box-shadow: none;
border-radius: var(--thumb-overlap);
box-shadow: 0 0 6px var(--bs-gray-dark);
}
.target-slider::-moz-range-thumb {
position: relative;
top: calc(var(--label-height) * -1);
height: 100%;
width: var(--thumb-width);
padding: 0 var(--thumb-horizontal-padding) 0;
box-sizing: content-box;
background-clip: content-box;
background-color: var(--bs-gray-dark);
background-color: var(--evcc-dark-green);
cursor: grab;
border: none;
opacity: 1;
transition: opacity 0.5s ease 1s;
}
/* 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;
border-radius: var(--thumb-overlap);
box-shadow: 0 0 6px var(--bs-gray-dark);
}
</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 i18n from "../i18n";
export default {
title: "Main/Footer/Version",
@ -7,11 +6,12 @@ export default {
argTypes: {},
};
const Template = (args, { argTypes }) => ({
i18n,
props: Object.keys(argTypes),
const Template = (args) => ({
setup() {
return { args };
},
components: { Version },
template: '<Version v-bind="$props"></Version>',
template: '<Version v-bind="args"></Version>',
});
const releaseNotes =

View file

@ -4,23 +4,25 @@
v-if="commit"
:href="githubHashUrl"
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 }}
<span class="text-muted">[{{ commit }}]</span>
<shopicon-regular-moonstars class="me-2 text-gray-medium"></shopicon-regular-moonstars>
v{{ installed }}
<span class="ms-2 text-gray-medium">[{{ commit }}]</span>
</a>
<button
v-else-if="newVersionAvailable"
href="#"
data-bs-toggle="modal"
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 }}
<span class="d-none d-xs-inline d-sm-none text-muted text-decoration-underline">
<shopicon-regular-gift class="me-2 text-evcc"></shopicon-regular-gift>
v{{ installed }}
<span class="ms-2 d-none d-xs-block d-sm-none text-decoration-underline">
{{ $t("footer.version.availableShort") }}
</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") }}
</span>
</button>
@ -28,17 +30,19 @@
v-else
:href="releaseNotesUrl(installed)"
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">{{
$t("footer.version.versionShort", { installed })
}}</span>
<span class="d-none d-xs-inline d-sm-inline">{{
$t("footer.version.versionLong", { installed })
}}</span>
<Logo class="logo me-2" />
v{{ installed }}
</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-content">
<div class="modal-header">
@ -119,10 +123,14 @@
<script>
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 {
name: "Version",
components: { Logo },
props: {
installed: String,
available: String,
@ -172,4 +180,8 @@ export default {
.icon {
color: var(--evcc-dark-green);
}
.logo {
height: 1.1rem;
margin-bottom: 0.2rem;
}
</style>

View file

@ -50,7 +50,7 @@ export default {
}
}
.root >>> svg {
.root ::v-deep(svg) {
display: block;
}
</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",
blog: "Blog",
github: "GitHub",
login: "Anmeldungen",
login: "Fahrzeug Logins",
about: "Über evcc",
},
footer: {
version: {
versionShort: "v{installed}",
versionLong: "Version {installed}",
availableShort: "Update",
availableLong: "Update verfügbar",
modalTitle: "Update verfügbar",
@ -26,8 +25,8 @@ export default {
savings: {
footerShort: "{percent}% Sonne",
footerLong: "{percent}% Sonnenenergie",
modalTitleShort: "{total} kWh geladen · {percent}% Sonne",
modalTitleLong: "{total} kWh geladen · {percent}% Sonnenenergie",
modalTitlePartOne: "{total} kWh geladen",
modalTitlePartTwo: "{percent}% Sonne",
modalChartGrid: "Netz {grid} kWh",
modalChartSelf: "Sonne {self} kWh",
modalSavingsPrice: "Effektiver Energiepreis",
@ -37,8 +36,6 @@ export default {
modalExplainationFeedIn: "Einspeisung {feedInPrice}",
modalServerStart: "Seit Serverstart {since}.",
modalNoData: "noch nicht geladen",
experimentalLabel: "experimentell",
experimentalText: "Unplausible Werte? Fragen zur Ansicht? Meld dich gerne in den",
},
sponsor: {
thanks: "Danke für deine Unterstützung, {sponsor}! Das hilft uns bei der Weiterentwicklung.",
@ -54,7 +51,6 @@ export default {
modalTitle: "Meldungen",
dismissAll: "Meldungen entfernen",
},
main: {
energyflow: {
noEnergy: "Kein Energiefluss",
@ -69,21 +65,32 @@ export default {
pvExport: "Einspeisung",
},
mode: {
title: "Modus",
stop: "Stop",
now: "Sofort",
offShort: "Aus",
offLong: "Aus",
nowShort: "Sofort",
nowLong: "Sofort",
minpvShort: "Min",
minpvLong: "Min + PV",
pvShort: "PV",
pvLong: "Nur PV",
pvLong: "PV",
fast: "Schnell",
cheap: "Günstig",
},
loadpoint: {
fallbackName: "Ladepunkt",
remoteDisabledSoft: "{source}: Adaptives PV-Laden deaktiviert",
remoteDisabledHard: "{source}: Deaktiviert",
power: "Leistung",
charged: "Geladen",
duration: "Dauer",
remaining: "Restzeit",
},
vehicles: "Parkplatz",
vehicle: {
fallbackName: "Fahrzeug",
vehicleSoC: "Ladestand",
targetSoC: "Ladeziel",
noVehicle: "Kein Fahrzeug",
},
vehicleSoC: {
disconnected: "getrennt",
@ -91,51 +98,42 @@ export default {
ready: "bereit",
connected: "verbunden",
},
vehicleSubline: {
mincharge: "Mindestladung bis {soc}%",
vehicleStatus: {
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: {
login: "anmelden",
logout: "abmelden",
},
targetCharge: {
title: "Zielzeit",
inactiveLabel: "Zielzeit",
activeLabel: "bis {time} Uhr",
activeLabel: "{time}",
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",
tomorrow: "morgen",
targetIsInThePast: "Zeitpunkt liegt in der Vergangenheit.",
remove: "Keine Zielzeit",
activate: "Zielzeit aktivieren",
experimentalLabel: "experimentell",
remove: "Entfernen",
activate: "Aktivieren",
experimentalLabel: "Experimentell",
experimentalText: `
Diese Funktion ist in einem frühen Stadium. Der Algorithmus ist noch
nicht perfekt. Die Zielzeit wird aktuell nicht persistiert - das
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
Dieses Feature funktioniert, ist aber noch nicht perfekt.
Bitte melde unerwartetes Verhalten in unseren
`,
},
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",
blog: "Blog",
github: "GitHub",
login: "Logins",
login: "Vehicle Logins",
about: "About evcc",
},
footer: {
version: {
versionShort: "v{installed}",
versionLong: "Version {installed}",
availableShort: "update",
availableLong: "update available",
modalTitle: "Update available",
@ -26,10 +25,10 @@ export default {
savings: {
footerShort: "{percent}% solar",
footerLong: "{percent}% solar energy",
modalTitleShort: "{total} kWh charged · {percent}% solar",
modalTitleLong: "{total} kWh charged · {percent}% solar energy",
modalChartGrid: "Grid energy {grid} kWh",
modalChartSelf: "Solar energy {self} kWh",
modalTitlePartOne: "{total} kWh charged",
modalTitlePartTwo: "{percent}% solar",
modalChartGrid: "Grid {grid} kWh",
modalChartSelf: "Solar {self} kWh",
modalSavingsPrice: "Effective energy price",
modalSavingsTotal: "Savings compared to grid",
modalExplaination: "Calculation",
@ -37,8 +36,6 @@ export default {
modalExplainationFeedIn: "feed-in rate {feedInPrice}",
modalServerStart: "since server start {since}.",
modalNoData: "nothing charged yet",
experimentalLabel: "experimental",
experimentalText: "Implausible values? Questions about this view? Feel free to join our ",
},
sponsor: {
thanks: "Thanks for your support, {sponsor}! It helps us with the further development.",
@ -58,8 +55,8 @@ export default {
energyflow: {
noEnergy: "No Energyflow",
homePower: "Consumption",
loadpoints: "Loadpoint | Loadpoint | {count} Loadpoints",
pvProduction: "Production",
loadpoints: "Loadpoint | Loadpoint | {count} Loadpoints",
battery: "Battery",
batteryCharge: "Battery charge",
batteryDischarge: "Battery discharge",
@ -68,21 +65,32 @@ export default {
pvExport: "Grid export",
},
mode: {
title: "Mode",
stop: "Stop",
now: "Now",
offShort: "Stop",
offLong: "Stop",
nowShort: "Now",
nowLong: "Now",
minpvShort: "Min",
minpvLong: "Min + PV",
pvShort: "PV",
pvLong: "PV only",
pvLong: "PV",
fast: "Fast",
cheap: "Cheap",
},
loadpoint: {
fallbackName: "Loadpoint",
remoteDisabledSoft: "{source}: adaptive PV charging disabled",
remoteDisabledHard: "{source}: disabled",
power: "Power",
charged: "Charged",
duration: "Duration",
remaining: "Remaining",
},
vehicles: "Parking",
vehicle: {
fallbackName: "Vehicle",
vehicleSoC: "SoC",
targetSoC: "Limit",
noVehicle: "No Vehicle",
},
vehicleSoC: {
disconnected: "disconnected",
@ -90,51 +98,42 @@ export default {
ready: "ready",
connected: "connected",
},
vehicleSubline: {
mincharge: "minimum charging to {soc}%",
vehicleStatus: {
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: {
login: "login",
logout: "logout",
},
targetCharge: {
title: "Target Time",
inactiveLabel: "Target time",
activeLabel: "until {time}",
activeLabel: "{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",
tomorrow: "tomorrow",
targetIsInThePast: "The chosen time is in the past.",
remove: "Remove",
activate: "Activate",
experimentalLabel: "experimental",
experimentalLabel: "Experimental",
experimentalText: `
This function is at an early stage.
The algorithm is not perfect yet.
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
This feature works but isn't perfect yet.
Please report unexpected behaviour in our
`,
},
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 VueI18n from "vue-i18n";
import { createI18n } from "vue-i18n/index";
import de from "./de";
import en from "./en";
import it from "./it";
import lt from "./lt";
Vue.use(VueI18n);
const PREFERRED_LOCALE_KEY = "preferred_locale";
function getBrowserLocale() {
@ -15,11 +12,10 @@ function getBrowserLocale() {
if (!navigatorLocale) {
return undefined;
}
const trimmedLocale = navigatorLocale.trim().split(/-|_/)[0];
return trimmedLocale;
return navigatorLocale.trim().split(/-|_/)[0];
}
export default new VueI18n({
export default createI18n({
locale: window.localStorage[PREFERRED_LOCALE_KEY] || getBrowserLocale(),
fallbackLocale: "en",
messages: { de, en, it, lt },

View file

@ -1,17 +1,16 @@
export default {
header: {
docs: "Dokumentacija",
docs: "Dokumentacija (Vokiečių k.)",
blog: "Tinklaraštis",
github: "GitHub",
login: "Prisijungimas",
login: " Automobilių prisijungimai",
about: "Apie EVCC",
},
footer: {
version: {
versionShort: "v{installed}",
versionLong: "Versija {installed}",
availableShort: "Naujinimas",
availableLong: "Prieinama naujesnė versija",
modalTitle: "Prieinama naujesnė versija",
availableLong: "Yra naujesnė versija",
modalTitle: "Yra naujesnė versija",
modalUpdateStarted: "Pasibaigus naujinimui EVCC startuos iš naujo..",
modalInstalledVersion: "Dabartinė versija",
modalNoReleaseNotes: "Naujinimo detalių nėra. Daugiau informacijos rasite čia:",
@ -25,8 +24,8 @@ export default {
savings: {
footerShort: "{percent}% saulės",
footerLong: "{percent}% saulės energija",
modalTitleShort: "{total} kWh įkrauta · {percent}% saulės",
modalTitleLong: "{total} kWh įkrauta · {percent}% saulės energija",
modalTitlePartOne: "{total} kWh įkrauta",
modalTitlePartTwo: "{percent}% saulės",
modalChartGrid: "Energija iš tinklo {grid} kWh",
modalChartSelf: "Saulės energija {self} kWh",
modalSavingsPrice: "Faktinė energijos kaina",
@ -36,18 +35,15 @@ export default {
modalExplainationFeedIn: "Kompensavimas už patiektą į tinklą energiją {feedInPrice}",
modalServerStart: "nuo EVCC starto {since}.",
modalNoData: "įkrovimo duomenų dar nėra",
experimentalLabel: "eksperimentinis",
experimentalText:
"Neteisingi duomenys? Turite klausimų apie šiuos skaičiavimus? Prisijunkite prie mūsų",
},
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?",
supportUs:
"Norime, kad efektyvesnis elektromobilių įkrovimas taptų pasiekiamas kuo daugiau žmonių. Tapdami rėmėjais prisidedate prie nuolatinio projekto vystymo ir palaikymo.",
sticker: "... ar evcc lipdukų?",
confettiPromise: "Gausite skaitmeninių lipdukų ir konfeti ;)",
becomeSponsor: "Taptkite GitHub rėmėju!",
"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ų?",
confettiPromise: "Gausite lipdukų ir skaitmeninių konfeti ;)",
becomeSponsor: "Pradėkite GitHub rėmimą!",
},
},
notifications: {
@ -58,8 +54,8 @@ export default {
energyflow: {
noEnergy: "Energija neteka",
homePower: "Namo suvartojimas",
loadpoints: "Įkroviklis | Įkroviklis | {count} Įkrovikliai",
pvProduction: "Gamyba",
loadpoints: "Įkroviklis | Įkroviklis | {count} Įkrovikliai",
battery: "Baterija",
batteryCharge: "Baterijos įkrovimas",
batteryDischarge: "Baterijos iškrovimas",
@ -68,73 +64,75 @@ export default {
pvExport: "Tinklo eksportas",
},
mode: {
title: "Darbo režimas",
stop: "Stop",
now: "Dabar",
offShort: "Stop",
offLong: "Stop",
nowShort: "Dabar",
nowLong: "Dabar",
minpvShort: "Min",
minpvLong: "Min + PV",
pvShort: "PV",
pvLong: "Tik PV",
pvLong: "PV",
fast: "Greitai",
cheap: "Pigiai",
},
loadpoint: {
fallbackName: "Įkroviklis",
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: {
fallbackName: "Automobilis",
vehicleSoC: "Įkrova",
targetSoC: "Limitas",
noVehicle: "Nėra automobilio",
},
vehicleSoC: {
disconnected: "neprijungtas",
charging: "vyksta įkrovimas",
ready: "leidžiama įkrauti",
connected: "prijungtas",
connected: "automobilis prijungtas",
},
vehicleSubline: {
mincharge: "minimalus įkrovimas iki {soc}%",
vehicleStatus: {
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: {
login: "prisijungti",
logout: "atsijungti",
},
targetCharge: {
inactiveLabel: "Suplanuota įkrovimo pabaiga",
activeLabel: "įkrauti iki {time}",
title: "Įkrauti iki",
inactiveLabel: "Įkrauti iki",
activeLabel: "{time}",
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",
tomorrow: "rytoj",
targetIsInThePast: "Pasirinktas laikas yra praeityje.",
remove: "Panaikinti",
activate: "Aktyvuoti",
experimentalLabel: "eksperimentinis",
experimentalLabel: "Eksperimentinis",
experimentalText: `
Ši funkcija yra ankstyvoje integravimo stadijoje.
Algoritmas kol kas nėra tobulas.
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ų
Ši funkcija veikia, bet dar nėra tobula.
Apie netikėtą elgesį praneškite 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,8 +4,10 @@ export default {
collectProps: function (component) {
let data = {};
for (var p in component.props) {
if (p in this) {
data[p] = this[p];
}
}
return data;
},
},

View file

@ -17,16 +17,21 @@ export default {
val = Math.abs(val);
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 value = kw ? watt / 1000 : watt;
let unit = "";
if (withUnit) {
unit = kw ? " kW" : " W";
}
return (
this.$n(value, { minimumFractionDigits: digits, maximumFractionDigits: digits }) + unit
);
return `${new Intl.NumberFormat(this.$i18n.locale, {
style: "decimal",
minimumFractionDigits: digits,
maximumFractionDigits: digits,
}).format(value)}${unit}`;
},
fmtKWh: function (val) {
return this.fmtKw(val) + "h";
},
fmtUnit: function (val) {
return Math.abs(val) >= this.fmtLimit ? "k" : "";
@ -88,15 +93,42 @@ export default {
const mm = `${date.getMinutes()}`.padStart(2, "0");
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, {
weekday: "short",
}).format(date);
},
fmtAbsoluteDate: function (date) {
const weekday = this.weekdayPrefix(date);
const hour = new Intl.DateTimeFormat(this.$i18n.locale, {
hour: "numeric",
minute: "numeric",
}).format(date);
return `${weekday} ${hour}`;
},
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") {
let unit = currency;
@ -107,7 +139,10 @@ export default {
unit = "ct";
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) {
const units = {

View file

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

View file

@ -1,9 +1,9 @@
import Vue from "vue";
import { reactive } from "vue";
function setProperty(obj, props, value) {
const prop = props.shift();
if (!obj[prop]) {
Vue.set(obj, prop, {});
obj[prop] = {};
}
if (!props.length) {
@ -18,16 +18,18 @@ function setProperty(obj, props, value) {
setProperty(obj[prop], props, value);
}
const store = {
state: {
const state = reactive({
loadpoints: [], // ensure array type
},
});
const store = {
state,
update: function (msg) {
Object.keys(msg).forEach(function (k) {
if (typeof window.app[k] === "function") {
window.app[k]({ message: msg[k] });
} 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>
<div class="app d-flex flex-column justify-content-between overflow-hidden">
<div class="flex-grow-1 d-flex flex-column justify-content-between">
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand" href="https://evcc.io/" target="_blank">
<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 class="app overflow-hidden">
<metainfo>
<template #title="{ content }">{{ content ? `${content} | evcc` : `evcc` }}</template>
</metainfo>
<router-view :notifications="notifications"></router-view>
</div>
</template>
<script>
import "../icons";
import Logo from "../components/Logo";
import Footer from "../components/Footer";
import Notifications from "../components/Notifications";
import store from "../store";
import baseAPI from "../baseapi";
export default {
name: "App",
components: { Logo, Footer, Notifications },
props: {
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 () {
const urlParams = new URLSearchParams(window.location.search);
this.compact = urlParams.get("compact");
@ -211,32 +60,15 @@ export default {
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() {
return {
title: this.title ? `evcc | ${this.title}` : "evcc",
};
return { title: store.state.siteTitle || "" };
},
};
</script>
<style scoped>
.logo {
width: 85px;
}
.app {
min-height: 100vh;
}
.title {
position: relative;
top: 0.1rem;
background-color: white;
}
</style>

View file

@ -1,7 +1,7 @@
<template>
<div class="container">
<Site v-if="configured" v-bind="state"></Site>
<div v-else>
<div>
<Site v-if="configured" :notifications="notifications" v-bind="state"></Site>
<div v-else class="container">
<div class="row py-5">
<div class="col12">
<p class="h1 pt-5 pb-2 border-bottom">Willkommen bei evcc</p>
@ -80,13 +80,17 @@
</template>
<script>
import Site from "../components/Site";
import Site from "../components/Site.vue";
import store from "../store";
export default {
name: "Main",
components: { Site },
props: {
notifications: Array,
},
data: function () {
return this.$root.$data.store; // global state
return store;
},
computed: {
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
script: |
state = {
residualpower: 300,
batterypower: 0,
pvpower: 6200,
gridpower: 0,
chargepower: 0,
maxcurrent: 0,
battery: 63, // car
residualpower: 500,
pvpower: 5000,
batterypower: -750,
batterySoC: 55,
gridpower: -1000,
loadpoints: [
{ 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 set() { console.log("set:", param+"="+val); }
function get() {
console.log("state:", JSON.stringify(state));
}
function set() {
console.log(param+":", val);
console.log("state:", JSON.stringify(state));
}
meters:
- name: grid
@ -27,22 +34,9 @@ meters:
source: js
vm: shared
script: |
state.pvpower += 200*(Math.random()-0.5);
state.chargepower = 0;
if (state.enabled) {
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.pvpower = 8000+500*Math.random();
state.batterypower = state.gridpower > 0 ? 1000 * Math.random() : 0;
state.gridpower = state.loadpoints[0].chargepower + state.loadpoints[1].chargepower + state.residualpower - state.batterypower - state.pvpower;
state.gridpower;
- name: pv
@ -50,7 +44,7 @@ meters:
power:
source: js
vm: shared
script: state.pvpower
script: state.pvpower;
- name: battery
type: custom
@ -61,56 +55,148 @@ meters:
soc:
source: js
vm: shared
script: "30"
- name: charge
script: |
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
power:
source: js
vm: shared
script: state.chargepower;
soc:
script: state.loadpoints[0].chargepower;
- name: meter_charger_2
type: custom
power:
source: js
vm: shared
script: "30"
script: state.loadpoints[1].chargepower;
- name: meter_charger_3
type: custom
power:
source: js
script: 0
chargers:
- name: demo
- name: charger_1
type: custom
enable:
source: js
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:
source: js
vm: shared
script: |
state.enabled;
state.loadpoints[0].enabled;
status:
source: js
vm: shared
script: |
if (state.enabled) "C"; else "B";
if (state.loadpoints[0].enabled) "C"; else "B";
maxcurrent:
source: js
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:
- name: demo
title: e-Golf
- name: vehicle_1
title: blauer e-Golf
type: custom
capacity: 50
soc:
source: js
vm: shared
script: |
if (state.chargepower > 0) state.battery++; else state.battery--;
if (state.battery < 15) state.battery = 15;
if (state.battery > 100) state.battery = 100;
state.battery;
var lp = state.loadpoints[0];
if (lp.chargepower > 0) lp.vehicleSoC++; else lp.vehicleSoC--;
if (lp.vehicleSoC < 15) lp.vehicleSoC = 80;
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:
title: Demo
title: Zuhause
meters:
grid: grid
pv: pv
@ -118,6 +204,24 @@ site:
loadpoints:
- title: Carport
charger: demo
meter: charge
vehicle: demo
charger: charger_1
mode: pv
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]]",
configured: "[[.Configured]]",
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