UI: darker dark mode, card layout, battery card polish (2) (#31593)

This commit is contained in:
Michael Geers 2026-07-08 19:47:54 +02:00 • committed by GitHub
parent 884956f3cf
commit 50e81a0726
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 338 additions and 80 deletions

View file

@ -748,7 +748,11 @@ html.app .modal-dialog {
padding: 1rem;
}
/* pull boxes into the container padding so box content aligns with the page header */
/* small margin on mobile (matches loadpoint card), pull into container padding at sm+ */
.box-pull-out {
margin-left: -1rem;
margin-right: -1rem;
}
@media (min-width: 576px) {
.box-pull-out {
margin-left: -1.5rem;

View file

@ -1,6 +1,10 @@
<template>
<div v-if="batteryAvailable" data-testid="battery-experimental">
<BatteryStatusCards class="mb-4 box-pull-out" :battery="state.battery" />
<BatteryStatusCards
class="mb-4 box-pull-out"
:battery="state.battery"
:battery-mode="state.batteryMode"
/>
<BatteryHistoryCard
class="mb-4 box-pull-out"

View file

@ -1,11 +1,16 @@
<template>
<Card :title="title" data-testid="battery-status-card">
<div class="d-flex justify-content-between align-items-start">
<LabelAndValue :label="$t('battery.card.soc')" align="start">
<div class="d-flex align-items-center gap-2">
<SocGauge :soc="soc" :color="color" :size="38" class="soc-gauge" />
<span class="value fw-bold" :style="{ color }">{{ fmtPercentage(soc) }}</span>
</div>
<Card data-testid="battery-status-card">
<div class="values">
<LabelAndValue :label="title" :color="color" align="start">
<h3 class="value m-0 fw-bold d-flex align-items-center gap-2" :style="{ color }">
<SocGauge
:soc="soc"
:color="color"
:power="power"
:mode="controllable ? batteryMode : undefined"
/>
{{ fmtPercentage(soc) }}
</h3>
</LabelAndValue>
<LabelAndValue
@ -19,15 +24,16 @@
<LabelAndValue
class="text-end"
:label="powerInfo.label"
:value="powerInfo.value"
:label="$t('battery.card.power')"
:value="powerValue"
:extraValue="powerState"
align="end"
/>
</div>
<OptimizerInfo
v-if="suggestion || forecast?.highest || forecast?.lowest"
class="optimizer"
class="mt-3 pt-3 border-top"
:suggestion="suggestion"
:forecast="forecast"
/>
@ -41,7 +47,7 @@ import Card from "../Helper/Card.vue";
import LabelAndValue from "../Helper/LabelAndValue.vue";
import SocGauge from "./SocGauge.vue";
import OptimizerInfo from "./OptimizerInfo.vue";
import type { BatteryForecast } from "@/types/evcc";
import { BATTERY_MODE, type BatteryForecast } from "@/types/evcc";
import type { BatterySuggestion } from "./types";
export default defineComponent({
@ -55,6 +61,8 @@ export default defineComponent({
power: { type: Number, default: 0 }, // W, + discharging / - charging
capacity: { type: Number, default: 0 }, // kWh, 0 = unspecified
color: { type: String, default: "" },
controllable: { type: Boolean, default: false },
batteryMode: String as PropType<BATTERY_MODE>,
suggestion: { type: Object as PropType<BatterySuggestion | null>, default: null },
forecast: { type: Object as PropType<BatteryForecast | null>, default: null },
},
@ -73,15 +81,41 @@ export default defineComponent({
total: this.fmtWh(this.capacity * 1e3, POWER_UNIT.KW, true, 1),
});
},
powerInfo(): { label: string; value: string } {
const abs = Math.abs(this.power);
const value = this.fmtW(abs, POWER_UNIT.AUTO);
if (abs < 50) {
return { label: this.$t("battery.card.power"), value };
powerValue(): string {
return this.fmtW(Math.abs(this.power), POWER_UNIT.AUTO);
},
// mode takes priority over the raw power reading when the battery is controllable
statusState():
| BATTERY_MODE.HOLD
| BATTERY_MODE.HOLDCHARGE
| BATTERY_MODE.CHARGE
| "charging"
| "discharging"
| "idle" {
if (this.controllable) {
if (this.batteryMode === BATTERY_MODE.HOLD) return BATTERY_MODE.HOLD;
if (this.batteryMode === BATTERY_MODE.HOLDCHARGE) return BATTERY_MODE.HOLDCHARGE;
if (this.batteryMode === BATTERY_MODE.CHARGE) return BATTERY_MODE.CHARGE;
}
const abs = Math.abs(this.power);
if (abs < 50) return "idle";
return this.power < 0 ? "charging" : "discharging";
},
powerState(): string | undefined {
switch (this.statusState) {
case BATTERY_MODE.HOLD:
return this.$t("battery.card.hold");
case BATTERY_MODE.HOLDCHARGE:
return this.$t("battery.card.holdCharge");
case BATTERY_MODE.CHARGE:
return this.$t("battery.card.gridCharge");
case "charging":
return this.$t("battery.card.charging");
case "discharging":
return this.$t("battery.card.discharging");
default:
return undefined;
}
return this.power < 0
? { label: this.$t("battery.card.charging"), value }
: { label: this.$t("battery.card.discharging"), value };
},
},
});
@ -91,14 +125,14 @@ export default defineComponent({
.value {
font-size: 18px;
}
/* let the gauge straddle the value line so the % sits level with the other values */
.soc-gauge {
margin-top: -0.5rem;
margin-bottom: -0.5rem;
.values {
display: grid;
grid-template-columns: repeat(3, 1fr);
align-items: start;
gap: 0.5rem;
}
.optimizer {
margin-top: 1rem;
padding-top: 0.5rem;
border-top: 1px solid var(--bs-border-color);
.values :deep(.root) {
margin-bottom: 0;
min-width: 0;
}
</style>

View file

@ -37,12 +37,18 @@ Single.args = {
battery: battery([device({ title: "Sungrow", soc: 76, power: 800, capacity: 13.5 })]),
};
// long title on one device illustrates the name label's overflow/ellipsis behavior
export const Multiple = Template.bind({});
Multiple.args = {
battery: {
...battery([
device({ title: "Sungrow", soc: 76, power: 800, capacity: 13.5, controllable: true }),
device({ title: "Anker", soc: 40, power: -1200, capacity: 7.5 }),
device({
title: "Anker SOLIX X1 Home Energy Storage System Extended Pro Max",
soc: 40,
power: -1200,
capacity: 7.5,
}),
]),
soc: 63,
},

View file

@ -1,19 +1,24 @@
<template>
<div v-if="showSplit" class="cards cards--split gap-4">
<BatteryStatusCard v-bind="cards[0]" />
<Card :title="$t('battery.optimizer.title')" data-testid="battery-optimizer-card">
<BatteryStatusCard v-bind="cards[0]" :battery-mode="batteryMode" />
<Card data-testid="battery-optimizer-card">
<OptimizerInfo :suggestion="suggestion" :forecast="batteryForecast" />
</Card>
</div>
<div v-else class="cards gap-4">
<BatteryStatusCard v-for="card in cards" :key="card.id" v-bind="card" />
<BatteryStatusCard
v-for="card in cards"
:key="card.id"
v-bind="card"
:battery-mode="batteryMode"
/>
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import { batteryColor } from "@/colors";
import type { Battery, BatteryForecast, BatteryMeter } from "@/types/evcc";
import type { BATTERY_MODE, Battery, BatteryForecast, BatteryMeter } from "@/types/evcc";
import Card from "../Helper/Card.vue";
import BatteryStatusCard from "./BatteryStatusCard.vue";
import OptimizerInfo from "./OptimizerInfo.vue";
@ -26,6 +31,7 @@ export default defineComponent({
components: { Card, BatteryStatusCard, OptimizerInfo },
props: {
battery: { type: Object as PropType<Battery> },
batteryMode: String as PropType<BATTERY_MODE>,
suggestion: { type: Object as PropType<BatterySuggestion | null>, default: null },
},
computed: {
@ -48,10 +54,12 @@ export default defineComponent({
power: d.power,
capacity: d.capacity || 0,
color: batteryColor(i),
controllable: d.controllable,
suggestion: null, // per-battery suggestion not wired yet
forecast: null, // aggregate forecast lives on the combined / dedicated card
}));
// combined uses the site aggregate soc/power, not per-device sums
// combined uses the site aggregate soc/power, not per-device sums; the site-wide
// battery mode always applies here regardless of per-device controllability
if (multiple) {
list.unshift({
id: "combined",
@ -60,6 +68,7 @@ export default defineComponent({
power: this.battery?.power ?? 0,
capacity: this.devices.reduce((s, d) => s + (d.capacity || 0), 0),
color: batteryColor(0),
controllable: true,
suggestion: this.suggestion,
forecast: this.batteryForecast,
});
@ -73,7 +82,7 @@ export default defineComponent({
<style scoped>
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(min(380px, 100%), 1fr));
}
.cards--split {
grid-template-columns: 1fr 1fr;

View file

@ -1,13 +1,13 @@
<template>
<div>
<div v-if="recommendation" class="d-flex justify-content-between gap-3 py-1">
<div class="d-flex flex-column gap-2">
<div v-if="recommendation" class="d-flex justify-content-between gap-3">
<span class="d-flex align-items-center gap-2 fw-bold">
<Optimizer />
{{ $t("battery.optimizer.suggestion") }}
</span>
<span class="text-muted text-end">{{ recommendation }}</span>
</div>
<div v-if="forecast?.highest" class="d-flex justify-content-between gap-3 py-1">
<div v-if="forecast?.highest" class="d-flex justify-content-between gap-3">
<span class="d-flex align-items-center gap-2 fw-bold">
<BatteryIcon :soc="95" />
{{ pointLabel(forecast.highest, true) }}
@ -16,7 +16,7 @@
fmtDayTime(new Date(forecast.highest.time))
}}</span>
</div>
<div v-if="forecast?.lowest" class="d-flex justify-content-between gap-3 py-1">
<div v-if="forecast?.lowest" class="d-flex justify-content-between gap-3">
<span class="d-flex align-items-center gap-2 fw-bold">
<BatteryIcon :soc="10" />
{{ pointLabel(forecast.lowest, false) }}

View file

@ -0,0 +1,54 @@
import SocGauge from "./SocGauge.vue";
import colors, { batteryColor } from "@/colors";
import { BATTERY_MODE } from "@/types/evcc";
import type { Meta, StoryFn } from "@storybook/vue3";
export default {
title: "Battery/SocGauge",
component: SocGauge,
parameters: {
layout: "centered",
},
argTypes: {
soc: { control: { type: "range", min: 0, max: 100 } },
color: { control: "inline-radio", options: colors.batteryPalette },
power: { control: "number", description: "W, + discharging / - charging" },
mode: { control: "inline-radio", options: Object.values(BATTERY_MODE) },
},
} as Meta<typeof SocGauge>;
const Template: StoryFn<typeof SocGauge> = (args) => ({
components: { SocGauge },
setup() {
return { args };
},
template: '<SocGauge v-bind="args" />',
});
export const Default = Template.bind({});
Default.args = { soc: 60, color: batteryColor(0), power: -800 };
// (power, mode) pairs that each drive a distinct SocGauge status
const scenarios = [
{ label: "idle", power: 0, mode: undefined },
{ label: "charging", power: -800, mode: undefined },
{ label: "discharging", power: 800, mode: undefined },
{ label: "hold", power: 0, mode: BATTERY_MODE.HOLD },
{ label: "holdcharge", power: 0, mode: BATTERY_MODE.HOLDCHARGE },
{ label: "charge", power: -800, mode: BATTERY_MODE.CHARGE },
];
export const AllStates = () => ({
components: { SocGauge },
setup() {
return { scenarios, color: batteryColor(0) };
},
template: `
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 24px;">
<div v-for="s in scenarios" :key="s.label" style="display: flex; flex-direction: column; align-items: center; gap: 8px;">
<SocGauge :soc="60" :color="color" :power="s.power" :mode="s.mode" />
<small style="font-family: monospace; color: #666; font-size: 12px;">{{ s.label }}</small>
</div>
</div>
`,
});

View file

@ -1,54 +1,108 @@
<template>
<svg :width="size" :height="size" :viewBox="`0 0 ${size} ${size}`" class="flex-shrink-0">
<circle
:cx="c"
:cy="c"
:r="r"
fill="none"
stroke="var(--bs-border-color)"
:stroke-width="stroke"
/>
<circle
:cx="c"
:cy="c"
:r="r"
fill="none"
:stroke="color"
:stroke-width="stroke"
stroke-linecap="round"
:stroke-dasharray="circumference"
:stroke-dashoffset="offset"
:transform="`rotate(-90 ${c} ${c})`"
class="progress"
/>
</svg>
<span class="position-relative d-inline-flex flex-shrink-0">
<svg :width="SIZE" :height="SIZE" :viewBox="`0 0 ${SIZE} ${SIZE}`">
<circle
:cx="C"
:cy="C"
:r="R"
fill="none"
stroke="var(--bs-border-color)"
:stroke-width="STROKE"
/>
<circle
:cx="C"
:cy="C"
:r="R"
fill="none"
:stroke="color"
:stroke-width="STROKE"
stroke-linecap="round"
:stroke-dasharray="CIRCUMFERENCE"
:stroke-dashoffset="offset"
:transform="`rotate(-90 ${C} ${C})`"
class="progress"
/>
</svg>
<span class="status-icon" :style="{ color }">
<shopicon-regular-powersupply
size="s"
class="layer grid-icon"
:class="{ 'layer--active': mode === BATTERY_MODE.CHARGE }"
></shopicon-regular-powersupply>
<ArrowDown
:size="ICON_SIZE.M"
class="layer"
:class="{ 'layer--active': isCharging || isDischarging }"
:style="{ '--rotate': arrowRotate }"
/>
<Pause
:size="ICON_SIZE.S"
class="layer"
:class="{
'layer--active': mode === BATTERY_MODE.HOLD || mode === BATTERY_MODE.HOLDCHARGE,
}"
/>
<Dot :size="ICON_SIZE.S" class="layer" :class="{ 'layer--active': isIdle }" />
</span>
</span>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import { defineComponent, type PropType } from "vue";
import "@h2d2/shopicons/es/regular/powersupply";
import { BATTERY_MODE, ICON_SIZE } from "@/types/evcc";
import ArrowDown from "../MaterialIcon/ArrowDown.vue";
import Pause from "../MaterialIcon/Pause.vue";
import Dot from "../MaterialIcon/Dot.vue";
// fixed ring geometry, computed once rather than per instance
const SIZE = 38;
const STROKE = Math.max(3, Math.round(SIZE * 0.1));
const C = SIZE / 2;
const R = (SIZE - STROKE) / 2;
const CIRCUMFERENCE = 2 * Math.PI * R;
const LOCKED_MODES: BATTERY_MODE[] = [
BATTERY_MODE.HOLD,
BATTERY_MODE.HOLDCHARGE,
BATTERY_MODE.CHARGE,
];
// all four icon layers stay mounted and cross-fade via opacity/scale, so switching state
// tweens instead of hard-swapping
export default defineComponent({
name: "SocGauge",
components: { ArrowDown, Pause, Dot },
props: {
soc: { type: Number, default: 0 },
color: { type: String, default: "" },
size: { type: Number, default: 38 },
power: { type: Number, default: 0 }, // W, + discharging / - charging
mode: String as PropType<BATTERY_MODE>,
},
data() {
return { ICON_SIZE, BATTERY_MODE, SIZE, STROKE, C, R, CIRCUMFERENCE };
},
computed: {
stroke(): number {
return Math.max(4, Math.round(this.size * 0.12));
isLocked(): boolean {
return LOCKED_MODES.includes(this.mode as BATTERY_MODE);
},
c(): number {
return this.size / 2;
isCharging(): boolean {
return !this.isLocked && this.power < -50;
},
r(): number {
return (this.size - this.stroke) / 2;
isDischarging(): boolean {
return !this.isLocked && this.power > 50;
},
circumference(): number {
return 2 * Math.PI * this.r;
isIdle(): boolean {
return !this.isLocked && !this.isCharging && !this.isDischarging;
},
// rests left-facing so it always swings into place instead of just fading in
arrowRotate(): string {
if (this.isCharging) return "180deg";
if (this.isDischarging) return "0deg";
return "90deg";
},
offset(): number {
return this.circumference * (1 - Math.max(0, Math.min(100, this.soc)) / 100);
return CIRCUMFERENCE * (1 - Math.max(0, Math.min(100, this.soc)) / 100);
},
},
});
@ -58,4 +112,27 @@ export default defineComponent({
.progress {
transition: stroke-dashoffset var(--evcc-transition-medium) ease;
}
.status-icon {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.layer {
grid-area: 1 / 1;
opacity: 0;
--rotate: 0deg;
--scale: 1;
transform: scale(calc(var(--scale) * 0.5)) rotate(var(--rotate));
transition:
opacity var(--evcc-transition-medium) ease,
transform var(--evcc-transition-medium) ease;
}
.layer--active {
opacity: 1;
transform: scale(var(--scale)) rotate(var(--rotate));
}
.grid-icon {
--scale: 0.9;
}
</style>

View file

@ -27,6 +27,7 @@ export interface BatteryStatusCardModel {
power: number;
capacity: number;
color: string;
controllable: boolean;
suggestion: BatterySuggestion | null;
forecast: BatteryForecast | null;
}

View file

@ -1,6 +1,6 @@
<template>
<div class="root">
<div class="mb-2 label text-truncate-xs-only" :class="labelClass">
<div class="mb-2 label text-truncate" :class="labelClass" :style="{ color }">
<slot name="label">{{ label }}</slot>
</div>
<slot>
@ -34,6 +34,7 @@ export default defineComponent({
valueFmt: Function as PropType<(n: number) => string>,
extraValue: String,
align: { type: String, default: "center" },
color: String,
},
computed: {
labelClass() {
@ -58,7 +59,9 @@ export default defineComponent({
font-size: 18px;
}
.extraValue {
margin-top: 0.1rem;
color: var(--evcc-gray);
font-size: 14px;
font-weight: normal;
}
</style>

View file

@ -0,0 +1,18 @@
<template>
<svg :style="svgStyle" viewBox="0 -0.7 24 24">
<path
fill="currentColor"
d="M11 14.2V6q0-.425.288-.712T12 5t.713.288T13 6v8.2l2.9-2.9q.275-.275.7-.275t.7.275t.275.7t-.275.7l-4.6 4.6q-.3.3-.7.3t-.7-.3l-4.6-4.6q-.275-.275-.275-.7t.275-.7t.7-.275t.7.275z"
/>
</svg>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import icon from "@/mixins/icon";
export default defineComponent({
name: "ArrowDown",
mixins: [icon],
});
</script>

View file

@ -0,0 +1,15 @@
<template>
<svg :style="svgStyle" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="4" fill="currentColor" />
</svg>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import icon from "@/mixins/icon";
export default defineComponent({
name: "Dot",
mixins: [icon],
});
</script>

View file

@ -0,0 +1,18 @@
<template>
<svg :style="svgStyle" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M16 19q-.825 0-1.412-.587T14 17V7q0-.825.588-1.412T16 5t1.413.588T18 7v10q0 .825-.587 1.413T16 19m-8 0q-.825 0-1.412-.587T6 17V7q0-.825.588-1.412T8 5t1.413.588T10 7v10q0 .825-.587 1.413T8 19"
/>
</svg>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import icon from "@/mixins/icon";
export default defineComponent({
name: "Pause",
mixins: [icon],
});
</script>

View file

@ -91,8 +91,10 @@ export default defineComponent({
font-size: 18px;
}
.extraValue {
margin-top: 0.1rem;
color: var(--evcc-gray);
font-size: 14px;
font-weight: normal;
}
.custom-select {
left: 0;

View file

@ -93,8 +93,10 @@ export default defineComponent({
overflow: hidden;
}
.extraValue {
margin-top: 0.1rem;
color: var(--evcc-gray);
font-size: 14px;
font-weight: normal;
}
.custom-select {
left: 0;

View file

@ -1283,9 +1283,14 @@ export default defineComponent({
.config-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
grid-gap: 2rem;
grid-gap: 1rem;
margin-bottom: 5rem;
}
@media (min-width: 576px) {
.config-list {
grid-gap: 2rem;
}
}
.wip {
opacity: 0.2 !important;
display: none !important;

View file

@ -24,6 +24,9 @@
"charging": "Lädt",
"discharging": "Entlädt",
"energy": "Energie",
"gridCharge": "Netzladen",
"hold": "Entladesperre",
"holdCharge": "Ladesperre",
"ofTotal": "von {total}",
"power": "Leistung",
"soc": "Ladestand"

View file

@ -21,9 +21,12 @@
},
"battery": {
"card": {
"charging": "Charging",
"discharging": "Discharging",
"charging": "charging",
"discharging": "discharging",
"energy": "Energy",
"gridCharge": "grid charging",
"hold": "discharge locked",
"holdCharge": "charge locked",
"ofTotal": "of {total}",
"power": "Power",
"soc": "Battery level"

View file

@ -28,11 +28,11 @@ test.describe("experimental battery page", async () => {
// per-battery: soc, charge/discharge state and energy of total
const charging = cards.filter({ hasText: "76%" });
await expect(charging).toContainText("Charging"); // battery1 power -800 W
await expect(charging).toContainText("charging"); // battery1 power -800 W
await expect(charging).toContainText("13.5 kWh"); // of total capacity
const discharging = cards.filter({ hasText: "40%" });
await expect(discharging).toContainText("Discharging"); // battery2 power 1200 W
await expect(discharging).toContainText("discharging"); // battery2 power 1200 W
});
test("history chart: unit toggle persists and window pages", async ({ page }) => {