Revised plan UI (#11086)

* revised plan ui

* adjust tests

* add german translation
This commit is contained in:
Michael Geers 2023-12-13 14:37:02 +01:00 • committed by GitHub
parent 855185b186
commit cddea4f872
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 159 additions and 78 deletions

View file

@ -428,3 +428,10 @@ input[type="time"]::-webkit-calendar-picker-indicator {
.table {
--bs-table-bg: transparent;
}
/* larger check switch */
.form-switch .form-check-input {
height: 1.1rem;
width: calc(1.2rem + 0.75rem);
border-radius: 3rem;
}

View file

@ -75,6 +75,9 @@ export default {
return hourSum ? priceSum / hourSum : undefined;
},
fmtAvgPrice() {
if (!this.targetTime) {
return "—";
}
let price = this.activeSlot ? this.activeSlot.price : this.avgPrice;
if (price === undefined) {
return this.$t("main.targetChargePlan.unknownPrice");
@ -109,7 +112,7 @@ export default {
end.setHours(startHour + 1);
const endHour = end.getHours();
const day = this.weekdayShort(start);
const toLate = this.targetTime.getTime() <= start.getTime();
const toLate = this.targetTime && this.targetTime.getTime() <= start.getTime();
// TODO: handle multiple matching time slots
const price = this.findSlotInRange(start, end, rates)?.price;
const charging = this.findSlotInRange(start, end, plan) != null;

View file

@ -1,41 +1,28 @@
<template>
<div class="mt-4">
<div class="form-group d-lg-flex align-items-baseline justify-content-between">
<div v-if="plans.length > 0" class="container px-0 mb-3">
<div class="container px-0 mb-3">
<ChargingPlanSettingsEntry
v-for="(p, index) in plans"
:id="`${id}_${index}`"
:key="index"
:id="`${id}_0`"
class="mb-2"
v-bind="p"
v-bind="plans[0] || {}"
:vehicle-capacity="vehicleCapacity"
:range-per-soc="rangePerSoc"
:soc-per-kwh="socPerKwh"
:soc-based-planning="socBasedPlanning"
@plan-updated="(data) => updatePlan({ index, ...data })"
@plan-removed="() => removePlan(index)"
@plan-updated="(data) => updatePlan({ index: 0, ...data })"
@plan-removed="() => removePlan(0)"
/>
</div>
<div v-else>
<p>
{{ $t("main.targetCharge.planDescription") }}
</p>
<button class="btn btn-outline-primary" type="button" @click="addPlan">
{{ $t("main.targetCharge.setPlan") }}
</button>
</div>
<ChargingPlanWarnings v-bind="chargingPlanWarningsProps" class="mb-4" />
<hr />
<h5>
<div class="inner">
{{ $t(`main.targetCharge.${plans.length ? "currentPlan" : "noActivePlan"}`) }}
</div>
</div>
<div v-if="plans.length > 0">
<ChargingPlanWarnings v-bind="chargingPlanWarningsProps" class="mb-4" />
<hr />
<h5>
<div class="inner">{{ $t("main.targetCharge.preview") }}</div>
</h5>
<ChargingPlanPreview
v-if="chargingPlanPreviewProps"
v-bind="chargingPlanPreviewProps"
/>
</div>
</h5>
<ChargingPlanPreview v-if="chargingPlanPreviewProps" v-bind="chargingPlanPreviewProps" />
</div>
</template>
@ -86,11 +73,8 @@ export default {
chargingPlanWarningsProps: function () {
return this.collectProps(ChargingPlanWarnings);
},
selectedTargetTime: function () {
return new Date(this.effectivePlanTime);
},
chargingPlanPreviewProps: function () {
const targetTime = this.selectedTargetTime;
const targetTime = this.effectivePlanTime ? new Date(this.effectivePlanTime) : null;
const { rates } = this.tariff;
const { duration, plan, power } = this.plan;
const { currency, smartCostType } = this;
@ -112,7 +96,7 @@ export default {
},
methods: {
fetchPlan: async function () {
if (this.plans.length > 0 && !this.loading) {
if (!this.loading) {
try {
this.loading = true;
this.plan = (await api.get(`loadpoints/${this.id}/plan`)).data.result;
@ -168,6 +152,7 @@ h5 {
position: relative;
display: flex;
top: -25px;
margin-bottom: -0.5rem;
padding: 0 0.5rem;
justify-content: center;
}

View file

@ -6,7 +6,7 @@
{{ $t("main.chargingPlan.day") }}
</label>
</div>
<div class="col-6 col-lg-3">
<div class="col-6 col-lg-2">
<label :for="formId('time')">
{{ $t("main.chargingPlan.time") }}
</label>
@ -16,7 +16,9 @@
{{ $t("main.chargingPlan.goal") }}
</label>
</div>
<div class="col-2"></div>
<div class="col-6 col-lg-1">
<label :for="formId('active')"> {{ $t("main.chargingPlan.active") }} </label>
</div>
</div>
<div class="row">
<div class="col-6 d-lg-none col-form-label">
@ -41,7 +43,7 @@
{{ $t("main.chargingPlan.time") }}
</label>
</div>
<div class="col-6 col-lg-3 mb-2 mb-lg-0">
<div class="col-6 col-lg-2 mb-2 mb-lg-0">
<input
:id="formId('time')"
v-model="selectedTime"
@ -81,26 +83,53 @@
</option>
</select>
</div>
<div class="col-12 col-lg-2 d-flex justify-content-end align-items-baseline">
<div class="col-6 d-lg-none col-form-label">
<label :for="formId('active')">
{{ $t("main.chargingPlan.active") }}
</label>
</div>
<div class="col-2 d-flex align-items-center justify-content-start">
<div class="form-check form-switch">
<input
:id="formId('active')"
class="form-check-input"
type="checkbox"
role="switch"
data-testid="plan-active"
:checked="!isNew"
:disabled="timeInThePast"
@change="toggle"
/>
</div>
<button
v-if="dataChanged && !isNew"
type="button"
class="btn evcc-default-text text-decoration-underline"
@click="removePlan"
class="btn btn-sm btn-outline-primary ms-3 border-0 text-decoration-underline"
data-testid="plan-apply"
:disabled="timeInThePast"
@click="update"
>
{{ $t("main.chargingPlan.remove") }}
{{ $t("main.chargingPlan.update") }}
</button>
</div>
</div>
<p class="mb-0">
<span v-if="timeInThePast" class="d-block text-danger my-2">
{{ $t("main.targetCharge.targetIsInThePast") }}
</span>
</p>
</div>
</template>
<script>
import "@h2d2/shopicons/es/regular/checkmark";
import { distanceUnit } from "../units";
import formatter from "../mixins/formatter";
import { energyOptions } from "../utils/energyOptions";
const LAST_TARGET_TIME_KEY = "last_target_time";
const DEFAULT_TARGET_TIME = "7:00";
export default {
name: "ChargingPlanSettingsEntry",
@ -122,13 +151,10 @@ export default {
selectedTime: null,
selectedSoc: this.soc,
selectedEnergy: this.energy,
enabled: false,
};
},
computed: {
timeInThePast: function () {
const now = new Date();
return now >= this.selectedDate;
},
selectedDate: function () {
return new Date(`${this.selectedDay}T${this.selectedTime || "00:00"}`);
},
@ -149,25 +175,47 @@ export default {
// remove the first entry (0)
return options.slice(1);
},
originalData: function () {
if (this.isNew) {
return {};
}
return {
soc: this.soc,
energy: this.energy,
day: this.fmtDayString(new Date(this.time)),
time: this.fmtTimeString(new Date(this.time)),
};
},
dataChanged: function () {
const dateChanged =
this.originalData.day != this.selectedDay ||
this.originalData.time != this.selectedTime;
const goalChanged = this.socBasedPlanning
? this.originalData.soc != this.selectedSoc
: this.originalData.energy != this.selectedEnergy;
return dateChanged || goalChanged;
},
isNew: function () {
return !this.time && (!this.soc || !this.energy);
},
timeInThePast: function () {
const now = new Date();
return now >= this.selectedDate;
},
},
watch: {
time() {
this.initInputFields();
},
selectedDate() {
this.updatePlan();
},
selectedSoc() {
this.updatePlan();
},
selectedEnergy() {
this.updatePlan();
},
soc() {
this.selectedSoc = this.soc;
if (this.soc) {
this.selectedSoc = this.soc;
}
},
energy() {
this.selectedEnergy = this.energy;
if (this.energy) {
this.selectedEnergy = this.energy;
}
},
},
mounted() {
@ -182,7 +230,22 @@ export default {
return { value, name };
},
initInputFields: function () {
const date = new Date(this.time);
if (!this.selectedSoc) {
this.selectedSoc = 100;
}
if (!this.selectedEnergy) {
this.selectedEnergy = this.vehicleCapacity || 10;
}
let time = this.time;
if (!time) {
// no time but existing selection, keep it
if (this.selectedDay && this.selectedTime) {
return;
}
time = this.defaultTime();
}
const date = new Date(time);
this.selectedDay = this.fmtDayString(date);
this.selectedTime = this.fmtTimeString(date);
},
@ -208,7 +271,7 @@ export default {
}
return options;
},
updatePlan: function () {
update: function () {
try {
const hours = this.selectedDate.getHours();
const minutes = this.selectedDate.getMinutes();
@ -222,8 +285,30 @@ export default {
energy: this.selectedEnergy,
});
},
removePlan: function () {
this.$emit("plan-removed");
toggle: function (e) {
const { checked } = e.target;
if (checked) {
this.update();
} else {
this.$emit("plan-removed");
}
this.enabled = checked;
},
defaultTime: function () {
const [hours, minutes] = (
window.localStorage[LAST_TARGET_TIME_KEY] || DEFAULT_TARGET_TIME
).split(":");
const target = new Date();
target.setSeconds(0);
target.setMinutes(minutes);
target.setHours(hours);
// today or tomorrow?
const isInPast = target < new Date();
if (isInPast) {
target.setDate(target.getDate() + 1);
}
return target;
},
},
};

View file

@ -1,17 +1,14 @@
<template>
<p class="mb-0">
<span v-if="timeInThePast" class="d-block text-danger mb-1">
{{ $t("main.targetCharge.targetIsInThePast") }}
</span>
<span v-if="targetIsAboveVehicleLimit" class="d-block text-danger mb-1">
{{ $t("main.targetCharge.targetIsAboveVehicleLimit", { limit: vehicleLimitFmt }) }}
</span>
<span v-if="['off', 'now'].includes(mode)" class="d-block text-secondary mb-1">
{{ $t("main.targetCharge.onlyInPvMode") }}
</span>
<span v-if="targetIsAboveLimit" class="d-block text-secondary mb-1">
{{ $t("main.targetCharge.targetIsAboveLimit", { limit: limitFmt }) }}
</span>
<span v-if="['off', 'now'].includes(mode)" class="d-block text-secondary mb-1">
{{ $t("main.targetCharge.onlyInPvMode") }}
</span>
<span v-if="timeTooFarInTheFuture" class="d-block text-secondary mb-1">
{{ $t("main.targetCharge.targetIsTooFarInTheFuture") }}
</span>
@ -43,20 +40,18 @@ export default {
currency: String,
mode: String,
tariff: Object,
selectedTargetTime: Date,
vehicleTargetSoc: Number,
},
computed: {
timeInThePast: function () {
const now = new Date();
return now >= this.selectedTargetTime;
},
timeTooFarInTheFuture: function () {
if (!this.effectivePlanTime) {
return false;
}
if (this.tariff?.rates) {
const lastRate = this.tariff.rates[this.tariff.rates.length - 1];
if (lastRate?.end) {
const end = new Date(lastRate.end);
return this.selectedTargetTime >= end;
return new Date(this.effectivePlanTime) >= end;
}
}
return false;

View file

@ -164,13 +164,19 @@ modalTitle = "Sicher, dass du neu starten möchtest?"
vehicles = "Parkplatz"
[main.chargingPlan]
active = "Aktiv"
arrivalTab = "Ankunft"
day = "Tag"
departureTab = "Abfahrt"
goal = "Ladeziel"
modalTitle = "Ladeplanung"
none = "keiner"
remove = "Entfernen"
time = "Zeit"
title = "Plan"
titleMinSoc = "Min. Ladung"
titleTargetCharge = "Abfahrt"
update = "Anwenden"
[main.energyflow]
battery = "Batterie"
@ -243,19 +249,18 @@ logout = "abmelden"
activate = "Aktivieren"
co2Limit = "CO₂-Grenze von {co2}"
costLimitIgnore = "Die eingestellte {limit} wird in diesem Zeitraum ignoriert."
currentPlan = "Geplante Zeiten"
descriptionEnergy = "Bis wann sollen {targetEnergy} ins Fahrzeug geladen sein?"
descriptionSoc = "Wann soll das Fahrzeug auf {targetSoc}% geladen sein?"
inactiveLabel = "Zielzeit"
noActivePlan = "Kein aktiver Plan"
onlyInPvMode = "Ladeplanung ist nur im PV-Modus aktiv."
planDescription = "Gib eine Abfahrtszeit an, und evcc lädt das Fahrzeug so kostengünstig und umweltfreundlich wie möglich."
planDuration = "Ladedauer"
planPeriodLabel = "Zeitraum"
planPeriodValue = "{start} bis {end}"
planUnknown = "noch unbekannt"
preview = "Vorschau"
priceLimit = "Preisgrenze von {price}"
remove = "Entfernen"
setPlan = "Ladeplan festlegen"
setTargetTime = "keine"
targetIsAboveLimit = "Das eingestellte Ladelimit von {limit} wird in diesem Zeitraum ignoriert."
targetIsAboveVehicleLimit = "Erhöhe das Fahrzeuglimit ({limit}), um das Ladeziel zu erreichen."

View file

@ -161,6 +161,7 @@ modalTitle = "Are you sure you want to restart?"
vehicles = "Parking"
[main.chargingPlan]
active = "Active"
arrivalTab = "Arrival"
day = "Day"
departureTab = "Departure"
@ -172,6 +173,7 @@ time = "Time"
title = "Plan"
titleMinSoc = "Min charge"
titleTargetCharge = "Departure"
update = "Apply"
[main.energyflow]
battery = "Battery"
@ -244,19 +246,18 @@ logout = "log out"
activate = "Activate"
co2Limit = "CO₂ limit of {co2}"
costLimitIgnore = "The configured {limit} will be ignored during this period."
currentPlan = "Charging schedule"
descriptionEnergy = "Until when should {targetEnergy} be loaded into the vehicle?"
descriptionSoc = "When should the vehicle be charged to {targetSoc}%?"
inactiveLabel = "Target time"
noActivePlan = "No Active Plan"
onlyInPvMode = "Charging plan only works in solar mode."
planDescription = "Enter a departure time, and evcc will charge the vehicle as cost-effectively or environmentally friendly as possible."
planDuration = "Charging time"
planPeriodLabel = "Period"
planPeriodValue = "{start} to {end}"
planUnknown = "not known yet"
preview = "Preview"
priceLimit = "price limit of {price}"
remove = "Remove"
setPlan = "Set a charging plan"
setTargetTime = "none"
targetIsAboveLimit = "The configured charging limit of {limit} will be ignored during this period."
targetIsAboveVehicleLimit = "Increase vehicle limit ({limit}) to reach the charging goal."

View file

@ -13,7 +13,6 @@ test.afterEach(async () => {
async function setAndVerifyPlan(page, lp, { soc, energy }) {
await lp.getByTestId("charging-plan").getByRole("button", { name: "none" }).click();
await page.getByRole("button", { name: "Set a charging plan" }).click();
if (soc) {
await page.getByTestId("plan-soc").selectOption(soc);
@ -26,6 +25,7 @@ async function setAndVerifyPlan(page, lp, { soc, energy }) {
.textContent();
await page.getByTestId("plan-energy").selectOption(optionText);
}
await page.getByTestId("plan-active").click();
await page.getByRole("button", { name: "Close" }).click();
await expect(lp.getByTestId("charging-plan")).toContainText(soc || energy);
}
@ -41,11 +41,11 @@ test.describe("basic functionality", async () => {
await lp1.getByTestId("limit-soc").getByRole("combobox").selectOption("90%");
await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).click();
await page.getByRole("button", { name: "Set a charging plan" }).click();
await page.getByTestId("plan-day").selectOption({ index: 1 });
await page.getByTestId("plan-time").fill("09:30");
await page.getByTestId("plan-soc").selectOption("80%");
await page.getByTestId("plan-active").click();
await page.getByRole("button", { name: "Close" }).click();
await expect(lp1.getByTestId("plan-marker")).toBeVisible();