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;