UI: show inactive phases as dots in phase indicator (#33173)

This commit is contained in:
Michael Geers 2026-08-28 08:50:57 +02:00 • committed by GitHub
parent fb120252a0
commit 8ccd72e141
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 187 additions and 98 deletions

View file

@ -2,6 +2,7 @@ import { type Preview, setup } from "@storybook/vue3";
import "bootstrap/dist/css/bootstrap.min.css";
import smoothscroll from "smoothscroll-polyfill";
import setupI18n from "../assets/js/i18n";
import tooltip from "../assets/js/directives/tooltip";
import "../assets/css/app.css";
import { watchThemeChanges } from "../assets/js/theme";
@ -35,6 +36,7 @@ export default {
setup((app) => {
app.use(setupI18n());
app.directive("tooltip", tooltip);
// Mock router-link for Storybook
app.component("router-link", {

View file

@ -50,6 +50,7 @@
--evcc-gray: var(--bs-gray-medium);
--evcc-gray-50: #93949e80;
--evcc-gray-25: #93949e40;
--evcc-gray-15: #93949e26;
--evcc-gray-10: #93949e20;
--evcc-accent1: var(--evcc-dark-yellow);
--evcc-accent2: var(--evcc-darker-green);

View file

@ -6,6 +6,7 @@ import { VueHeadMixin, createHead } from "@unhead/vue/client";
import App from "./views/App.vue";
import setupRouter from "./router.ts";
import setupI18n from "./i18n.ts";
import tooltip from "./directives/tooltip.ts";
import { watchThemeChanges } from "./theme.ts";
import { applyUrlSettings } from "./urlSettings.ts";
import { appDetection, sendToApp } from "./utils/native";
@ -99,6 +100,7 @@ app.use(i18n);
app.use(router);
app.use(head);
app.mixin(VueHeadMixin);
app.directive("tooltip", tooltip);
window.app = app.mount("#app");
watchThemeChanges();

View file

@ -1,11 +1,9 @@
<template>
<span
ref="root"
v-tooltip="tooltip"
class="d-flex align-items-center gap-2 text-nowrap evcc-gray"
:role="tooltip ? 'img' : undefined"
:aria-label="tooltip || undefined"
:title="tooltip || undefined"
:data-bs-toggle="tooltip ? 'tooltip' : undefined"
>
<span class="d-inline-block rounded-circle status-dot" :class="dotClass"></span>
<slot />
@ -14,7 +12,6 @@
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import Tooltip from "bootstrap/js/dist/tooltip";
type Variant = "success" | "warning" | "danger" | "muted";
@ -24,9 +21,6 @@ export default defineComponent({
variant: { type: String as PropType<Variant>, default: "muted" },
tooltip: { type: String, default: "" },
},
data() {
return { tooltipInstance: null as Tooltip | null };
},
computed: {
dotClass(): string {
switch (this.variant) {
@ -41,30 +35,6 @@ export default defineComponent({
}
},
},
watch: {
tooltip() {
this.initTooltip();
},
},
mounted() {
this.initTooltip();
},
beforeUnmount() {
this.tooltipInstance?.dispose();
},
methods: {
initTooltip() {
this.$nextTick(() => {
this.tooltipInstance?.dispose();
this.tooltipInstance = null;
const el = this.$refs["root"] as Element | undefined;
if (el && this.tooltip) {
// explicit title: bootstrap clears the attr, so :title alone goes stale
this.tooltipInstance = new Tooltip(el, { title: this.tooltip });
}
});
},
},
});
</script>

View file

@ -1,6 +1,6 @@
import Loadpoints from "./Loadpoints.vue";
import type { Meta, StoryFn } from "@storybook/vue3";
import { CURRENCY, SMART_COST_TYPE } from "@/types/evcc";
import { CHARGE_MODE, CURRENCY, SMART_COST_TYPE } from "@/types/evcc";
// Create LoadpointCompact structure for the Loadpoints component
const createLoadpoint = (opts: any = {}) => {
@ -117,11 +117,18 @@ TwoLoadpoints.args = {
index: 0,
vehicleName: "vehicle_3",
charging: true,
chargePower: 8050,
power: 8050,
chargePower: 1380,
power: 1380,
connected: true,
soc: 66,
vehicleSoc: 66,
mode: CHARGE_MODE.PV,
phasesConfigured: 3,
phasesActive: 1,
offeredCurrent: 9,
chargeCurrents: [6, 0, 0],
minCurrent: 6,
maxCurrent: 16,
}),
createLoadpoint({
title: "Charging point with a very very very long title that tests layout compatibility!!!",

View file

@ -1,5 +1,6 @@
import Phases from "./Phases.vue";
import type { Meta, StoryFn } from "@storybook/vue3";
import { ref, onMounted, onUnmounted } from "vue";
export default {
title: "Loadpoints/Phases",
@ -9,6 +10,7 @@ export default {
},
argTypes: {
phasesActive: { control: { type: "number" } },
phasesConfigured: { control: { type: "number" } },
minCurrent: { control: { type: "number" } },
maxCurrent: { control: { type: "number" } },
offeredCurrent: { control: { type: "number" } },
@ -24,72 +26,117 @@ const Template: StoryFn<typeof Phases> = (args) => ({
template: '<Phases v-bind="args" />',
});
export const OnePhase = Template.bind({});
OnePhase.args = {
phasesActive: 1,
const base = {
phasesConfigured: 3,
minCurrent: 6,
maxCurrent: 16,
offeredCurrent: 8,
chargeCurrents: undefined,
};
export const TwoPhase = Template.bind({});
TwoPhase.args = {
...OnePhase.args,
phasesActive: 2,
};
// --- expected phases only (no per-phase measurement) ---
export const ThreePhase = Template.bind({});
ThreePhase.args = {
...OnePhase.args,
phasesActive: 3,
};
export const NoData = Template.bind({});
NoData.args = { ...base, phasesActive: undefined, offeredCurrent: 0 };
export const RealCurrents = Template.bind({});
RealCurrents.args = {
...OnePhase.args,
phasesActive: 3,
offeredCurrent: 13,
chargeCurrents: [11, 9, 12],
};
export const Expected1p = Template.bind({});
Expected1p.args = { ...base, phasesActive: 1 };
export const OnePhaseMoreAvailable = Template.bind({});
OnePhaseMoreAvailable.args = {
...OnePhase.args,
offeredCurrent: 12,
chargeCurrents: [6, 0.2, 0],
};
export const Expected2p = Template.bind({});
Expected2p.args = { ...base, phasesActive: 2 };
export const TwoPhasesActive = Template.bind({});
TwoPhasesActive.args = {
...OnePhase.args,
phasesActive: 2,
offeredCurrent: 16,
chargeCurrents: [16, 16, 0.3],
};
export const Expected3p = Template.bind({});
Expected3p.args = { ...base, phasesActive: 3 };
export const AsymetricPhases = Template.bind({});
AsymetricPhases.args = {
...OnePhase.args,
phasesActive: 2,
offeredCurrent: 16,
chargeCurrents: [8, 0.9, 14],
};
// --- measured per-phase currents ---
export const OnlySecondPhase = Template.bind({});
OnlySecondPhase.args = {
...OnePhase.args,
export const Measured1p = Template.bind({});
Measured1p.args = { ...base, phasesActive: 1, offeredCurrent: 12, chargeCurrents: [6, 0.2, 0] };
export const Measured2p = Template.bind({});
Measured2p.args = { ...base, phasesActive: 2, offeredCurrent: 16, chargeCurrents: [16, 16, 0.3] };
export const Measured3p = Template.bind({});
Measured3p.args = { ...base, phasesActive: 3, offeredCurrent: 13, chargeCurrents: [11, 9, 12] };
export const Asymmetric = Template.bind({});
Asymmetric.args = { ...base, phasesActive: 2, offeredCurrent: 16, chargeCurrents: [8, 0.9, 14] };
export const OnlyL2 = Template.bind({});
OnlyL2.args = { ...base, phasesActive: 1, offeredCurrent: 13, chargeCurrents: [0, 13, 0] };
export const MainlyL3 = Template.bind({});
MainlyL3.args = {
...base,
phasesActive: 1,
offeredCurrent: 13,
chargeCurrents: [0, 13, 0],
};
export const MainlyThirdPhase = Template.bind({});
MainlyThirdPhase.args = {
...OnePhase.args,
phasesActive: 1,
offeredCurrent: 10,
chargeCurrents: [0.007, 0.009, 5.945],
minCurrent: 6,
offeredCurrent: 20,
maxCurrent: 20,
chargeCurrents: [0.007, 0.009, 5.945],
};
// 1p device but 3 phases measured: configuration error, warning color + tooltip
export const Mismatch1p = Template.bind({});
Mismatch1p.args = {
...base,
phasesConfigured: 1,
phasesActive: 3,
offeredCurrent: 10,
chargeCurrents: [10, 10, 10],
};
// cycles 1p → 3p → 1p every few seconds to inspect the transition
export const Switching: StoryFn<typeof Phases> = (args) => ({
components: { Phases },
setup() {
const phasesActive = ref(1);
let timer: ReturnType<typeof setInterval>;
onMounted(() => {
timer = setInterval(() => {
phasesActive.value = phasesActive.value === 1 ? 3 : 1;
}, 3000);
});
onUnmounted(() => clearInterval(timer));
return { args, phasesActive };
},
template: '<Phases v-bind="args" :phasesActive="phasesActive" />',
});
Switching.args = { ...base, phasesConfigured: 0, offeredCurrent: 12 };
// --- overview: rows = scenario, columns = device phase configuration ---
const scenarios = [
{ name: "NoData", args: NoData.args },
{ name: "Expected1p", args: Expected1p.args },
{ name: "Expected2p", args: Expected2p.args },
{ name: "Expected3p", args: Expected3p.args },
{ name: "Measured1p", args: Measured1p.args },
{ name: "Measured2p", args: Measured2p.args },
{ name: "Measured3p", args: Measured3p.args },
{ name: "Asymmetric", args: Asymmetric.args },
{ name: "OnlyL2", args: OnlyL2.args },
{ name: "MainlyL3", args: MainlyL3.args },
];
// 1p3p auto (0) behaves like 3p
const configs = [
{ name: "1p device", phasesConfigured: 1 },
{ name: "3p/auto", phasesConfigured: 3 },
];
export const Overview: StoryFn<typeof Phases> = () => ({
components: { Phases },
setup() {
return { scenarios, configs };
},
template: `
<div style="display: grid; grid-template-columns: 140px repeat(2, 120px); gap: 0.75rem 1.5rem; align-items: center; padding: 1rem; background: var(--evcc-box); border-radius: 0.5rem; color: var(--evcc-default-text); font-size: 0.85rem;">
<div></div>
<div v-for="c in configs" :key="c.name" style="opacity: 0.7;">{{ c.name }}</div>
<template v-for="s in scenarios" :key="s.name">
<div style="opacity: 0.7;">{{ s.name }}</div>
<Phases v-for="c in configs" :key="c.name" v-bind="{ ...s.args, phasesConfigured: c.phasesConfigured }" />
</template>
</div>
`,
});
Overview.parameters = { controls: { disable: true } };

View file

@ -1,12 +1,23 @@
<template>
<div :class="`phases d-flex justify-content-between`">
<div
v-tooltip="phasesMismatch ? $t('main.loadpoint.phasesMismatch') : undefined"
class="phases d-flex"
:class="{ 'phases-warning': phasesMismatch }"
>
<div
v-for="num in [1, 2, 3]"
:key="num"
class="phase me-1"
:class="{ 'phase-inactive': !isPhaseActive(num) }"
:class="{
'phase-inactive': !isPhaseActive(num),
'phase-hidden': !isPhaseActive(num) && (maxPhases === 1 || num > maxPhases),
}"
>
<div class="target" :style="{ width: `${targetWidth()}%` }"></div>
<div
v-show="targetWidth() > 0"
class="target"
:style="{ width: `${targetWidth()}%` }"
></div>
<div class="real" :style="{ width: `${realWidth(num)}%` }"></div>
</div>
</div>
@ -23,6 +34,7 @@ export default defineComponent({
offeredCurrent: { type: Number, default: 0 },
chargeCurrents: { type: Array as PropType<number[]> },
phasesActive: { type: Number as PropType<PHASES> },
phasesConfigured: { type: Number, default: 3 },
minCurrent: { type: Number, default: 6 },
maxCurrent: { type: Number, default: 16 },
},
@ -31,9 +43,17 @@ export default defineComponent({
if (!this.chargeCurrents) return false;
return this.chargeCurrents.filter((c) => c >= MIN_ACTIVE_CURRENT).length > 0;
},
maxPhases() {
// 0 = automatic 1p3p switching
return this.phasesConfigured || 3;
},
phasesMismatch() {
return [1, 2, 3].filter((num) => this.isPhaseActive(num)).length > this.maxPhases;
},
},
methods: {
targetWidth() {
if (this.offeredCurrent <= 0) return 0;
const current = Math.min(
Math.max(this.minCurrent, this.offeredCurrent),
this.maxCurrent
@ -52,7 +72,8 @@ export default defineComponent({
const current = this.chargeCurrents[num - 1];
return current !== undefined && current >= MIN_ACTIVE_CURRENT;
}
return num <= (this.phasesActive || 0);
// unknown active phases: assume all
return num <= (this.phasesActive || this.maxPhases);
},
},
});
@ -63,15 +84,14 @@ export default defineComponent({
width: 73px;
}
.phase {
background-color: var(--bs-gray-bright);
background-color: var(--evcc-gray-15);
height: 4px;
flex-grow: 1;
flex: 1 1 0;
position: relative;
border-radius: 1px;
border-radius: 2px;
overflow: hidden;
flex-basis: 100%;
opacity: 1;
transition-property: flex-basis, margin, opacity;
transition-property: flex-basis, flex-grow, margin, opacity;
transition-duration: var(--evcc-transition-slow);
transition-timing-function: ease-in;
}
@ -79,6 +99,13 @@ html.dark .phase {
background-color: var(--bs-gray-bright);
}
.phase-inactive {
flex: 0 0 4px;
}
.phase-inactive .target,
.phase-inactive .real {
opacity: 0;
}
.phase-hidden {
flex-basis: 0;
margin-right: 0 !important;
opacity: 0;
@ -96,9 +123,15 @@ html.dark .phase {
opacity: 1;
}
.target {
background-color: var(--evcc-green);
background-color: color-mix(in srgb, var(--evcc-dark-green) 40%, white);
}
.real {
background-color: var(--evcc-dark-green);
}
.phases-warning .target {
background-color: color-mix(in srgb, var(--bs-warning) 40%, white);
}
.phases-warning .real {
background-color: var(--bs-warning);
}
</style>

View file

@ -0,0 +1,25 @@
import Tooltip from "bootstrap/js/dist/tooltip";
import type { Directive, DirectiveBinding } from "vue";
function update(el: HTMLElement, { value, oldValue }: DirectiveBinding<string | undefined>) {
if (value === oldValue) return;
const instance = Tooltip.getInstance(el);
if (!value) {
instance?.dispose();
} else if (instance) {
instance.setContent({ ".tooltip-inner": value });
} else {
new Tooltip(el, { title: value });
}
}
// v-tooltip="text": bootstrap tooltip bound to text, removed when text is empty
const tooltip: Directive<HTMLElement, string | undefined> = {
mounted: update,
updated: update,
beforeUnmount(el) {
Tooltip.getInstance(el)?.dispose();
},
};
export default tooltip;

View file

@ -1432,6 +1432,7 @@
"finished": "Ladeende",
"last24hEnergy": "Letzte 24h",
"last7dEnergy": "Letzte 7 Tage",
"phasesMismatch": "Mehr Phasen aktiv als konfiguriert. Phasenkonfiguration prüfen.",
"power": "Leistung",
"price": "Kosten",
"remaining": "Restzeit",

View file

@ -1432,6 +1432,7 @@
"finished": "Finish time",
"last24hEnergy": "Last 24h",
"last7dEnergy": "Last 7 days",
"phasesMismatch": "More phases active than configured. Check phase configuration.",
"power": "Power",
"price": "Cost",
"remaining": "Remaining",