Charging plan: make strategy settings discoverable (#32452)

This commit is contained in:
Michael Geers 2026-08-06 09:25:43 +02:00 • committed by GitHub
parent 0df2610dcf
commit 9d0bc5a865
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 232 additions and 109 deletions

View file

@ -847,7 +847,7 @@ input::-webkit-date-and-time-value {
.collapsible-wrapper {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows var(--evcc-transition-fast) ease 0.2s;
transition: grid-template-rows var(--evcc-transition-fast) ease var(--evcc-transition-fast);
}
.collapsible-wrapper.open {
@ -858,12 +858,19 @@ input::-webkit-date-and-time-value {
.collapsible-content {
overflow: hidden;
opacity: 0;
transition: opacity 0.2s ease;
/* keep hidden content non-interactive and out of the a11y tree */
visibility: hidden;
transition:
opacity var(--evcc-transition-fast) ease,
visibility var(--evcc-transition-fast) ease;
}
.collapsible-wrapper.open .collapsible-content {
opacity: 1;
transition: opacity var(--evcc-transition-fast) ease 0.2s;
visibility: visible;
transition:
opacity var(--evcc-transition-fast) ease var(--evcc-transition-fast),
visibility var(--evcc-transition-fast) ease var(--evcc-transition-fast);
}
.sticky-bottom {

View file

@ -1,5 +1,5 @@
<template>
<div class="mb-5 mb-lg-4" data-testid="plan-entry">
<div class="mb-4" data-testid="plan-entry">
<h5
v-if="multiplePlans"
class="d-flex gap-3 align-items-baseline d-lg-none mb-4 fw-normal evcc-gray"
@ -8,6 +8,9 @@
<span class="text-uppercase fs-6">
{{ `${$t("main.chargingPlan.planNumber", { number: "#1" })}` }}
</span>
<small>
{{ $t("main.chargingPlan.once") }}
</small>
</h5>
<div class="row d-none d-lg-flex mb-2">

View file

@ -1,58 +1,86 @@
<template>
<div class="collapsible-wrapper" :class="{ open: show }">
<div class="collapsible-content pb-3">
<div v-if="disabled" class="row mb-4">
<div class="small text-muted">
<strong class="text-primary">{{ $t("general.note") }}</strong>
{{ $t("main.chargingPlan.strategyDisabledDescription") }}
</div>
<div data-testid="plan-strategy" class="mb-5 mb-lg-4">
<div :class="{ 'mb-2': open }">
<div v-if="disabled">
<span class="fw-bold evcc-gray">{{ $t("main.chargingPlan.strategy.label") }}:</span>
{{ " " }}
<span class="small">{{ summary }}</span>
</div>
<div v-else class="row">
<div class="col-12 col-sm-6 col-lg-3 offset-lg-3 mb-3">
<div class="row">
<label :for="formId('continuous')" class="col-form-label col-5 col-sm-12">
{{ $t("main.chargingPlan.optimization.label") }}
</label>
<div class="col-7 col-sm-12">
<select
:id="formId('continuous')"
v-model="localContinuous"
class="form-select"
@change="updateStrategy"
>
<option :value="false">
{{ $t("main.chargingPlan.optimization.cheapest") }}
</option>
<option :value="true">
{{ $t("main.chargingPlan.optimization.continuous") }}
</option>
</select>
</div>
<button
v-else
type="button"
class="btn btn-link btn-sm text-gray p-0 border-0 d-flex align-items-center text-start"
:class="{ 'text-primary': open }"
@click="open = !open"
>
<span class="fw-bold">
{{ $t("main.chargingPlan.strategy.label") }}<span v-if="!open">:</span>
</span>
<span v-if="!open" class="ms-1">{{ summary }}</span>
<DropdownIcon class="icon flex-shrink-0" :class="{ iconUp: open }" />
</button>
</div>
<div class="collapsible-wrapper" :class="{ open }">
<div class="collapsible-content ring-space">
<div v-if="disabled" class="row mb-4">
<div class="small text-muted">
<strong class="text-primary">{{ $t("general.note") }}</strong>
{{ $t("main.chargingPlan.strategyDisabledDescription") }}
</div>
</div>
<div class="col-sm-6 col-lg-3 mb-3">
<div class="row">
<label :for="formId('precondition')" class="col-form-label col-5 col-sm-12">
{{ $t("main.chargingPlan.precondition.label") }}
</label>
<div class="col-7 col-sm-12">
<select
:id="formId('precondition')"
v-model="localPrecondition"
class="form-select"
@change="updateStrategy"
>
<option :value="0">
{{ $t("main.chargingPlan.precondition.optionNo") }}
</option>
<option
v-for="opt in preconditionOptions"
:key="opt.value"
:value="opt.value"
<div v-else class="row">
<div class="col-12 col-lg-6 mb-3">
<div class="row">
<label :for="formId('continuous')" class="col-form-label col-5 col-lg-12">
{{ $t("main.chargingPlan.optimization.label") }}
</label>
<div class="col-7 col-lg-12">
<select
:id="formId('continuous')"
v-model="localContinuous"
class="form-select"
@change="updateStrategy"
>
{{ opt.name }}
</option>
</select>
<option :value="false">
{{ $t(`main.chargingPlan.optimization.${cheapestKey}`) }}
</option>
<option :value="true">
{{ $t("main.chargingPlan.optimization.continuous") }}
</option>
</select>
</div>
</div>
<div class="small text-muted mt-1">
{{ optimizationDescription }}
</div>
</div>
<div class="col-12 col-lg-6 mb-3">
<div class="row">
<label :for="formId('precondition')" class="col-form-label col-5 col-lg-12">
{{ $t("main.chargingPlan.precondition.label") }}
</label>
<div class="col-7 col-lg-12">
<select
:id="formId('precondition')"
v-model="localPrecondition"
class="form-select"
@change="updateStrategy"
>
<option :value="0">
{{ $t("main.chargingPlan.precondition.optionNo") }}
</option>
<option
v-for="opt in preconditionOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.name }}
</option>
</select>
</div>
</div>
<div class="small text-muted mt-1">
{{ $t("main.chargingPlan.precondition.description") }}
</div>
</div>
</div>
@ -62,28 +90,63 @@
</template>
<script lang="ts">
import { defineComponent } from "vue";
import { defineComponent, type PropType } from "vue";
import formatter from "@/mixins/formatter";
import type { PlanStrategy } from "@/types/evcc";
import { SMART_COST_TYPE, type PlanStrategy } from "@/types/evcc";
import DropdownIcon from "../MaterialIcon/Dropdown.vue";
export default defineComponent({
name: "ChargingPlanStrategy",
components: { DropdownIcon },
mixins: [formatter],
props: {
id: [String, Number],
show: Boolean,
precondition: { type: Number, default: 0 },
continuous: { type: Boolean, default: false },
disabled: Boolean,
smartCostType: String as PropType<SMART_COST_TYPE>,
},
emits: ["update"],
data() {
return {
open: false,
localPrecondition: this.precondition,
localContinuous: this.continuous,
};
},
computed: {
isCo2(): boolean {
return this.smartCostType === SMART_COST_TYPE.CO2;
},
cheapestKey(): string {
return this.isCo2 ? "cleanest" : "cheapest";
},
summary(): string {
if (this.disabled) {
return this.$t("main.chargingPlan.strategyDisabledDescription");
}
const parts = [
this.$t(
`main.chargingPlan.optimization.${this.continuous ? "continuous" : this.cheapestKey}`
),
];
if (this.precondition) {
parts.push(
this.$t("main.chargingPlan.precondition.summary", {
precondition: this.fmtDurationLong(this.precondition),
})
);
}
return parts.join(", ");
},
optimizationDescription(): string {
const variant = this.localContinuous
? this.isCo2
? "continuousCo2"
: "continuousPrice"
: this.cheapestKey;
return this.$t(`main.chargingPlan.optimization.${variant}Description`);
},
preconditionOptions() {
const HOUR = 60 * 60;
const QUARTER_HOUR = 0.25 * HOUR;
@ -142,3 +205,18 @@ export default defineComponent({
},
});
</script>
<style scoped>
/* overflow-hidden would clip the select focus rings */
.ring-space {
padding: 0.5rem;
margin: -0.5rem;
}
.icon {
transform: rotate(0deg);
transition: transform var(--evcc-transition-medium) ease;
}
.iconUp {
transform: rotate(-180deg);
}
</style>

View file

@ -4,7 +4,7 @@
<ChargingPlanRepeatingSettings
:showHeader="index === 0"
:number="index + 2"
class="mb-5 mb-lg-4"
class="mb-4"
:formIdPrefix="formIdPrefix"
v-bind="plan"
:rangePerSoc="rangePerSoc"

View file

@ -32,6 +32,14 @@
@updated="updateRepeatingPlans"
/>
</div>
<ChargingPlanStrategy
:id="id"
:precondition="effectivePlanStrategy?.precondition"
:continuous="effectivePlanStrategy?.continuous"
:disabled="strategyDisabled"
:smart-cost-type="smartCostType"
@update="updatePlanStrategy"
/>
</div>
</div>
<hr />
@ -40,29 +48,11 @@
<span v-if="!multiplePlans">
{{ $t(`main.targetCharge.${noActivePlan ? "preview" : "currentPlan"}`) }}
</span>
<span v-else-if="noActivePlan">{{ $t("main.targetCharge.preview") }} #1</span>
<span v-else-if="noActivePlan">{{ $t("main.targetCharge.preview") }}: #1</span>
<span v-else-if="alreadyReached">{{ $t("main.targetCharge.goalReached") }}</span>
<span v-else>{{ nextPlanTitle }}</span>
<button
type="button"
class="btn btn-sm"
:class="strategyOpen ? 'btn-secondary' : 'evcc-gray'"
:aria-label="$t('main.chargingPlan.strategySettings')"
tabindex="0"
@click="strategyOpen = !strategyOpen"
>
<shopicon-regular-adjust size="s"></shopicon-regular-adjust>
</button>
</div>
</h5>
<ChargingPlanStrategy
:id="id"
:precondition="effectivePlanStrategy?.precondition"
:continuous="effectivePlanStrategy?.continuous"
:disabled="strategyDisabled"
:show="strategyOpen"
@update="updatePlanStrategy"
/>
<ChargingPlanPreview v-bind="chargingPlanPreviewProps" />
<ChargingPlanWarnings v-bind="chargingPlanWarningsProps" />
</div>
@ -88,6 +78,7 @@ import type {
PlanStrategy,
PlanWrapper,
RepeatingPlan,
SMART_COST_TYPE,
StaticEnergyPlan,
StaticPlan,
StaticSocPlan,
@ -117,7 +108,7 @@ export default defineComponent({
socBasedPlanning: Boolean,
socPerKwh: Number,
rangePerSoc: Number,
smartCostType: String,
smartCostType: String as PropType<SMART_COST_TYPE>,
currency: String as PropType<CURRENCY>,
mode: String,
capacity: Number,
@ -138,7 +129,6 @@ export default defineComponent({
plan: {} as PlanWrapper,
activeTab: "time",
nextPlanId: 0,
strategyOpen: false,
updatePlanPreviewDebounced: null as any as () => void,
updateActivePlanDebounced: null as any as () => void,
};
@ -170,7 +160,7 @@ export default defineComponent({
return this.plan.duration === 0;
},
nextPlanTitle(): string {
return `${this.$t("main.targetCharge.nextPlan")} #${this.nextPlanId}`;
return `${this.$t("main.targetCharge.nextPlan")}: #${this.nextPlanId}`;
},
strategyDisabled(): boolean {
// options only make sense if there are variable prices
@ -327,7 +317,7 @@ export default defineComponent({
h5 {
position: relative;
display: flex;
top: -33px;
top: -26px;
margin-bottom: -0.5rem;
padding: 0 0.5rem;
justify-content: center;

View file

@ -1280,24 +1280,33 @@
"goal": "Ladeziel",
"modalTitle": "Ladeplanung",
"none": "keiner",
"once": "einmalig",
"optimization": {
"cheapest": "günstigst",
"cheapestDescription": "Wählt die günstigsten Zeitfenster. Kann zu mehreren Starts und Stopps führen.",
"cleanest": "sauberst",
"cleanestDescription": "Wählt die saubersten Zeitfenster. Kann zu mehreren Starts und Stopps führen.",
"continuous": "kontinuierlich",
"continuousCo2Description": "Wählt die sauberste unterbrechungsfreie Folge von Zeitfenstern.",
"continuousPriceDescription": "Wählt die günstigste unterbrechungsfreie Folge von Zeitfenstern.",
"label": "Optimierung"
},
"planNumber": "Plan {number}",
"precondition": {
"description": "Lade {duration} vor Abfahrt zur Batterie-Vorkonditionierung.",
"description": "Verlagert einen Teil des Ladens direkt vor die Abfahrt: warme Batterie, weniger Zeit mit hohem Ladestand.",
"label": "Spätes Laden",
"optionAll": "alles",
"optionNo": "nein"
"optionNo": "nein",
"summary": "{precondition} vorher"
},
"remove": "Entfernen",
"repeating": "wiederholend",
"repeatingPlans": "Wiederholende Pläne",
"selectAll": "Alle wählen",
"strategy": {
"label": "Strategie"
},
"strategyDisabledDescription": "Das Laden startet so spät wie möglich und wird rechtzeitig zur Abfahrt abgeschlossen. Mit dynamischen Netzpreisen oder einem CO₂-Tarif stehen hier weitere Optionen zur Verfügung.",
"strategySettings": "Strategie-Einstellungen",
"time": "Zeit",
"title": "Plan",
"unsavedChanges": "Ungespeicherte Änderungen vorhanden. Jetzt anwenden?",

View file

@ -1280,24 +1280,33 @@
"goal": "Charging goal",
"modalTitle": "Charging Plan",
"none": "none",
"once": "once",
"optimization": {
"cheapest": "cheapest",
"cheapestDescription": "Selects the cheapest slots. May result in multiple starts and stops.",
"cleanest": "cleanest",
"cleanestDescription": "Selects the cleanest slots. May result in multiple starts and stops.",
"continuous": "continuous",
"continuousCo2Description": "Selects the cleanest uninterrupted series of slots.",
"continuousPriceDescription": "Selects the cheapest uninterrupted series of slots.",
"label": "Optimization"
},
"planNumber": "Plan {number}",
"precondition": {
"description": "Charge {duration} before departure for battery preconditioning.",
"description": "Moves part of the charging right before departure: warm battery, less time at high charge level.",
"label": "Late Charging",
"optionAll": "everything",
"optionNo": "no"
"optionNo": "no",
"summary": "{precondition} before"
},
"remove": "Remove",
"repeating": "repeating",
"repeatingPlans": "Repeating plans",
"selectAll": "Select all",
"strategy": {
"label": "Strategy"
},
"strategyDisabledDescription": "Charging starts as late as possible to finish just in time for departure. With dynamic grid prices or CO₂ tariff, more options are available here.",
"strategySettings": "Strategy settings",
"time": "Time",
"title": "Plan",
"unsavedChanges": "There are unsaved changes. Apply now?",

View file

@ -9,6 +9,7 @@ const mobile = devices["iPhone 12 Mini"].viewport;
const CONFIG = "plan.evcc.yaml";
const CONFIG_NO_TARIFF = "basics.evcc.yaml";
const CONFIG_FIXED_TARIFF = "plan-fixed-tariff.evcc.yaml";
const CONFIG_CO2 = "battery-settings-co2.evcc.yaml";
test.beforeEach(async () => {
await start(CONFIG);
@ -79,11 +80,11 @@ test.describe("basic functionality", async () => {
await page.getByTestId("static-plan-day").selectOption({ index: 1 });
await page.getByTestId("static-plan-time").fill("09:30");
await page.getByTestId("static-plan-soc").selectOption("80%");
await page.getByRole("button", { name: "Strategy settings" }).click();
await page.getByTestId("plan-strategy").getByRole("button", { name: "cheapest" }).click();
await expect(page.getByLabel("Late Charging")).toBeVisible();
await page.getByLabel("Late Charging").selectOption("1 hour");
await page.getByTestId("static-plan-active").click();
await page.getByRole("button", { name: "Close" }).click();
await page.getByRole("button", { name: "Close", exact: true }).click();
const tomorrowShort = getWeekday(1, "short");
await expect(lp1.getByTestId("plan-marker")).toBeVisible();
@ -487,23 +488,23 @@ test.describe("repeating", async () => {
// with multiple plans, preview shows first plan
await page.waitForLoadState("networkidle");
await expect(modal.getByTestId("plan-preview-title")).toHaveText("Preview plan #1");
await expect(modal.getByTestId("plan-preview-title")).toHaveText("Preview plan: #1");
await expect(modal.getByTestId("target-text")).toContainText("09:00");
// activate #1 - should show next plan #1
await modal.getByTestId("static-plan-active").click();
await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #1");
await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan: #1");
await expect(modal.getByTestId("target-text")).toContainText("09:00");
// deactivate #1, activate #2 - should show next plan #2
await modal.getByTestId("static-plan-active").click();
await modal.getByTestId("repeating-plan-active").click();
await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #2");
await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan: #2");
await expect(modal.getByTestId("target-text")).toContainText("11:11");
// back to preview if no active plan
await modal.getByTestId("repeating-plan-active").click();
await expect(modal.getByTestId("plan-preview-title")).toHaveText("Preview plan #1");
await expect(modal.getByTestId("plan-preview-title")).toHaveText("Preview plan: #1");
await expect(modal.getByTestId("target-text")).toContainText("9:00");
});
@ -550,7 +551,7 @@ test.describe("repeating", async () => {
await page.waitForLoadState("networkidle");
// specific weekday and time
await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #2");
await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan: #2");
await expect(modal.getByTestId("target-text")).toContainText("Thu 09:22");
});
@ -599,12 +600,12 @@ test.describe("repeating", async () => {
await plan3.getByTestId("repeating-plan-active").click();
// check next plans
await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #3");
await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan: #3");
await expect(modal.getByTestId("target-text")).toContainText("9:10");
// disable plan #3
await plan3.getByTestId("repeating-plan-active").click();
await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #2");
await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan: #2");
await expect(modal.getByTestId("target-text")).toContainText("9:20");
// change plan #2 to yesterday
@ -613,13 +614,13 @@ test.describe("repeating", async () => {
await days2.getByRole("checkbox", { name: yesterday }).click(); // check
await days2.click(); // close
// no changes yet
await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #2");
await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan: #2");
await expect(modal.getByTestId("target-text")).toContainText("9:20");
// apply
await plan2.getByTestId("repeating-plan-apply").click();
await expect(plan2.getByTestId("repeating-plan-apply")).not.toBeVisible();
await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #1");
await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan: #1");
await expect(modal.getByTestId("target-text")).toContainText("9:30");
// set lower targets than vehicle soc (50%)
@ -654,13 +655,14 @@ test.describe("repeating", async () => {
await plan.getByRole("checkbox", { name: "Select all" }).click(); // uncheck all
await plan.getByRole("checkbox", { name: tomorrow }).check();
await plan.getByTestId("repeating-plan-time").fill("09:20");
await modal.getByRole("button", { name: "Strategy settings" }).click();
await plan.getByTestId("repeating-plan-weekdays").click(); // close dropdown
await modal.getByTestId("plan-strategy").getByRole("button", { name: "cheapest" }).click();
await expect(modal.getByLabel("Late Charging")).toBeVisible();
await modal.getByLabel("Late Charging").selectOption("2 hours");
await plan.getByTestId("repeating-plan-active").click();
await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #2");
await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan: #2");
await expect(modal.getByTestId("target-text")).toContainText("09:20");
await modal.getByRole("button", { name: "Close" }).click();
await modal.getByRole("button", { name: "Close", exact: true }).click();
await expect(
lp1.getByTestId("charging-plan").getByRole("button", { name: `${tomorrowShort} 09:20` })
).toBeVisible();
@ -679,7 +681,7 @@ test.describe("repeating", async () => {
.getByRole("button", { name: `${tomorrowShort} 09:20` })
.click();
await expect(modal.getByTestId("plan-entry")).toHaveCount(2);
await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #2");
await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan: #2");
await expect(modal.getByTestId("target-text")).toContainText("09:20");
await expect(modal.getByLabel("Late Charging")).toHaveValue("7200");
});
@ -695,12 +697,37 @@ test.describe("plan strategy", async () => {
const modal = page.getByTestId("charging-plan-modal");
await expect(modal.getByTestId("static-plan-active")).toBeVisible();
// Strategy toggle should be visible but expand to show informational note only
await expect(modal.getByRole("button", { name: "Strategy settings" })).toBeVisible();
await modal.getByRole("button", { name: "Strategy settings" }).click();
// Strategy line shows informational note only, not clickable
await expect(modal.getByTestId("plan-strategy")).toContainText("just in time for departure");
await expect(modal.getByTestId("plan-strategy").getByRole("button")).toHaveCount(0);
await expect(modal.getByLabel("Optimization")).not.toBeVisible();
await expect(modal.getByLabel("Late Charging")).not.toBeVisible();
await expect(modal).toContainText("just in time for departure");
});
test("co2 tariff: cleanest instead of cheapest", async ({ page }) => {
await restart(CONFIG_CO2);
await page.goto("/");
const lp1 = await page.getByTestId("loadpoint").first();
await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).click();
const modal = page.getByTestId("charging-plan-modal");
await expect(modal.getByTestId("plan-strategy")).toContainText("cleanest");
// collapsed selects must not be interactive
await expect(modal.getByLabel("Optimization")).not.toBeVisible();
await expect(modal.getByLabel("Late Charging")).not.toBeVisible();
await modal.getByTestId("plan-strategy").getByRole("button", { name: "cleanest" }).click();
const optimization = modal.getByLabel("Optimization");
await expect(optimization).toBeVisible();
await expect(optimization.getByRole("option", { name: "cleanest" })).toHaveCount(1);
await expect(modal.getByTestId("plan-strategy")).toContainText("cleanest slots");
await optimization.selectOption("continuous");
await expect(modal.getByTestId("plan-strategy")).toContainText(
"cleanest uninterrupted series"
);
});
test("visible and functional on mobile", async ({ page }) => {
@ -719,11 +746,11 @@ test.describe("plan strategy", async () => {
const modal = page.getByTestId("charging-plan-modal");
// Strategy toggle should be visible on mobile
await expect(modal.getByRole("button", { name: "Strategy settings" })).toBeVisible();
// Strategy summary should be visible on mobile
await expect(modal.getByTestId("plan-strategy")).toContainText("cheapest");
// Open strategy panel
await modal.getByRole("button", { name: "Strategy settings" }).click();
await modal.getByTestId("plan-strategy").getByRole("button", { name: "cheapest" }).click();
// Strategy controls should be visible and functional
await expect(modal.getByLabel("Optimization")).toBeVisible();