Add schedule for planner (#16091)
This commit is contained in:
parent
35c9452d76
commit
a35a705e48
33 changed files with 1771 additions and 421 deletions
9
api/plans.go
Normal file
9
api/plans.go
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
package api
|
||||
|
||||
type RepeatingPlanStruct struct {
|
||||
Weekdays []int `json:"weekdays"` // 0-6 (Sunday-Saturday)
|
||||
Time string `json:"time"` // HH:MM
|
||||
Tz string `json:"tz"` // timezone in IANA format
|
||||
Soc int `json:"soc"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
|
@ -632,3 +632,7 @@ html.app .modal-dialog {
|
|||
.alert-danger code {
|
||||
color: var(--evcc-darkest-green);
|
||||
}
|
||||
|
||||
input::-webkit-date-and-time-value {
|
||||
text-align: left;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,12 @@
|
|||
data-testid="charging-plan"
|
||||
>
|
||||
<div class="value m-0 d-block align-items-baseline justify-content-center">
|
||||
<button class="value-button p-0" :class="buttonColor" @click="openModal">
|
||||
<button
|
||||
class="value-button p-0"
|
||||
:class="buttonColor"
|
||||
data-testid="charging-plan-button"
|
||||
@click="openModal"
|
||||
>
|
||||
<strong v-if="enabled">
|
||||
<span class="targetTimeLabel"> {{ targetTimeLabel }}</span>
|
||||
<div
|
||||
|
|
@ -33,6 +38,7 @@
|
|||
tabindex="-1"
|
||||
role="dialog"
|
||||
aria-hidden="true"
|
||||
data-testid="charging-plan-modal"
|
||||
>
|
||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
|
|
@ -74,11 +80,12 @@
|
|||
</li>
|
||||
</ul>
|
||||
<div v-if="isModalVisible">
|
||||
<ChargingPlanSettings
|
||||
<ChargingPlansSettings
|
||||
v-if="departureTabActive"
|
||||
v-bind="chargingPlanSettingsProps"
|
||||
@plan-updated="updatePlan"
|
||||
@plan-removed="removePlan"
|
||||
v-bind="chargingPlansSettingsProps"
|
||||
@static-plan-updated="updateStaticPlan"
|
||||
@static-plan-removed="removeStaticPlan"
|
||||
@repeating-plans-updated="updateRepeatingPlans"
|
||||
/>
|
||||
<ChargingPlanArrival
|
||||
v-if="arrivalTabActive"
|
||||
|
|
@ -98,7 +105,7 @@
|
|||
<script>
|
||||
import Modal from "bootstrap/js/dist/modal";
|
||||
import LabelAndValue from "./LabelAndValue.vue";
|
||||
import ChargingPlanSettings from "./ChargingPlanSettings.vue";
|
||||
import ChargingPlansSettings from "./ChargingPlansSettings.vue";
|
||||
import ChargingPlanArrival from "./ChargingPlanArrival.vue";
|
||||
|
||||
import formatter from "../mixins/formatter";
|
||||
|
|
@ -110,7 +117,7 @@ const ONE_MINUTE = 60 * 1000;
|
|||
|
||||
export default {
|
||||
name: "ChargingPlan",
|
||||
components: { LabelAndValue, ChargingPlanSettings, ChargingPlanArrival },
|
||||
components: { LabelAndValue, ChargingPlansSettings, ChargingPlanArrival },
|
||||
mixins: [formatter, collector],
|
||||
props: {
|
||||
currency: String,
|
||||
|
|
@ -162,12 +169,18 @@ export default {
|
|||
limitSoc: function () {
|
||||
return this.vehicle?.limitSoc;
|
||||
},
|
||||
plans: function () {
|
||||
staticPlan: function () {
|
||||
if (this.socBasedPlanning) {
|
||||
return this.vehicle?.plans || [];
|
||||
return this.vehicle?.plan;
|
||||
}
|
||||
if (this.planEnergy && this.planTime) {
|
||||
return [{ energy: this.planEnergy, time: this.planTime }];
|
||||
return { energy: this.planEnergy, time: this.planTime };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
repeatingPlans: function () {
|
||||
if (this.vehicle?.repeatingPlans.length > 0) {
|
||||
return [...this.vehicle.repeatingPlans];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
|
|
@ -183,8 +196,8 @@ export default {
|
|||
arrivalTabActive: function () {
|
||||
return this.activeTab === "arrival";
|
||||
},
|
||||
chargingPlanSettingsProps: function () {
|
||||
return this.collectProps(ChargingPlanSettings);
|
||||
chargingPlansSettingsProps: function () {
|
||||
return this.collectProps(ChargingPlansSettings);
|
||||
},
|
||||
chargingPlanArrival: function () {
|
||||
return this.collectProps(ChargingPlanArrival);
|
||||
|
|
@ -211,6 +224,11 @@ export default {
|
|||
effectivePlanTime() {
|
||||
this.updateTargetTimeLabel();
|
||||
},
|
||||
"$i18n.locale": {
|
||||
handler() {
|
||||
this.updateTargetTimeLabel();
|
||||
},
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.modal = Modal.getOrCreateInstance(this.$refs.modal);
|
||||
|
|
@ -263,7 +281,7 @@ export default {
|
|||
showArrivalTab: function () {
|
||||
this.activeTab = "arrival";
|
||||
},
|
||||
updatePlan: function ({ soc, time, energy }) {
|
||||
updateStaticPlan: function ({ soc, time, energy }) {
|
||||
const timeISO = time.toISOString();
|
||||
if (this.socBasedPlanning) {
|
||||
api.post(`${this.apiVehicle}plan/soc/${soc}/${timeISO}`);
|
||||
|
|
@ -271,13 +289,16 @@ export default {
|
|||
api.post(`${this.apiLoadpoint}plan/energy/${energy}/${timeISO}`);
|
||||
}
|
||||
},
|
||||
removePlan: function () {
|
||||
removeStaticPlan: function () {
|
||||
if (this.socBasedPlanning) {
|
||||
api.delete(`${this.apiVehicle}plan/soc`);
|
||||
} else {
|
||||
api.delete(`${this.apiLoadpoint}plan/energy`);
|
||||
}
|
||||
},
|
||||
updateRepeatingPlans: function (plans) {
|
||||
api.post(`${this.apiVehicle}plan/repeating`, { plans });
|
||||
},
|
||||
setMinSoc: function (soc) {
|
||||
api.post(`${this.apiVehicle}minsoc/${soc}`);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -37,8 +37,8 @@ describe("basics", () => {
|
|||
result = wrapper.vm.slots;
|
||||
});
|
||||
|
||||
test("should return 42 slots", () => {
|
||||
expect(result.length).eq(42);
|
||||
test("should return 39 slots", () => {
|
||||
expect(result.length).eq(39);
|
||||
});
|
||||
|
||||
test("slots should be an hour apart", () => {
|
||||
|
|
|
|||
|
|
@ -25,7 +25,12 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TariffChart :slots="slots" @slot-hovered="slotHovered" />
|
||||
<TariffChart
|
||||
:slots="slots"
|
||||
:target-text="targetText"
|
||||
:target-offset="targetHourOffset"
|
||||
@slot-hovered="slotHovered"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -114,12 +119,28 @@ export default {
|
|||
}
|
||||
return null;
|
||||
},
|
||||
targetHourOffset() {
|
||||
if (!this.targetTime) {
|
||||
return null;
|
||||
}
|
||||
const start = new Date(this.startTime);
|
||||
start.setMinutes(0);
|
||||
start.setSeconds(0);
|
||||
start.setMilliseconds(0);
|
||||
return (this.targetTime.getTime() - start.getTime()) / (60 * 60 * 1000);
|
||||
},
|
||||
targetText() {
|
||||
if (!this.targetTime) {
|
||||
return null;
|
||||
}
|
||||
return this.fmtWeekdayTime(this.targetTime);
|
||||
},
|
||||
slots() {
|
||||
const result = [];
|
||||
const rates = this.convertDates(this.rates);
|
||||
const plan = this.convertDates(this.plan);
|
||||
const oneHour = 60 * 60 * 1000;
|
||||
for (let i = 0; i < 42; i++) {
|
||||
for (let i = 0; i < 39; i++) {
|
||||
const start = new Date(this.startTime.getTime() + oneHour * i);
|
||||
const startHour = start.getHours();
|
||||
start.setMinutes(0);
|
||||
|
|
@ -132,13 +153,23 @@ export default {
|
|||
const toLate = this.targetTime && this.targetTime <= start;
|
||||
// TODO: handle multiple matching time slots
|
||||
const price = this.findSlotInRange(start, end, rates)?.price;
|
||||
const isTarget = start <= this.targetTime && end > this.targetTime;
|
||||
const charging = this.findSlotInRange(start, end, plan) != null;
|
||||
const warning =
|
||||
charging &&
|
||||
this.targetTime &&
|
||||
end > this.targetTime &&
|
||||
this.targetTime < this.endTime;
|
||||
result.push({ day, price, startHour, endHour, charging, toLate, warning });
|
||||
result.push({
|
||||
day,
|
||||
price,
|
||||
startHour,
|
||||
endHour,
|
||||
charging,
|
||||
toLate,
|
||||
warning,
|
||||
isTarget,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
|
|
|||
244
assets/js/components/ChargingPlanRepeatingSettings.vue
Normal file
244
assets/js/components/ChargingPlanRepeatingSettings.vue
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
<template>
|
||||
<div>
|
||||
<h5
|
||||
class="d-flex gap-3 align-items-baseline d-lg-none mb-4 fw-normal evcc-gray"
|
||||
data-testid="repeating-plan-title"
|
||||
>
|
||||
<span class="text-uppercase fs-6">
|
||||
{{ `${$t("main.chargingPlan.planNumber", { number: `#${number}` })}` }}
|
||||
</span>
|
||||
<small>
|
||||
{{ $t("main.chargingPlan.repeating") }}
|
||||
</small>
|
||||
</h5>
|
||||
|
||||
<div v-if="showHeader" class="row d-none d-lg-flex mb-2">
|
||||
<div class="plan-id d-none d-lg-flex"></div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<label :for="formId('weekdays')">
|
||||
{{ $t("main.chargingPlan.weekdays") }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-6 col-lg-2">
|
||||
<label :for="formId('time')">
|
||||
{{ $t("main.chargingPlan.time") }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<label :for="formId('goal')">
|
||||
{{ $t("main.chargingPlan.goal") }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-6 col-lg-1">
|
||||
<label :for="formId('active')"> {{ $t("main.chargingPlan.active") }} </label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="plan-id d-none d-lg-flex align-items-center justify-content-start fs-6">
|
||||
#{{ number }}
|
||||
</div>
|
||||
<div class="col-5 d-lg-none col-form-label">
|
||||
<label :for="formId('weekdays')">
|
||||
{{ $t("main.chargingPlan.weekdays") }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-7 col-lg-3 mb-2 mb-lg-0">
|
||||
<MultiSelect
|
||||
:id="formId('weekdays')"
|
||||
:value="selectedWeekdays"
|
||||
:options="dayOptions"
|
||||
:selectAllLabel="$t('main.chargingPlan.selectAll')"
|
||||
data-testid="repeating-plan-weekdays"
|
||||
@update:model-value="changeSelectedWeekdays"
|
||||
>
|
||||
{{ weekdaysLabel }}
|
||||
</MultiSelect>
|
||||
</div>
|
||||
<div class="col-5 d-lg-none col-form-label">
|
||||
<label :for="formId('time')">
|
||||
{{ $t("main.chargingPlan.time") }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-7 col-lg-2 mb-2 mb-lg-0">
|
||||
<input
|
||||
:id="formId('time')"
|
||||
v-model="selectedTime"
|
||||
type="time"
|
||||
class="form-control mx-0 text-start"
|
||||
:step="60 * 5"
|
||||
data-testid="repeating-plan-time"
|
||||
required
|
||||
@change="update()"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-5 d-lg-none col-form-label">
|
||||
<label :for="formId('goal')">
|
||||
{{ $t("main.chargingPlan.goal") }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-7 col-lg-3 mb-2 mb-lg-0">
|
||||
<select
|
||||
:id="formId('goal')"
|
||||
v-model="selectedSoc"
|
||||
class="form-select mx-0"
|
||||
data-testid="repeating-plan-soc"
|
||||
@change="update()"
|
||||
>
|
||||
<option v-for="opt in socOptions" :key="opt.value" :value="opt.value">
|
||||
{{ opt.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-5 d-lg-none col-form-label">
|
||||
<label :for="formId('active')">
|
||||
{{ $t("main.chargingPlan.active") }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-2 col-lg-1 d-flex align-items-center">
|
||||
<div class="form-check form-switch">
|
||||
<input
|
||||
:id="formId('active')"
|
||||
v-model="selectedActive"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
data-testid="repeating-plan-active"
|
||||
:checked="selectedActive"
|
||||
tabindex="0"
|
||||
@change="update(true)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-5 col-lg-2 d-flex align-items-center">
|
||||
<button
|
||||
v-if="showApply"
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-primary border-0 text-decoration-underline"
|
||||
data-testid="repeating-plan-apply"
|
||||
tabindex="0"
|
||||
@click="update(true)"
|
||||
>
|
||||
{{ $t("main.chargingPlan.update") }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-secondary border-0"
|
||||
aria-label="Remove"
|
||||
tabindex="0"
|
||||
@click="$emit('removed', id)"
|
||||
>
|
||||
<shopicon-regular-trash size="s" class="flex-shrink-0"></shopicon-regular-trash>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import "@h2d2/shopicons/es/regular/trash";
|
||||
import { distanceUnit } from "../units";
|
||||
import MultiSelect from "./MultiSelect.vue";
|
||||
import formatter from "../mixins/formatter";
|
||||
import deepEqual from "../utils/deepEqual";
|
||||
export default {
|
||||
name: "ChargingPlanRepeatingSettings",
|
||||
components: {
|
||||
MultiSelect,
|
||||
},
|
||||
mixins: [formatter],
|
||||
props: {
|
||||
number: Number,
|
||||
weekdays: { type: Array, default: () => [] },
|
||||
time: String,
|
||||
tz: String,
|
||||
soc: Number,
|
||||
showHeader: Boolean,
|
||||
active: Boolean,
|
||||
rangePerSoc: Number,
|
||||
formIdPrefix: String,
|
||||
},
|
||||
emits: ["updated", "removed"],
|
||||
data: function () {
|
||||
return {
|
||||
selectedWeekdays: this.weekdays,
|
||||
selectedTime: this.time,
|
||||
selectedSoc: this.soc,
|
||||
selectedActive: this.active,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
dataChanged: function () {
|
||||
return (
|
||||
!deepEqual(this.weekdays, this.selectedWeekdays) ||
|
||||
this.time !== this.selectedTime ||
|
||||
this.soc !== this.selectedSoc ||
|
||||
this.active !== this.selectedActive
|
||||
);
|
||||
},
|
||||
showApply: function () {
|
||||
return this.dataChanged && this.selectedActive;
|
||||
},
|
||||
weekdaysLabel: function () {
|
||||
return this.getShortenedWeekdaysLabel(this.selectedWeekdays);
|
||||
},
|
||||
socOptions: function () {
|
||||
// a list of entries from 5 to 100 with a step of 5
|
||||
return Array.from(Array(20).keys())
|
||||
.map((i) => 5 + i * 5)
|
||||
.map(this.socOption);
|
||||
},
|
||||
dayOptions: function () {
|
||||
return this.getWeekdaysList("long");
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
weekdays(newValue, oldValue) {
|
||||
if (!deepEqual(newValue, oldValue)) {
|
||||
this.selectedWeekdays = newValue;
|
||||
}
|
||||
},
|
||||
time(newValue) {
|
||||
this.selectedTime = newValue;
|
||||
},
|
||||
soc(newValue) {
|
||||
this.selectedSoc = newValue;
|
||||
},
|
||||
active(newValue) {
|
||||
this.selectedActive = newValue;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
changeSelectedWeekdays: function (weekdays) {
|
||||
this.selectedWeekdays = weekdays;
|
||||
this.update();
|
||||
},
|
||||
formId: function (name) {
|
||||
return `${this.formIdPrefix}-${this.number}-${name}`;
|
||||
},
|
||||
socOption: function (value) {
|
||||
const name = this.fmtSocOption(value, this.rangePerSoc, distanceUnit());
|
||||
return { value, name };
|
||||
},
|
||||
update: function (forceSave = false) {
|
||||
const plan = {
|
||||
weekdays: this.selectedWeekdays,
|
||||
time: this.selectedTime,
|
||||
soc: this.selectedSoc,
|
||||
tz: this.tz,
|
||||
active: this.selectedActive,
|
||||
};
|
||||
|
||||
if (forceSave || !this.selectedActive) {
|
||||
this.$emit("updated", plan);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style scoped>
|
||||
.plan-id {
|
||||
width: 2.5rem;
|
||||
color: var(--evcc-gray);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,200 +0,0 @@
|
|||
<template>
|
||||
<div class="mt-4">
|
||||
<div class="form-group d-lg-flex align-items-baseline justify-content-between">
|
||||
<div class="container px-0 mb-3">
|
||||
<ChargingPlanSettingsEntry
|
||||
:id="`${id}_0`"
|
||||
class="mb-2"
|
||||
v-bind="plans[0] || {}"
|
||||
:capacity="capacity"
|
||||
:range-per-soc="rangePerSoc"
|
||||
:soc-per-kwh="socPerKwh"
|
||||
:soc-based-planning="socBasedPlanning"
|
||||
@plan-updated="(data) => updatePlan({ index: 0, ...data })"
|
||||
@plan-removed="() => removePlan(0)"
|
||||
@plan-preview="previewPlan"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ChargingPlanWarnings v-bind="chargingPlanWarningsProps" class="mb-4" />
|
||||
<hr />
|
||||
<h5>
|
||||
<div class="inner" data-testid="plan-preview-title">
|
||||
{{ $t(`main.targetCharge.${isPreview ? "preview" : "currentPlan"}`) }}
|
||||
</div>
|
||||
</h5>
|
||||
<ChargingPlanPreview v-bind="chargingPlanPreviewProps" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ChargingPlanPreview from "./ChargingPlanPreview.vue";
|
||||
import ChargingPlanSettingsEntry from "./ChargingPlanSettingsEntry.vue";
|
||||
import ChargingPlanWarnings from "./ChargingPlanWarnings.vue";
|
||||
import formatter from "../mixins/formatter";
|
||||
import collector from "../mixins/collector";
|
||||
import api from "../api";
|
||||
import deepEqual from "../utils/deepEqual";
|
||||
|
||||
const DEFAULT_TARGET_TIME = "7:00";
|
||||
const LAST_TARGET_TIME_KEY = "last_target_time";
|
||||
|
||||
export default {
|
||||
name: "ChargingPlanSettings",
|
||||
components: { ChargingPlanPreview, ChargingPlanSettingsEntry, ChargingPlanWarnings },
|
||||
mixins: [formatter, collector],
|
||||
props: {
|
||||
id: [String, Number],
|
||||
plans: { type: Array, default: () => [] },
|
||||
effectiveLimitSoc: Number,
|
||||
effectivePlanTime: String,
|
||||
effectivePlanSoc: Number,
|
||||
planEnergy: Number,
|
||||
limitEnergy: Number,
|
||||
socBasedPlanning: Boolean,
|
||||
socPerKwh: Number,
|
||||
rangePerSoc: Number,
|
||||
smartCostLimit: Number,
|
||||
smartCostType: String,
|
||||
currency: String,
|
||||
mode: String,
|
||||
capacity: Number,
|
||||
vehicle: Object,
|
||||
vehicleLimitSoc: Number,
|
||||
planOverrun: Number,
|
||||
},
|
||||
emits: ["plan-removed", "plan-updated"],
|
||||
data: function () {
|
||||
return {
|
||||
tariff: {},
|
||||
plan: {},
|
||||
activeTab: "time",
|
||||
isPreview: false,
|
||||
debounceTimer: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
chargingPlanWarningsProps: function () {
|
||||
return this.collectProps(ChargingPlanWarnings);
|
||||
},
|
||||
chargingPlanPreviewProps: function () {
|
||||
const { rates } = this.tariff;
|
||||
const { duration, plan, power, planTime } = this.plan;
|
||||
const targetTime = planTime ? new Date(planTime) : null;
|
||||
const { currency, smartCostType } = this;
|
||||
return rates
|
||||
? { duration, plan, power, rates, targetTime, currency, smartCostType }
|
||||
: null;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
plans(newPlans, oldPlans) {
|
||||
if (!deepEqual(newPlans, oldPlans) && newPlans.length > 0) {
|
||||
this.fetchPlanDebounced();
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
if (this.plans.length > 0) {
|
||||
this.fetchPlanDebounced();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
fetchActivePlan: async function () {
|
||||
return await api.get(`loadpoints/${this.id}/plan`);
|
||||
},
|
||||
fetchPlanPreviewSoc: async function (soc, time) {
|
||||
const timeISO = time.toISOString();
|
||||
return await api.get(`loadpoints/${this.id}/plan/preview/soc/${soc}/${timeISO}`);
|
||||
},
|
||||
fetchPlanPreviewEnergy: async function (energy, time) {
|
||||
const timeISO = time.toISOString();
|
||||
return await api.get(`loadpoints/${this.id}/plan/preview/energy/${energy}/${timeISO}`);
|
||||
},
|
||||
fetchPlan: async function (preview) {
|
||||
try {
|
||||
let planRes = null;
|
||||
if (preview && this.socBasedPlanning) {
|
||||
planRes = await this.fetchPlanPreviewSoc(preview.soc, preview.time);
|
||||
this.isPreview = true;
|
||||
} else if (preview && !this.socBasedPlanning) {
|
||||
planRes = await this.fetchPlanPreviewEnergy(preview.energy, preview.time);
|
||||
this.isPreview = true;
|
||||
} else {
|
||||
planRes = await this.fetchActivePlan();
|
||||
this.isPreview = false;
|
||||
}
|
||||
this.plan = planRes.data.result;
|
||||
|
||||
const tariffRes = await api.get(`tariff/planner`, {
|
||||
validateStatus: function (status) {
|
||||
return status >= 200 && status < 500;
|
||||
},
|
||||
});
|
||||
this.tariff = tariffRes.status === 404 ? { rates: [] } : tariffRes.data.result;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
fetchPlanDebounced: async function (preview) {
|
||||
if (!this.debounceTimer) {
|
||||
await this.fetchPlan(preview);
|
||||
return;
|
||||
}
|
||||
clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = setTimeout(async () => await this.fetchPlan(preview), 1000);
|
||||
},
|
||||
defaultDate: 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;
|
||||
},
|
||||
addPlan: function () {
|
||||
this.$emit("plan-updated", {
|
||||
time: this.defaultDate(),
|
||||
soc: 100,
|
||||
energy: this.capacity || 10,
|
||||
});
|
||||
},
|
||||
removePlan: function (index) {
|
||||
this.$emit("plan-removed", index);
|
||||
},
|
||||
updatePlan: function (data) {
|
||||
this.$emit("plan-updated", data);
|
||||
},
|
||||
previewPlan: function (data) {
|
||||
this.fetchPlanDebounced(data);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
h5 {
|
||||
position: relative;
|
||||
display: flex;
|
||||
top: -25px;
|
||||
margin-bottom: -0.5rem;
|
||||
padding: 0 0.5rem;
|
||||
justify-content: center;
|
||||
}
|
||||
h5 .inner {
|
||||
padding: 0 0.5rem;
|
||||
background-color: var(--evcc-box);
|
||||
font-weight: normal;
|
||||
color: var(--evcc-gray);
|
||||
text-transform: uppercase;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,7 +1,18 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="mb-5 mb-lg-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"
|
||||
data-testid="repeating-plan-title"
|
||||
>
|
||||
<span class="text-uppercase fs-6">
|
||||
{{ `${$t("main.chargingPlan.planNumber", { number: "#1" })}` }}
|
||||
</span>
|
||||
</h5>
|
||||
|
||||
<div class="row d-none d-lg-flex mb-2">
|
||||
<div class="col-6 col-lg-4">
|
||||
<div v-if="multiplePlans" class="plan-id d-none d-lg-flex"></div>
|
||||
<div class="col-6" :class="multiplePlans ? 'col-lg-3' : 'col-lg-4'">
|
||||
<label :for="formId('day')">
|
||||
{{ $t("main.chargingPlan.day") }}
|
||||
</label>
|
||||
|
|
@ -21,18 +32,24 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div
|
||||
v-if="multiplePlans"
|
||||
class="plan-id d-none d-lg-flex align-items-center justify-content-start fs-6"
|
||||
>
|
||||
#1
|
||||
</div>
|
||||
<div class="col-5 d-lg-none col-form-label">
|
||||
<label :for="formId('day')">
|
||||
{{ $t("main.chargingPlan.day") }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-7 col-lg-4 mb-2 mb-lg-0">
|
||||
<div :class="['col-7', multiplePlans ? 'col-lg-3' : 'col-lg-4', 'mb-2', 'mb-lg-0']">
|
||||
<select
|
||||
:id="formId('day')"
|
||||
v-model="selectedDay"
|
||||
class="form-select me-2"
|
||||
data-testid="plan-day"
|
||||
@change="preview"
|
||||
data-testid="static-plan-day"
|
||||
@change="preview()"
|
||||
>
|
||||
<option v-for="opt in dayOptions()" :key="opt.value" :value="opt.value">
|
||||
{{ opt.name }}
|
||||
|
|
@ -51,9 +68,9 @@
|
|||
type="time"
|
||||
class="form-control mx-0"
|
||||
:step="60 * 5"
|
||||
data-testid="plan-time"
|
||||
data-testid="static-plan-time"
|
||||
required
|
||||
@change="preview"
|
||||
@change="preview()"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-5 d-lg-none col-form-label">
|
||||
|
|
@ -67,8 +84,8 @@
|
|||
:id="formId('goal')"
|
||||
v-model="selectedSoc"
|
||||
class="form-select mx-0"
|
||||
data-testid="plan-soc"
|
||||
@change="preview"
|
||||
data-testid="static-plan-soc"
|
||||
@change="preview()"
|
||||
>
|
||||
<option v-for="opt in socOptions" :key="opt.value" :value="opt.value">
|
||||
{{ opt.name }}
|
||||
|
|
@ -79,8 +96,8 @@
|
|||
:id="formId('goal')"
|
||||
v-model="selectedEnergy"
|
||||
class="form-select mx-0"
|
||||
data-testid="plan-energy"
|
||||
@change="preview"
|
||||
data-testid="static-plan-energy"
|
||||
@change="preview()"
|
||||
>
|
||||
<option v-for="opt in energyOptions" :key="opt.energy" :value="opt.energy">
|
||||
{{ opt.text }}
|
||||
|
|
@ -92,25 +109,29 @@
|
|||
{{ $t("main.chargingPlan.active") }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-2 d-flex align-items-center justify-content-start">
|
||||
<div class="col-2 col-lg-1 d-flex align-items-center">
|
||||
<div class="form-check form-switch">
|
||||
<input
|
||||
:id="formId('active')"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
data-testid="plan-active"
|
||||
data-testid="static-plan-active"
|
||||
:checked="!isNew"
|
||||
:disabled="timeInThePast"
|
||||
tabindex="0"
|
||||
@change="toggle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-5 col-lg-2 d-flex align-items-center">
|
||||
<button
|
||||
v-if="dataChanged && !isNew"
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-primary ms-3 border-0 text-decoration-underline"
|
||||
data-testid="plan-apply"
|
||||
class="btn btn-sm btn-outline-primary border-0 text-decoration-underline"
|
||||
data-testid="static-plan-apply"
|
||||
:disabled="timeInThePast"
|
||||
tabindex="0"
|
||||
@click="update"
|
||||
>
|
||||
{{ $t("main.chargingPlan.update") }}
|
||||
|
|
@ -138,7 +159,7 @@ const LAST_ENERGY_GOAL_KEY = "last_energy_goal";
|
|||
const DEFAULT_TARGET_TIME = "7:00";
|
||||
|
||||
export default {
|
||||
name: "ChargingPlanSettingsEntry",
|
||||
name: "ChargingPlanStaticSettings",
|
||||
mixins: [formatter],
|
||||
props: {
|
||||
id: String,
|
||||
|
|
@ -149,15 +170,16 @@ export default {
|
|||
socPerKwh: Number,
|
||||
capacity: Number,
|
||||
socBasedPlanning: Boolean,
|
||||
multiplePlans: Boolean,
|
||||
},
|
||||
emits: ["plan-updated", "plan-removed", "plan-preview"],
|
||||
emits: ["static-plan-updated", "static-plan-removed", "plan-preview"],
|
||||
data: function () {
|
||||
return {
|
||||
selectedDay: null,
|
||||
selectedTime: null,
|
||||
selectedSoc: this.soc,
|
||||
selectedEnergy: this.energy,
|
||||
enabled: false,
|
||||
active: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
|
|
@ -224,9 +246,13 @@ export default {
|
|||
this.selectedEnergy = this.energy;
|
||||
}
|
||||
},
|
||||
isNew(value) {
|
||||
this.active = !value;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.initInputFields();
|
||||
this.preview();
|
||||
},
|
||||
methods: {
|
||||
formId: function (name) {
|
||||
|
|
@ -249,7 +275,6 @@ export default {
|
|||
if (!time) {
|
||||
// no time but existing selection, keep it
|
||||
if (this.selectedDay && this.selectedTime) {
|
||||
this.preview();
|
||||
return;
|
||||
}
|
||||
time = this.defaultTime();
|
||||
|
|
@ -257,10 +282,6 @@ export default {
|
|||
const date = new Date(time);
|
||||
this.selectedDay = this.fmtDayString(date);
|
||||
this.selectedTime = this.fmtTimeString(date);
|
||||
|
||||
if (this.isNew || this.dataChanged) {
|
||||
this.preview();
|
||||
}
|
||||
},
|
||||
dayOptions: function () {
|
||||
const options = [];
|
||||
|
|
@ -271,11 +292,11 @@ export default {
|
|||
];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const dayNumber = date.toLocaleDateString(this.$i18n?.locale, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
});
|
||||
const dayName =
|
||||
labels[i] || date.toLocaleDateString(this.$i18n?.locale, { weekday: "long" });
|
||||
labels[i] || date.toLocaleDateString(this.$i18n?.locale, { weekday: "short" });
|
||||
options.push({
|
||||
value: this.fmtDayString(date),
|
||||
name: `${dayNumber} (${dayName})`,
|
||||
|
|
@ -298,13 +319,16 @@ export default {
|
|||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
this.$emit("plan-updated", {
|
||||
this.$emit("static-plan-updated", {
|
||||
time: this.selectedDate,
|
||||
soc: this.selectedSoc,
|
||||
energy: this.selectedEnergy,
|
||||
});
|
||||
},
|
||||
preview: function () {
|
||||
preview: function (force = false) {
|
||||
if (!this.isNew && !force) {
|
||||
return false;
|
||||
}
|
||||
this.$emit("plan-preview", {
|
||||
time: this.selectedDate,
|
||||
soc: this.selectedSoc,
|
||||
|
|
@ -316,9 +340,10 @@ export default {
|
|||
if (checked) {
|
||||
this.update();
|
||||
} else {
|
||||
this.$emit("plan-removed");
|
||||
this.$emit("static-plan-removed");
|
||||
this.preview(true);
|
||||
}
|
||||
this.enabled = checked;
|
||||
this.active = checked;
|
||||
},
|
||||
defaultTime: function () {
|
||||
const [hours, minutes] = (
|
||||
|
|
@ -339,3 +364,9 @@ export default {
|
|||
},
|
||||
};
|
||||
</script>
|
||||
<style scoped>
|
||||
.plan-id {
|
||||
width: 2.5rem;
|
||||
color: var(--evcc-gray);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,15 +1,15 @@
|
|||
<template>
|
||||
<p class="mb-0" data-testid="plan-warnings">
|
||||
<span v-if="targetIsAboveLimit" class="d-block text-secondary mb-1">
|
||||
<p class="mb-3 root" data-testid="plan-warnings">
|
||||
<span v-if="targetIsAboveLimit" class="d-block evcc-gray mb-1">
|
||||
{{ $t("main.targetCharge.targetIsAboveLimit", { limit: limitFmt }) }}
|
||||
</span>
|
||||
<span v-if="['off', 'now'].includes(mode)" class="d-block text-secondary mb-1">
|
||||
<span v-if="['off', 'now'].includes(mode)" class="d-block evcc-gray mb-1">
|
||||
{{ $t("main.targetCharge.onlyInPvMode") }}
|
||||
</span>
|
||||
<span v-if="timeTooFarInTheFuture" class="d-block text-secondary mb-1">
|
||||
<span v-if="timeTooFarInTheFuture" class="d-block evcc-gray mb-1">
|
||||
{{ $t("main.targetCharge.targetIsTooFarInTheFuture") }}
|
||||
</span>
|
||||
<span v-if="costLimitExists" class="d-block text-secondary mb-1">
|
||||
<span v-if="costLimitExists" class="d-block evcc-gray mb-1">
|
||||
{{ $t("main.targetCharge.costLimitIgnore", { limit: costLimitText }) }}
|
||||
</span>
|
||||
<span v-if="notReachableInTime" class="d-block text-warning mb-1">
|
||||
|
|
@ -132,3 +132,9 @@ export default {
|
|||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.root:empty {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
94
assets/js/components/ChargingPlansRepeatingSettings.vue
Normal file
94
assets/js/components/ChargingPlansRepeatingSettings.vue
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
<template>
|
||||
<div v-for="(plan, index) in plans" :key="index" data-testid="plan-entry">
|
||||
<div>
|
||||
<ChargingPlanRepeatingSettings
|
||||
:showHeader="index === 0"
|
||||
:number="index + 2"
|
||||
class="mb-5 mb-lg-4"
|
||||
:formIdPrefix="formIdPrefix"
|
||||
v-bind="plan"
|
||||
:rangePerSoc="rangePerSoc"
|
||||
@updated="updatePlan(index, $event)"
|
||||
@saved="savePlan(index, $event)"
|
||||
@removed="removePlan(index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center pb-4">
|
||||
<button
|
||||
type="button"
|
||||
class="d-flex btn btn-sm btn-outline-secondary border-0 align-items-center gap-2 evcc-gray"
|
||||
data-testid="repeating-plan-add"
|
||||
tabindex="0"
|
||||
@click="addPlan"
|
||||
>
|
||||
<shopicon-regular-plus size="s" class="flex-shrink-0"></shopicon-regular-plus>
|
||||
{{ $t("main.chargingPlan.addRepeatingPlan") }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ChargingPlanRepeatingSettings from "./ChargingPlanRepeatingSettings.vue";
|
||||
import deepEqual from "../utils/deepEqual";
|
||||
import formatter from "../mixins/formatter";
|
||||
|
||||
const DEFAULT_WEEKDAYS = [1, 2, 3, 4, 5]; // weekdays
|
||||
const DEFAULT_TARGET_TIME = "07:00";
|
||||
const DEFAULT_TARGET_SOC = 80;
|
||||
|
||||
export default {
|
||||
name: "ChargingPlansRepeatingSettings",
|
||||
components: {
|
||||
ChargingPlanRepeatingSettings,
|
||||
},
|
||||
mixins: [formatter],
|
||||
props: {
|
||||
id: Number,
|
||||
rangePerSoc: Number,
|
||||
plans: { type: Array, default: () => [] },
|
||||
},
|
||||
emits: ["updated"],
|
||||
computed: {
|
||||
formIdPrefix: function () {
|
||||
return `chargingplan-lp${this.id}`;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
deepEqual,
|
||||
addPlan: function () {
|
||||
const newPlan = {
|
||||
weekdays: DEFAULT_WEEKDAYS,
|
||||
time: DEFAULT_TARGET_TIME,
|
||||
soc: DEFAULT_TARGET_SOC,
|
||||
active: false,
|
||||
tz: this.timezone(),
|
||||
};
|
||||
|
||||
// update the plan without storing non-applied changes from other plans
|
||||
const plans = [...this.plans]; // clone array
|
||||
plans.push(newPlan);
|
||||
this.updatePlans(plans);
|
||||
},
|
||||
updatePlan: function (index, plan) {
|
||||
const plans = [...this.plans]; // clone array
|
||||
plans.splice(index, 1, plan);
|
||||
this.updatePlans(plans);
|
||||
},
|
||||
updatePlans: function (plans) {
|
||||
this.$emit("updated", plans);
|
||||
},
|
||||
removePlan: function (index) {
|
||||
const plans = [...this.plans]; // clone array
|
||||
plans.splice(index, 1);
|
||||
this.updatePlans(plans);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.btn-outline-secondary {
|
||||
margin-left: -0.5rem;
|
||||
}
|
||||
</style>
|
||||
338
assets/js/components/ChargingPlansSettings.vue
Normal file
338
assets/js/components/ChargingPlansSettings.vue
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
<template>
|
||||
<div class="mt-4">
|
||||
<div class="form-group d-lg-flex align-items-baseline justify-content-between">
|
||||
<div class="container px-0">
|
||||
<ChargingPlanStaticSettings
|
||||
:id="`lp${id}-1`"
|
||||
class="mb-2"
|
||||
v-bind="staticPlan || {}"
|
||||
:capacity="capacity"
|
||||
:range-per-soc="rangePerSoc"
|
||||
:soc-per-kwh="socPerKwh"
|
||||
:soc-based-planning="socBasedPlanning"
|
||||
:multiplePlans="multiplePlans"
|
||||
@static-plan-updated="(data) => updateStaticPlan({ index: 0, ...data })"
|
||||
@static-plan-removed="() => removeStaticPlan(0)"
|
||||
@plan-preview="previewStaticPlan"
|
||||
/>
|
||||
<div v-if="socBasedPlanning">
|
||||
<div v-if="multiplePlans" class="d-none d-lg-block">
|
||||
<hr class="mt-5" />
|
||||
<h5>
|
||||
<div class="inner mb-3" data-testid="repeating-plan-title">
|
||||
{{ $t("main.chargingPlan.repeatingPlans") }}
|
||||
</div>
|
||||
</h5>
|
||||
</div>
|
||||
|
||||
<ChargingPlansRepeatingSettings
|
||||
:id="id"
|
||||
:rangePerSoc="rangePerSoc"
|
||||
:plans="repeatingPlans"
|
||||
@updated="updateRepeatingPlans"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<h5>
|
||||
<div class="inner" data-testid="plan-preview-title">
|
||||
<span v-if="!multiplePlans">
|
||||
{{ $t(`main.targetCharge.${noActivePlan ? "preview" : "currentPlan"}`) }}
|
||||
</span>
|
||||
<CustomSelect
|
||||
v-else-if="noActivePlan"
|
||||
:options="previewPlanOptions"
|
||||
:selected="selectedPreviewId"
|
||||
data-testid="preview-plan-select"
|
||||
@change="selectPreviewPlan($event.target.value)"
|
||||
>
|
||||
<span class="text-decoration-underline">
|
||||
{{ selectedPreviewPlanTitle }}
|
||||
</span>
|
||||
</CustomSelect>
|
||||
<span v-else>
|
||||
{{ nextPlanTitle }}
|
||||
</span>
|
||||
</div>
|
||||
</h5>
|
||||
<ChargingPlanWarnings v-bind="chargingPlanWarningsProps" />
|
||||
<ChargingPlanPreview v-bind="chargingPlanPreviewProps" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import "@h2d2/shopicons/es/regular/plus";
|
||||
import ChargingPlanPreview from "./ChargingPlanPreview.vue";
|
||||
import ChargingPlanStaticSettings from "./ChargingPlanStaticSettings.vue";
|
||||
import ChargingPlansRepeatingSettings from "./ChargingPlansRepeatingSettings.vue";
|
||||
import ChargingPlanWarnings from "./ChargingPlanWarnings.vue";
|
||||
import formatter from "../mixins/formatter";
|
||||
import collector from "../mixins/collector";
|
||||
import api from "../api";
|
||||
import CustomSelect from "./CustomSelect.vue";
|
||||
import deepEqual from "../utils/deepEqual";
|
||||
|
||||
const TARIFF_CACHE_TIME = 300000; // 5 minutes
|
||||
|
||||
export default {
|
||||
name: "ChargingPlansSettings",
|
||||
components: {
|
||||
ChargingPlanPreview,
|
||||
ChargingPlanStaticSettings,
|
||||
ChargingPlansRepeatingSettings,
|
||||
ChargingPlanWarnings,
|
||||
CustomSelect,
|
||||
},
|
||||
mixins: [formatter, collector],
|
||||
props: {
|
||||
id: [String, Number],
|
||||
staticPlan: Object,
|
||||
repeatingPlans: { type: Array, default: () => [] },
|
||||
effectiveLimitSoc: Number,
|
||||
effectivePlanTime: String,
|
||||
effectivePlanSoc: Number,
|
||||
planEnergy: Number,
|
||||
limitEnergy: Number,
|
||||
socBasedPlanning: Boolean,
|
||||
socPerKwh: Number,
|
||||
rangePerSoc: Number,
|
||||
smartCostLimit: Number,
|
||||
smartCostType: String,
|
||||
currency: String,
|
||||
mode: String,
|
||||
capacity: Number,
|
||||
vehicle: Object,
|
||||
vehicleLimitSoc: Number,
|
||||
planOverrun: Number,
|
||||
},
|
||||
emits: ["static-plan-removed", "static-plan-updated", "repeating-plans-updated"],
|
||||
data: function () {
|
||||
return {
|
||||
staticPlanPreview: {},
|
||||
tariff: {},
|
||||
plan: {},
|
||||
activeTab: "time",
|
||||
debounceTimer: null,
|
||||
selectedPreviewId: 1,
|
||||
nextPlanId: 0,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
noActivePlan: function () {
|
||||
return !this.staticPlan && this.repeatingPlans.every((plan) => !plan.active);
|
||||
},
|
||||
multiplePlans: function () {
|
||||
return this.repeatingPlans.length !== 0;
|
||||
},
|
||||
selectedPreviewPlanTitle: function () {
|
||||
return this.previewPlanOptions[this.selectedPreviewId - 1]?.name;
|
||||
},
|
||||
chargingPlanWarningsProps: function () {
|
||||
return this.collectProps(ChargingPlanWarnings);
|
||||
},
|
||||
chargingPlanPreviewProps: function () {
|
||||
const { rates } = this.tariff;
|
||||
const { duration, plan, power, planTime } = this.plan;
|
||||
const targetTime = planTime ? new Date(planTime) : null;
|
||||
const { currency, smartCostType } = this;
|
||||
return rates
|
||||
? { duration, plan, power, rates, targetTime, currency, smartCostType }
|
||||
: null;
|
||||
},
|
||||
previewPlanOptions: function () {
|
||||
const name = (number) => `${this.$t("main.targetCharge.preview")} #${number}`;
|
||||
|
||||
// static plan
|
||||
const options = [{ value: 1, name: name(1) }];
|
||||
|
||||
// repeating plans
|
||||
this.repeatingPlans.forEach((plan, index) => {
|
||||
const number = index + 2;
|
||||
options.push({
|
||||
value: number,
|
||||
name: name(number),
|
||||
disabled: !plan.weekdays.length,
|
||||
});
|
||||
});
|
||||
|
||||
return options;
|
||||
},
|
||||
nextPlanTitle: function () {
|
||||
return `${this.$t("main.targetCharge.nextPlan")} #${this.nextPlanId}`;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
effectivePlanTime(newValue) {
|
||||
if (null !== newValue) {
|
||||
this.fetchPlanDebounced();
|
||||
}
|
||||
},
|
||||
staticPlan: {
|
||||
deep: true,
|
||||
handler: function (vNew, vOld) {
|
||||
if (!deepEqual(vNew, vOld)) {
|
||||
this.fetchPlanDebounced();
|
||||
}
|
||||
},
|
||||
},
|
||||
repeatingPlans: {
|
||||
deep: true,
|
||||
handler: function (vNew, vOld) {
|
||||
if (!deepEqual(vNew, vOld)) {
|
||||
this.adjustPreviewId();
|
||||
this.fetchPlanDebounced();
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.fetchPlanDebounced();
|
||||
},
|
||||
methods: {
|
||||
selectPreviewPlan: function (id) {
|
||||
this.selectedPreviewId = id;
|
||||
this.fetchPlanPreviewDebounced();
|
||||
},
|
||||
fetchPlanDebounced: async function () {
|
||||
if (this.noActivePlan) {
|
||||
await this.fetchPlanPreviewDebounced();
|
||||
} else {
|
||||
await this.fetchActivePlanDebounced();
|
||||
}
|
||||
},
|
||||
adjustPreviewId: function () {
|
||||
if (this.selectedPreviewId > this.previewPlanOptions.length) {
|
||||
this.selectedPreviewId = this.previewPlanOptions.length;
|
||||
}
|
||||
},
|
||||
fetchActivePlan: async function () {
|
||||
try {
|
||||
const res = await api.get(`loadpoints/${this.id}/plan`);
|
||||
this.plan = res.data.result;
|
||||
this.nextPlanId = this.plan.planId;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
await this.updateTariff();
|
||||
},
|
||||
fetchStaticPreviewSoc: async function (soc, time) {
|
||||
const timeISO = time.toISOString();
|
||||
return await api.get(`loadpoints/${this.id}/plan/static/preview/soc/${soc}/${timeISO}`);
|
||||
},
|
||||
fetchRepeatingPreview: async function (weekdays, soc, time, tz) {
|
||||
return await api.get(
|
||||
`loadpoints/${this.id}/plan/repeating/preview/${soc}/${weekdays}/${time}/${encodeURIComponent(tz)}`
|
||||
);
|
||||
},
|
||||
fetchStaticPreviewEnergy: async function (energy, time) {
|
||||
const timeISO = time.toISOString();
|
||||
return await api.get(
|
||||
`loadpoints/${this.id}/plan/static/preview/energy/${energy}/${timeISO}`
|
||||
);
|
||||
},
|
||||
fetchPreviewPlan: async function () {
|
||||
// only show preview of no plan is active
|
||||
if (!this.noActivePlan) return;
|
||||
|
||||
try {
|
||||
let planRes = undefined;
|
||||
|
||||
if (this.selectedPreviewId < 2 && this.staticPlanPreview) {
|
||||
// static plan
|
||||
const { soc, energy, time } = this.staticPlanPreview;
|
||||
if (this.socBasedPlanning) {
|
||||
planRes = await this.fetchStaticPreviewSoc(soc, new Date(time));
|
||||
} else {
|
||||
planRes = await this.fetchStaticPreviewEnergy(energy, new Date(time));
|
||||
}
|
||||
} else {
|
||||
// repeating plan
|
||||
const plan = this.repeatingPlans[this.selectedPreviewId - 2];
|
||||
if (!plan) {
|
||||
return;
|
||||
}
|
||||
const { weekdays, soc, time, tz } = plan;
|
||||
if (weekdays.length === 0) {
|
||||
return;
|
||||
}
|
||||
planRes = await this.fetchRepeatingPreview(weekdays, soc, time, tz);
|
||||
}
|
||||
this.plan = planRes.data.result;
|
||||
await this.updateTariff();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
updateTariff: async function () {
|
||||
// cache tariff for 5 minutes
|
||||
if (
|
||||
this.tariff?.lastUpdate &&
|
||||
Date.now() - this.tariff.lastUpdate.getTime() <= TARIFF_CACHE_TIME
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tariffRes = await api.get(`tariff/planner`, {
|
||||
validateStatus: function (status) {
|
||||
return status >= 200 && status < 500;
|
||||
},
|
||||
});
|
||||
if (tariffRes.status === 404) {
|
||||
this.tariff = { rates: [] };
|
||||
} else {
|
||||
this.tariff = tariffRes.data.result;
|
||||
this.tariff.lastUpdate = new Date();
|
||||
}
|
||||
},
|
||||
fetchPlanPreviewDebounced: async function () {
|
||||
if (!this.debounceTimer) {
|
||||
await this.fetchPreviewPlan();
|
||||
return;
|
||||
}
|
||||
clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = setTimeout(async () => await this.fetchPreviewPlan(), 1000);
|
||||
},
|
||||
fetchActivePlanDebounced: async function () {
|
||||
if (!this.debounceTimer) {
|
||||
await this.fetchActivePlan();
|
||||
return;
|
||||
}
|
||||
clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = setTimeout(async () => await this.fetchActivePlan(), 1000);
|
||||
},
|
||||
removeStaticPlan: function (index) {
|
||||
this.$emit("static-plan-removed", index);
|
||||
},
|
||||
updateStaticPlan: function (data) {
|
||||
this.$emit("static-plan-updated", data);
|
||||
},
|
||||
updateRepeatingPlans: function (plans) {
|
||||
this.$emit("repeating-plans-updated", plans);
|
||||
},
|
||||
previewStaticPlan: function (plan) {
|
||||
this.staticPlanPreview = plan;
|
||||
this.fetchPlanPreviewDebounced();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
h5 {
|
||||
position: relative;
|
||||
display: flex;
|
||||
top: -25px;
|
||||
margin-bottom: -0.5rem;
|
||||
padding: 0 0.5rem;
|
||||
justify-content: center;
|
||||
}
|
||||
h5 .inner {
|
||||
padding: 0 0.5rem;
|
||||
background-color: var(--evcc-box);
|
||||
font-weight: normal;
|
||||
color: var(--evcc-gray);
|
||||
text-transform: uppercase;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<label class="position-relative d-block" :for="id" role="button">
|
||||
<select :id="id" :value="selected" class="custom-select" @change="change">
|
||||
<label class="root position-relative d-block" :for="id" role="button">
|
||||
<select :id="id" :value="selected" class="custom-select" tabindex="0" @change="change">
|
||||
<option
|
||||
v-for="{ name, value, count, disabled } in options"
|
||||
:key="value"
|
||||
|
|
@ -47,4 +47,12 @@ export default {
|
|||
opacity: 0;
|
||||
-webkit-appearance: menulist-button;
|
||||
}
|
||||
.root {
|
||||
border-radius: var(--bs-border-radius);
|
||||
}
|
||||
.root:focus-within {
|
||||
outline: var(--bs-focus-ring-width) solid var(--bs-focus-ring-color);
|
||||
outline-width: var(--bs-focus-ring-width);
|
||||
outline-offset: var(--bs-focus-ring-width);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -7,17 +7,20 @@
|
|||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
data-bs-auto-close="outside"
|
||||
tabindex="0"
|
||||
>
|
||||
<slot></slot>
|
||||
</button>
|
||||
<ul ref="dropdown" class="dropdown-menu dropdown-menu-end" :aria-labelledby="id">
|
||||
<template v-if="selectAllLabel">
|
||||
<li class="dropdown-item p-0">
|
||||
<label class="form-check px-3 py-2">
|
||||
<label class="form-check px-3 py-2" :for="formId('all')">
|
||||
<input
|
||||
:id="formId('all')"
|
||||
class="form-check-input ms-0 me-2"
|
||||
type="checkbox"
|
||||
value="all"
|
||||
tabindex="0"
|
||||
:checked="allOptionsSelected"
|
||||
@change="toggleCheckAll()"
|
||||
/>
|
||||
|
|
@ -27,13 +30,14 @@
|
|||
<li><hr class="dropdown-divider" /></li>
|
||||
</template>
|
||||
<li v-for="option in options" :key="option.value" class="dropdown-item p-0">
|
||||
<label class="form-check px-3 py-2 d-flex" :for="option.value">
|
||||
<label class="form-check px-3 py-2 d-flex" :for="formId(option.value)">
|
||||
<input
|
||||
:id="option.value"
|
||||
:id="formId(option.value)"
|
||||
v-model="internalValue"
|
||||
class="form-check-input ms-0 me-2"
|
||||
type="checkbox"
|
||||
:value="option.value"
|
||||
tabindex="0"
|
||||
/>
|
||||
<div class="form-check-label">
|
||||
{{ option.name }}
|
||||
|
|
@ -46,7 +50,7 @@
|
|||
|
||||
<script>
|
||||
import Dropdown from "bootstrap/js/dist/dropdown";
|
||||
|
||||
import deepEqual from "../utils/deepEqual";
|
||||
export default {
|
||||
name: "MultiSelect",
|
||||
props: {
|
||||
|
|
@ -73,35 +77,17 @@ export default {
|
|||
options: {
|
||||
immediate: true,
|
||||
handler(newOptions) {
|
||||
// If value is empty, set internalValue to include all options
|
||||
if (this.value.length === 0) {
|
||||
this.internalValue = newOptions.map((option) => option.value);
|
||||
} else {
|
||||
// Otherwise, keep selected options that still exist in the new options
|
||||
this.internalValue = this.internalValue.filter((value) =>
|
||||
newOptions.some((option) => option.value === value)
|
||||
);
|
||||
}
|
||||
this.internalValue = this.internalValue.filter((value) =>
|
||||
newOptions.some((option) => option.value === value)
|
||||
);
|
||||
this.$nextTick(() => {
|
||||
Dropdown.getOrCreateInstance(this.$refs.dropdown).update();
|
||||
});
|
||||
},
|
||||
},
|
||||
value: {
|
||||
immediate: true,
|
||||
handler(newValue) {
|
||||
this.internalValue =
|
||||
newValue.length === 0 && this.options.length > 0
|
||||
? this.options.map((o) => o.value)
|
||||
: [...newValue];
|
||||
},
|
||||
},
|
||||
internalValue(newValue) {
|
||||
if (this.allOptionsSelected || this.noneSelected) {
|
||||
this.$emit("update:modelValue", []);
|
||||
} else {
|
||||
this.$emit("update:modelValue", newValue);
|
||||
}
|
||||
internalValue(newValue, oldValue) {
|
||||
if (deepEqual(newValue, oldValue)) return;
|
||||
this.$emit("update:modelValue", newValue);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
|
|
@ -114,6 +100,9 @@ export default {
|
|||
open() {
|
||||
this.$emit("open");
|
||||
},
|
||||
formId(name) {
|
||||
return `${this.id}-${name}`;
|
||||
},
|
||||
toggleCheckAll() {
|
||||
if (this.allOptionsSelected) {
|
||||
this.internalValue = [];
|
||||
|
|
|
|||
|
|
@ -1,47 +1,75 @@
|
|||
<template>
|
||||
<div class="chart">
|
||||
<div
|
||||
v-for="(slot, index) in slots"
|
||||
:key="slot.start"
|
||||
:data-index="index"
|
||||
class="slot user-select-none"
|
||||
:class="{
|
||||
active: slot.charging,
|
||||
hovered: activeIndex === index,
|
||||
toLate: slot.toLate,
|
||||
warning: slot.warning,
|
||||
'cursor-pointer': slot.selectable,
|
||||
faded: activeIndex !== null && activeIndex !== index,
|
||||
}"
|
||||
@touchstart="startLongPress(index)"
|
||||
@touchend="cancelLongPress"
|
||||
@touchmove="cancelLongPress"
|
||||
@mouseenter="hoverSlot(index)"
|
||||
@mouseleave="hoverSlot(null)"
|
||||
@mouseup="hoverSlot(null)"
|
||||
@click="selectSlot(index)"
|
||||
>
|
||||
<div class="slot-bar" :style="priceStyle(slot.price)">
|
||||
<span v-if="slot.price === undefined && avgPrice" class="unknown">?</span>
|
||||
<div class="root position-relative">
|
||||
<div class="chart position-relative">
|
||||
<div
|
||||
v-for="(slot, index) in slots"
|
||||
:key="slot.start"
|
||||
:data-index="index"
|
||||
class="slot user-select-none"
|
||||
:class="{
|
||||
active: slot.charging,
|
||||
hovered: activeIndex === index,
|
||||
toLate: slot.toLate,
|
||||
warning: slot.warning,
|
||||
'cursor-pointer': slot.selectable,
|
||||
faded: activeIndex !== null && activeIndex !== index,
|
||||
}"
|
||||
@touchstart="startLongPress(index)"
|
||||
@touchend="cancelLongPress"
|
||||
@touchmove="cancelLongPress"
|
||||
@mouseenter="hoverSlot(index)"
|
||||
@mouseleave="hoverSlot(null)"
|
||||
@mouseup="hoverSlot(null)"
|
||||
@click="selectSlot(index)"
|
||||
>
|
||||
<div class="slot-bar" :style="priceStyle(slot.price)">
|
||||
<span v-if="slot.price === undefined && avgPrice" class="unknown">?</span>
|
||||
</div>
|
||||
<div class="slot-label">
|
||||
<span v-if="!slot.isTarget || targetNearlyOutOfRange">{{
|
||||
slot.startHour
|
||||
}}</span>
|
||||
<br />
|
||||
<span v-if="showWeekday(index)">{{ slot.day }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="slot-label">
|
||||
{{ slot.startHour }}
|
||||
<br />
|
||||
<span v-if="slot.startHour === 0">{{ slot.day }}</span>
|
||||
<div
|
||||
v-if="targetText && !targetNearlyOutOfRange"
|
||||
class="target-inline"
|
||||
:class="{ 'target-inline--faded': activeIndex !== null }"
|
||||
:style="{ left: targetLeft }"
|
||||
>
|
||||
<div class="target-inline-marker"></div>
|
||||
<div class="text-nowrap target-inline-text" data-testid="target-text">
|
||||
{{ targetText }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="targetText && targetNearlyOutOfRange" ref="targetFixed" class="target-fixed">
|
||||
<div class="text-nowrap" data-testid="target-text">{{ targetText }}</div>
|
||||
<PlanEndIcon v-if="targetOutOfRange" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import "@h2d2/shopicons/es/regular/arrowright";
|
||||
import PlanEndIcon from "./MaterialIcon/PlanEnd.vue";
|
||||
import formatter from "../mixins/formatter";
|
||||
import { CO2_TYPE } from "../units";
|
||||
|
||||
const BAR_WIDTH = 20;
|
||||
|
||||
export default {
|
||||
name: "TariffChart",
|
||||
components: {
|
||||
PlanEndIcon,
|
||||
},
|
||||
mixins: [formatter],
|
||||
props: {
|
||||
slots: Array,
|
||||
targetText: String,
|
||||
targetOffset: Number,
|
||||
},
|
||||
emits: ["slot-hovered", "slot-selected"],
|
||||
data() {
|
||||
|
|
@ -65,6 +93,17 @@ export default {
|
|||
});
|
||||
return { min, range: max - min };
|
||||
},
|
||||
targetLeft() {
|
||||
const fullHours = Math.floor(this.targetOffset);
|
||||
const hourFraction = this.targetOffset - fullHours;
|
||||
return `${fullHours * BAR_WIDTH + 4 + hourFraction * 12}px`;
|
||||
},
|
||||
targetNearlyOutOfRange() {
|
||||
return this.targetOffset > this.slots.length - 4;
|
||||
},
|
||||
targetOutOfRange() {
|
||||
return this.targetOffset > this.slots.length;
|
||||
},
|
||||
avgPrice() {
|
||||
let sum = 0;
|
||||
let count = 0;
|
||||
|
|
@ -97,6 +136,21 @@ export default {
|
|||
this.$emit("slot-selected", index);
|
||||
}
|
||||
},
|
||||
showWeekday(index) {
|
||||
const slot = this.slots[index];
|
||||
if (!slot) {
|
||||
return false;
|
||||
}
|
||||
for (let i = 0; i < 7; i++) {
|
||||
if (this.slots[index - i]?.isTarget) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (slot.startHour === 0) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
priceStyle(price) {
|
||||
const value = price === undefined ? this.avgPrice : price;
|
||||
const height =
|
||||
|
|
@ -122,10 +176,47 @@ export default {
|
|||
<style scoped>
|
||||
.chart {
|
||||
display: flex;
|
||||
height: 140px;
|
||||
height: 150px;
|
||||
overflow-x: auto;
|
||||
align-items: flex-end;
|
||||
overflow-y: none;
|
||||
padding-bottom: 45px;
|
||||
padding-bottom: 55px;
|
||||
}
|
||||
.target-inline {
|
||||
height: 130px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
color: var(--bs-primary);
|
||||
opacity: 1;
|
||||
transition-property: opacity, color, left;
|
||||
transition-duration: var(--evcc-transition-fast);
|
||||
pointer-events: none;
|
||||
transition-timing-function: ease-in;
|
||||
}
|
||||
.target-inline--faded {
|
||||
opacity: 0.33;
|
||||
}
|
||||
.target-fixed {
|
||||
position: absolute;
|
||||
background-color: var(--evcc-box);
|
||||
color: var(--bs-primary);
|
||||
padding-left: 1rem;
|
||||
right: 0;
|
||||
top: 110px;
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
}
|
||||
.target-inline-marker {
|
||||
height: 100%;
|
||||
border: 1px solid var(--bs-primary);
|
||||
border-width: 0 0 1px 1px;
|
||||
width: 0.75rem;
|
||||
height: 1.5rem;
|
||||
margin-right: 0.25rem;
|
||||
margin-bottom: 11px;
|
||||
}
|
||||
.slot {
|
||||
text-align: center;
|
||||
|
|
@ -139,26 +230,18 @@ export default {
|
|||
transition-property: opacity, background, color;
|
||||
transition-duration: var(--evcc-transition-fast);
|
||||
transition-timing-function: ease-in;
|
||||
width: 20px;
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@media (max-width: 991px) {
|
||||
.chart {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.slot {
|
||||
width: 20px;
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
@media (min-width: 992px) {
|
||||
.chart {
|
||||
overflow-x: none;
|
||||
justify-content: stretch;
|
||||
}
|
||||
.slot {
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
flex-basis: 1;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
}
|
||||
.slot-bar {
|
||||
|
|
|
|||
|
|
@ -210,6 +210,13 @@ export default {
|
|||
minute: "numeric",
|
||||
}).format(date);
|
||||
},
|
||||
fmtWeekdayTime: function (date) {
|
||||
return new Intl.DateTimeFormat(this.$i18n?.locale, {
|
||||
weekday: "short",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
}).format(date);
|
||||
},
|
||||
fmtMonthYear: function (date) {
|
||||
return new Intl.DateTimeFormat(this.$i18n?.locale, {
|
||||
month: "long",
|
||||
|
|
@ -289,6 +296,9 @@ export default {
|
|||
}
|
||||
return price;
|
||||
},
|
||||
timezone: function () {
|
||||
return Intl?.DateTimeFormat?.().resolvedOptions?.().timeZone || "UTC";
|
||||
},
|
||||
pricePerKWhUnit: function (currency = "EUR", short = false) {
|
||||
const unit = ENERGY_PRICE_IN_SUBUNIT[currency] || currency;
|
||||
return `${unit}${short ? "" : "/kWh"}`;
|
||||
|
|
@ -335,5 +345,63 @@ export default {
|
|||
// TODO: handle fahrenheit
|
||||
return this.fmtNumber(value, 1, "celsius");
|
||||
},
|
||||
getWeekdaysList: function (weekdayFormat) {
|
||||
const { format } = new Intl.DateTimeFormat(this.$i18n?.locale, { weekday: weekdayFormat });
|
||||
const mondayToSaturday = [7, 8, 9, 10, 11, 12].map((day, index) => {
|
||||
return { name: format(new Date(Date.UTC(2021, 5, day))), value: index + 1 };
|
||||
});
|
||||
const sunday = { name: format(new Date(Date.UTC(2021, 5, 6))), value: 0 };
|
||||
return [...mondayToSaturday, sunday];
|
||||
},
|
||||
getShortenedWeekdaysLabel: function (selectedWeekdays) {
|
||||
if (0 === selectedWeekdays.length) {
|
||||
return "–";
|
||||
}
|
||||
|
||||
const weekdays = this.getWeekdaysList("short");
|
||||
let label = "";
|
||||
|
||||
// the week in the input-parameter starts with 0 for sunday and ends with 6 for saturday
|
||||
// this algorithms works only if the week starts with 1 for monday and ends with 7 for sunday because
|
||||
// then we are able to count from 1 to 7 by incrementing the number
|
||||
// so we have to transform the input accordingly
|
||||
const selectedWeekdaysTransformed = selectedWeekdays.map(function (dayIndex) {
|
||||
return 0 === dayIndex ? 7 : dayIndex;
|
||||
});
|
||||
function getWeekdayName(dayIndex) {
|
||||
return weekdays.find((day) => day.value === (7 === dayIndex ? 0 : dayIndex)).name;
|
||||
}
|
||||
|
||||
let maxWeekday = Math.max(...selectedWeekdaysTransformed);
|
||||
|
||||
for (let weekdayRangeStart = 1; weekdayRangeStart < 8; weekdayRangeStart++) {
|
||||
if (selectedWeekdaysTransformed.includes(weekdayRangeStart)) {
|
||||
label += getWeekdayName(weekdayRangeStart);
|
||||
|
||||
let weekdayRangeEnd = weekdayRangeStart;
|
||||
while (selectedWeekdaysTransformed.includes(weekdayRangeEnd + 1)) {
|
||||
weekdayRangeEnd++;
|
||||
}
|
||||
|
||||
if (weekdayRangeEnd - weekdayRangeStart > 1) {
|
||||
// more than 2 consecutive weekdays selected
|
||||
label += " – " + getWeekdayName(weekdayRangeEnd);
|
||||
weekdayRangeStart = weekdayRangeEnd;
|
||||
if (maxWeekday !== weekdayRangeEnd) {
|
||||
label += ", ";
|
||||
}
|
||||
} else if (weekdayRangeStart !== weekdayRangeEnd) {
|
||||
// exactly 2 consecutive weekdays selected
|
||||
label += ", ";
|
||||
} else {
|
||||
// exactly 1 single day selected
|
||||
if (maxWeekday !== weekdayRangeEnd) {
|
||||
label += ", ";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return label;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -137,3 +137,35 @@ describe("fmtDuration", () => {
|
|||
config.global.mocks["$i18n"].locale = "de-DE";
|
||||
});
|
||||
});
|
||||
|
||||
describe("getWeekdaysList", () => {
|
||||
test("should return the correct weekday-order", () => {
|
||||
expect(fmt.getWeekdaysList("long")).toEqual([
|
||||
{ name: "Montag", value: 1 },
|
||||
{ name: "Dienstag", value: 2 },
|
||||
{ name: "Mittwoch", value: 3 },
|
||||
{ name: "Donnerstag", value: 4 },
|
||||
{ name: "Freitag", value: 5 },
|
||||
{ name: "Samstag", value: 6 },
|
||||
{ name: "Sonntag", value: 0 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getShortenedWeekdaysLabel", () => {
|
||||
test("should format single days", () => {
|
||||
expect(fmt.getShortenedWeekdaysLabel([0])).eq("So");
|
||||
expect(fmt.getShortenedWeekdaysLabel([0, 2, 4, 6])).eq("Di, Do, Sa, So");
|
||||
expect(fmt.getShortenedWeekdaysLabel([6])).eq("Sa");
|
||||
expect(fmt.getShortenedWeekdaysLabel([3, 6])).eq("Mi, Sa");
|
||||
});
|
||||
test("should format ranges", () => {
|
||||
expect(fmt.getShortenedWeekdaysLabel([1, 2])).eq("Mo, Di");
|
||||
expect(fmt.getShortenedWeekdaysLabel([0, 1, 2, 3, 4, 5, 6])).eq("Mo – So");
|
||||
expect(fmt.getShortenedWeekdaysLabel([0, 1, 3, 4, 5])).eq("Mo, Mi – Fr, So");
|
||||
});
|
||||
test("should format single days and ranges", () => {
|
||||
expect(fmt.getShortenedWeekdaysLabel([0, 1, 3, 5, 6])).eq("Mo, Mi, Fr – So");
|
||||
expect(fmt.getShortenedWeekdaysLabel([0, 2, 3, 5, 6])).eq("Di, Mi, Fr – So");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ const (
|
|||
|
||||
// effective values
|
||||
EffectivePriority = "effectivePriority" // effective priority
|
||||
EffectivePlanId = "effectivePlanId" // effective plan id
|
||||
EffectivePlanTime = "effectivePlanTime" // effective plan time
|
||||
EffectivePlanSoc = "effectivePlanSoc" // effective plan soc
|
||||
EffectiveMinCurrent = "effectiveMinCurrent" // effective min current
|
||||
|
|
@ -67,6 +68,9 @@ const (
|
|||
PlanProjectedEnd = "planProjectedEnd" // charge plan ends (end of last slot)
|
||||
PlanOverrun = "planOverrun" // charge plan goal not reachable in time
|
||||
|
||||
// repeating plans
|
||||
RepeatingPlans = "repeatingPlans" // key to access all repeating plans in db
|
||||
|
||||
// remote control
|
||||
RemoteDisabled = "remoteDisabled" // remote disabled
|
||||
RemoteDisabledSource = "remoteDisabledSource" // remote disabled source
|
||||
|
|
|
|||
|
|
@ -67,6 +67,8 @@ type API interface {
|
|||
|
||||
// EffectivePriority returns the effective priority
|
||||
EffectivePriority() int
|
||||
// EffectivePlanId returns the effective plan id
|
||||
EffectivePlanId() int
|
||||
// EffectivePlanTime returns the effective plan time
|
||||
EffectivePlanTime() time.Time
|
||||
// EffectiveMinPower returns the min charging power for the minimum active phases
|
||||
|
|
|
|||
|
|
@ -83,6 +83,20 @@ func (mr *MockAPIMockRecorder) EffectiveMinPower() *gomock.Call {
|
|||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EffectiveMinPower", reflect.TypeOf((*MockAPI)(nil).EffectiveMinPower))
|
||||
}
|
||||
|
||||
// EffectivePlanId mocks base method.
|
||||
func (m *MockAPI) EffectivePlanId() int {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "EffectivePlanId")
|
||||
ret0, _ := ret[0].(int)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// EffectivePlanId indicates an expected call of EffectivePlanId.
|
||||
func (mr *MockAPIMockRecorder) EffectivePlanId() *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EffectivePlanId", reflect.TypeOf((*MockAPI)(nil).EffectivePlanId))
|
||||
}
|
||||
|
||||
// EffectivePlanTime mocks base method.
|
||||
func (m *MockAPI) EffectivePlanTime() time.Time {
|
||||
m.ctrl.T.Helper()
|
||||
|
|
|
|||
|
|
@ -1,16 +1,19 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/core/keys"
|
||||
"github.com/evcc-io/evcc/core/vehicle"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
)
|
||||
|
||||
// PublishEffectiveValues publishes all effective values
|
||||
func (lp *Loadpoint) PublishEffectiveValues() {
|
||||
lp.publish(keys.EffectivePriority, lp.EffectivePriority())
|
||||
lp.publish(keys.EffectivePlanId, lp.EffectivePlanId())
|
||||
lp.publish(keys.EffectivePlanTime, lp.EffectivePlanTime())
|
||||
lp.publish(keys.EffectivePlanSoc, lp.EffectivePlanSoc())
|
||||
lp.publish(keys.EffectiveMinCurrent, lp.effectiveMinCurrent())
|
||||
|
|
@ -28,24 +31,72 @@ func (lp *Loadpoint) EffectivePriority() int {
|
|||
return lp.GetPriority()
|
||||
}
|
||||
|
||||
// vehiclePlanSoc returns the next vehicle plan time and soc
|
||||
func (lp *Loadpoint) vehiclePlanSoc() (time.Time, int) {
|
||||
// nextVehiclePlan returns the next vehicle plan time, soc and id
|
||||
func (lp *Loadpoint) nextVehiclePlan() (time.Time, int, int) {
|
||||
if v := lp.GetVehicle(); v != nil {
|
||||
return vehicle.Settings(lp.log, v).GetPlanSoc()
|
||||
type plan struct {
|
||||
Time time.Time
|
||||
Soc int
|
||||
Id int
|
||||
}
|
||||
|
||||
var plans []plan
|
||||
|
||||
// static plan
|
||||
if planTime, soc := vehicle.Settings(lp.log, v).GetPlanSoc(); soc != 0 {
|
||||
plans = append(plans, plan{Id: 1, Soc: soc, Time: planTime})
|
||||
}
|
||||
|
||||
// repeating plans
|
||||
for index, rp := range vehicle.Settings(lp.log, v).GetRepeatingPlans() {
|
||||
if !rp.Active || len(rp.Weekdays) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
time, err := util.GetNextOccurrence(rp.Weekdays, rp.Time, rp.Tz)
|
||||
if err != nil {
|
||||
lp.log.DEBUG.Printf("invalid repeating plan: weekdays=%v, time=%s, tz=%s, error=%v", rp.Weekdays, rp.Time, rp.Tz, err)
|
||||
continue
|
||||
}
|
||||
|
||||
plans = append(plans, plan{Id: index + 2, Soc: rp.Soc, Time: time})
|
||||
}
|
||||
|
||||
// sort plans by time
|
||||
sort.Slice(plans, func(i, j int) bool {
|
||||
return plans[i].Time.Before(plans[j].Time)
|
||||
})
|
||||
|
||||
if len(plans) > 0 {
|
||||
return plans[0].Time, plans[0].Soc, plans[0].Id
|
||||
}
|
||||
}
|
||||
return time.Time{}, 0
|
||||
return time.Time{}, 0, 0
|
||||
}
|
||||
|
||||
// EffectivePlanSoc returns the soc target for the current plan
|
||||
func (lp *Loadpoint) EffectivePlanSoc() int {
|
||||
_, soc := lp.vehiclePlanSoc()
|
||||
_, soc, _ := lp.nextVehiclePlan()
|
||||
return soc
|
||||
}
|
||||
|
||||
// EffectivePlanSoc returns the id for the current plan
|
||||
func (lp *Loadpoint) EffectivePlanId() int {
|
||||
if lp.socBasedPlanning() {
|
||||
_, _, id := lp.nextVehiclePlan()
|
||||
return id
|
||||
}
|
||||
if lp.planEnergy > 0 {
|
||||
return 1
|
||||
}
|
||||
// no plan
|
||||
return 0
|
||||
}
|
||||
|
||||
// EffectivePlanTime returns the effective plan time
|
||||
func (lp *Loadpoint) EffectivePlanTime() time.Time {
|
||||
if lp.socBasedPlanning() {
|
||||
ts, _ := lp.vehiclePlanSoc()
|
||||
ts, _, _ := lp.nextVehiclePlan()
|
||||
return ts
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ func (lp *Loadpoint) GetPlanGoal() (float64, bool) {
|
|||
defer lp.RUnlock()
|
||||
|
||||
if lp.socBasedPlanning() {
|
||||
_, soc := vehicle.Settings(lp.log, lp.GetVehicle()).GetPlanSoc()
|
||||
_, soc, _ := lp.nextVehiclePlan()
|
||||
return float64(soc), true
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,17 +18,18 @@ type planStruct struct {
|
|||
}
|
||||
|
||||
type vehicleStruct struct {
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Capacity float64 `json:"capacity,omitempty"`
|
||||
Phases int `json:"phases,omitempty"`
|
||||
MinSoc int `json:"minSoc,omitempty"`
|
||||
LimitSoc int `json:"limitSoc,omitempty"`
|
||||
MinCurrent float64 `json:"minCurrent,omitempty"`
|
||||
MaxCurrent float64 `json:"maxCurrent,omitempty"`
|
||||
Priority int `json:"priority,omitempty"`
|
||||
Features []string `json:"features,omitempty"`
|
||||
Plans []planStruct `json:"plans,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Capacity float64 `json:"capacity,omitempty"`
|
||||
Phases int `json:"phases,omitempty"`
|
||||
MinSoc int `json:"minSoc,omitempty"`
|
||||
LimitSoc int `json:"limitSoc,omitempty"`
|
||||
MinCurrent float64 `json:"minCurrent,omitempty"`
|
||||
MaxCurrent float64 `json:"maxCurrent,omitempty"`
|
||||
Priority int `json:"priority,omitempty"`
|
||||
Features []string `json:"features,omitempty"`
|
||||
Plan *planStruct `json:"plan,omitempty"`
|
||||
RepeatingPlans []api.RepeatingPlanStruct `json:"repeatingPlans"`
|
||||
}
|
||||
|
||||
// publishVehicles returns a list of vehicle titles
|
||||
|
|
@ -37,28 +38,28 @@ func (site *Site) publishVehicles() {
|
|||
res := make(map[string]vehicleStruct, len(vv))
|
||||
|
||||
for _, v := range vv {
|
||||
var plans []planStruct
|
||||
var plan *planStruct
|
||||
|
||||
// TODO: add support for multiple plans
|
||||
if time, soc := v.GetPlanSoc(); !time.IsZero() {
|
||||
plans = append(plans, planStruct{Soc: soc, Time: time})
|
||||
plan = &planStruct{Soc: soc, Time: time}
|
||||
}
|
||||
|
||||
instance := v.Instance()
|
||||
ac := instance.OnIdentified()
|
||||
|
||||
res[v.Name()] = vehicleStruct{
|
||||
Title: instance.Title(),
|
||||
Icon: instance.Icon(),
|
||||
Capacity: instance.Capacity(),
|
||||
Phases: instance.Phases(),
|
||||
MinSoc: v.GetMinSoc(),
|
||||
LimitSoc: v.GetLimitSoc(),
|
||||
MinCurrent: ac.MinCurrent,
|
||||
MaxCurrent: ac.MaxCurrent,
|
||||
Priority: ac.Priority,
|
||||
Features: lo.Map(instance.Features(), func(f api.Feature, _ int) string { return f.String() }),
|
||||
Plans: plans,
|
||||
Title: instance.Title(),
|
||||
Icon: instance.Icon(),
|
||||
Capacity: instance.Capacity(),
|
||||
Phases: instance.Phases(),
|
||||
MinSoc: v.GetMinSoc(),
|
||||
LimitSoc: v.GetLimitSoc(),
|
||||
MinCurrent: ac.MinCurrent,
|
||||
MaxCurrent: ac.MaxCurrent,
|
||||
Priority: ac.Priority,
|
||||
Features: lo.Map(instance.Features(), func(f api.Feature, _ int) string { return f.String() }),
|
||||
Plan: plan,
|
||||
RepeatingPlans: v.GetRepeatingPlans(),
|
||||
}
|
||||
|
||||
if lp := site.coordinator.Owner(instance); lp != nil {
|
||||
|
|
|
|||
|
|
@ -103,3 +103,38 @@ func (v *adapter) SetPlanSoc(ts time.Time, soc int) error {
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *adapter) SetRepeatingPlans(plans []api.RepeatingPlanStruct) error {
|
||||
for _, plan := range plans {
|
||||
for _, day := range plan.Weekdays {
|
||||
if day < 0 || day > 6 {
|
||||
return fmt.Errorf("weekday out of range: %v", day)
|
||||
}
|
||||
}
|
||||
if _, err := time.LoadLocation(plan.Tz); err != nil {
|
||||
return fmt.Errorf("invalid timezone: %v", err)
|
||||
}
|
||||
if _, err := time.Parse("15:04", plan.Time); err != nil {
|
||||
return fmt.Errorf("invalid time: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
v.log.DEBUG.Printf("update repeating plans for %s to: %v", v.name, plans)
|
||||
|
||||
settings.SetJson(v.key()+keys.RepeatingPlans, plans)
|
||||
|
||||
v.publish()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *adapter) GetRepeatingPlans() []api.RepeatingPlanStruct {
|
||||
var plans []api.RepeatingPlanStruct
|
||||
|
||||
err := settings.Json(v.key()+keys.RepeatingPlans, &plans)
|
||||
if err == nil {
|
||||
return plans
|
||||
}
|
||||
|
||||
return []api.RepeatingPlanStruct{}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,11 @@ type API interface {
|
|||
// SetPlanSoc sets the charge plan time and soc
|
||||
SetPlanSoc(time.Time, int) error
|
||||
|
||||
// GetRepeatingPlans returns every repeating plan
|
||||
GetRepeatingPlans() []api.RepeatingPlanStruct
|
||||
// SetRepeatingPlans stores every repeating plan
|
||||
SetRepeatingPlans([]api.RepeatingPlanStruct) error
|
||||
|
||||
// // GetMinCurrent returns the min charging current
|
||||
// GetMinCurrent() float64
|
||||
// // SetMinCurrent sets the min charging current
|
||||
|
|
|
|||
|
|
@ -47,3 +47,12 @@ func (v *dummy) GetPlanSoc() (time.Time, int) {
|
|||
func (v *dummy) SetPlanSoc(ts time.Time, soc int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetRepeatingPlans stores every repeating plan
|
||||
func (v *dummy) SetRepeatingPlans(plans []api.RepeatingPlanStruct) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *dummy) GetRepeatingPlans() []api.RepeatingPlanStruct {
|
||||
return []api.RepeatingPlanStruct{}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,6 +84,20 @@ func (mr *MockAPIMockRecorder) GetPlanSoc() *gomock.Call {
|
|||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlanSoc", reflect.TypeOf((*MockAPI)(nil).GetPlanSoc))
|
||||
}
|
||||
|
||||
// GetRepeatingPlans mocks base method.
|
||||
func (m *MockAPI) GetRepeatingPlans() []api.RepeatingPlanStruct {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetRepeatingPlans")
|
||||
ret0, _ := ret[0].([]api.RepeatingPlanStruct)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// GetRepeatingPlans indicates an expected call of GetRepeatingPlans.
|
||||
func (mr *MockAPIMockRecorder) GetRepeatingPlans() *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRepeatingPlans", reflect.TypeOf((*MockAPI)(nil).GetRepeatingPlans))
|
||||
}
|
||||
|
||||
// Instance mocks base method.
|
||||
func (m *MockAPI) Instance() api.Vehicle {
|
||||
m.ctrl.T.Helper()
|
||||
|
|
@ -149,3 +163,17 @@ func (mr *MockAPIMockRecorder) SetPlanSoc(arg0, arg1 any) *gomock.Call {
|
|||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPlanSoc", reflect.TypeOf((*MockAPI)(nil).SetPlanSoc), arg0, arg1)
|
||||
}
|
||||
|
||||
// SetRepeatingPlans mocks base method.
|
||||
func (m *MockAPI) SetRepeatingPlans(arg0 []api.RepeatingPlanStruct) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "SetRepeatingPlans", arg0)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// SetRepeatingPlans indicates an expected call of SetRepeatingPlans.
|
||||
func (mr *MockAPIMockRecorder) SetRepeatingPlans(arg0 any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetRepeatingPlans", reflect.TypeOf((*MockAPI)(nil).SetRepeatingPlans), arg0)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -384,19 +384,25 @@ vehicles = "Parkplatz"
|
|||
|
||||
[main.chargingPlan]
|
||||
active = "Aktiv"
|
||||
addRepeatingPlan = "Wiederholenden Plan hinzufügen"
|
||||
arrivalTab = "Ankunft"
|
||||
day = "Tag"
|
||||
departureTab = "Abfahrt"
|
||||
goal = "Ladeziel"
|
||||
modalTitle = "Ladeplanung"
|
||||
none = "keiner"
|
||||
planNumber = "Plan {number}"
|
||||
remove = "Entfernen"
|
||||
repeating = "wiederholend"
|
||||
repeatingPlans = "Wiederholende Pläne"
|
||||
selectAll = "Alle wählen"
|
||||
time = "Zeit"
|
||||
title = "Plan"
|
||||
titleMinSoc = "Min. Ladung"
|
||||
titleTargetCharge = "Abfahrt"
|
||||
unsavedChanges = "Ungespeicherte Änderungen vorhanden. Jetzt anwenden?"
|
||||
update = "Anwenden"
|
||||
weekdays = "Wochentage"
|
||||
|
||||
[main.energyflow]
|
||||
battery = "Batterie"
|
||||
|
|
@ -490,6 +496,7 @@ currentPlan = "Aktiver Plan"
|
|||
descriptionEnergy = "Bis wann sollen {targetEnergy} ins Fahrzeug geladen sein?"
|
||||
descriptionSoc = "Wann soll das Fahrzeug auf {targetSoc} geladen sein?"
|
||||
inactiveLabel = "Zielzeit"
|
||||
nextPlan = "Nächster Plan"
|
||||
notReachableInTime = "Zielzeit wird {overrun} später erreicht."
|
||||
onlyInPvMode = "Ladeplanung ist nur im PV-Modus aktiv."
|
||||
planDuration = "Ladedauer"
|
||||
|
|
|
|||
|
|
@ -383,19 +383,25 @@ vehicles = "Parking"
|
|||
|
||||
[main.chargingPlan]
|
||||
active = "Active"
|
||||
addRepeatingPlan = "Add repeating plan"
|
||||
arrivalTab = "Arrival"
|
||||
day = "Day"
|
||||
departureTab = "Departure"
|
||||
goal = "Charging goal"
|
||||
modalTitle = "Charging Plan"
|
||||
none = "none"
|
||||
planNumber = "Plan {number}"
|
||||
remove = "Remove"
|
||||
repeating = "repeating"
|
||||
repeatingPlans = "Repeating plans"
|
||||
selectAll = "Select all"
|
||||
time = "Time"
|
||||
title = "Plan"
|
||||
titleMinSoc = "Min charge"
|
||||
titleTargetCharge = "Departure"
|
||||
unsavedChanges = "There are unsaved changes. Apply now?"
|
||||
update = "Apply"
|
||||
weekdays = "Weekdays"
|
||||
|
||||
[main.energyflow]
|
||||
battery = "Battery"
|
||||
|
|
@ -489,6 +495,7 @@ currentPlan = "Active plan"
|
|||
descriptionEnergy = "Until when should {targetEnergy} be loaded into the vehicle?"
|
||||
descriptionSoc = "When should the vehicle be charged to {targetSoc}?"
|
||||
inactiveLabel = "Target time"
|
||||
nextPlan = "Next plan"
|
||||
notReachableInTime = "Goal will be reached {overrun} later."
|
||||
onlyInPvMode = "Charging plan only works in solar mode."
|
||||
planDuration = "Charging time"
|
||||
|
|
|
|||
|
|
@ -138,10 +138,11 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, valueChan chan<- util.Param)
|
|||
|
||||
// vehicle api
|
||||
vehicles := map[string]route{
|
||||
"minsoc": {"POST", "/vehicles/{name:[a-zA-Z0-9_.:-]+}/minsoc/{value:[0-9]+}", minSocHandler(site)},
|
||||
"limitsoc": {"POST", "/vehicles/{name:[a-zA-Z0-9_.:-]+}/limitsoc/{value:[0-9]+}", limitSocHandler(site)},
|
||||
"plan": {"POST", "/vehicles/{name:[a-zA-Z0-9_.:-]+}/plan/soc/{value:[0-9]+}/{time:[0-9TZ:.+-]+}", planSocHandler(site)},
|
||||
"plan2": {"DELETE", "/vehicles/{name:[a-zA-Z0-9_.:-]+}/plan/soc", planSocRemoveHandler(site)},
|
||||
"minsoc": {"POST", "/vehicles/{name:[a-zA-Z0-9_.:-]+}/minsoc/{value:[0-9]+}", minSocHandler(site)},
|
||||
"limitsoc": {"POST", "/vehicles/{name:[a-zA-Z0-9_.:-]+}/limitsoc/{value:[0-9]+}", limitSocHandler(site)},
|
||||
"plan": {"POST", "/vehicles/{name:[a-zA-Z0-9_.:-]+}/plan/soc/{value:[0-9]+}/{time:[0-9TZ:.+-]+}", planSocHandler(site)},
|
||||
"plan2": {"DELETE", "/vehicles/{name:[a-zA-Z0-9_.:-]+}/plan/soc", planSocRemoveHandler(site)},
|
||||
"repeatingPlans": {"POST", "/vehicles/{name:[a-zA-Z0-9_.:-]+}/plan/repeating", addRepeatingPlansHandler(site)},
|
||||
|
||||
// config ui
|
||||
// "mode": {"POST", "/mode/{value:[a-z]+}", chargeModeHandler(v)},
|
||||
|
|
@ -159,28 +160,29 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, valueChan chan<- util.Param)
|
|||
api := api.PathPrefix(fmt.Sprintf("/loadpoints/%d", id+1)).Subrouter()
|
||||
|
||||
routes := map[string]route{
|
||||
"mode": {"POST", "/mode/{value:[a-z]+}", handler(eapi.ChargeModeString, pass(lp.SetMode), lp.GetMode)},
|
||||
"limitsoc": {"POST", "/limitsoc/{value:[0-9]+}", intHandler(pass(lp.SetLimitSoc), lp.GetLimitSoc)},
|
||||
"limitenergy": {"POST", "/limitenergy/{value:[0-9.]+}", floatHandler(pass(lp.SetLimitEnergy), lp.GetLimitEnergy)},
|
||||
"mincurrent": {"POST", "/mincurrent/{value:[0-9.]+}", floatHandler(lp.SetMinCurrent, lp.GetMinCurrent)},
|
||||
"maxcurrent": {"POST", "/maxcurrent/{value:[0-9.]+}", floatHandler(lp.SetMaxCurrent, lp.GetMaxCurrent)},
|
||||
"phases": {"POST", "/phases/{value:[0-9]+}", intHandler(lp.SetPhases, lp.GetPhases)},
|
||||
"plan": {"GET", "/plan", planHandler(lp)},
|
||||
"planpreview": {"GET", "/plan/preview/{type:(?:soc|energy)}/{value:[0-9.]+}/{time:[0-9TZ:.+-]+}", planPreviewHandler(lp)},
|
||||
"planenergy": {"POST", "/plan/energy/{value:[0-9.]+}/{time:[0-9TZ:.+-]+}", planEnergyHandler(lp)},
|
||||
"planenergy2": {"DELETE", "/plan/energy", planRemoveHandler(lp)},
|
||||
"vehicle": {"POST", "/vehicle/{name:[a-zA-Z0-9_.:-]+}", vehicleSelectHandler(site, lp)},
|
||||
"vehicle2": {"DELETE", "/vehicle", vehicleRemoveHandler(lp)},
|
||||
"vehicleDetect": {"PATCH", "/vehicle", vehicleDetectHandler(lp)},
|
||||
"remotedemand": {"POST", "/remotedemand/{demand:[a-z]+}/{source:[0-9a-zA-Z_-]+}", remoteDemandHandler(lp)},
|
||||
"enableThreshold": {"POST", "/enable/threshold/{value:-?[0-9.]+}", floatHandler(pass(lp.SetEnableThreshold), lp.GetEnableThreshold)},
|
||||
"enableDelay": {"POST", "/enable/delay/{value:[0-9]+}", durationHandler(pass(lp.SetEnableDelay), lp.GetEnableDelay)},
|
||||
"disableThreshold": {"POST", "/disable/threshold/{value:-?[0-9.]+}", floatHandler(pass(lp.SetDisableThreshold), lp.GetDisableThreshold)},
|
||||
"disableDelay": {"POST", "/disable/delay/{value:[0-9]+}", durationHandler(pass(lp.SetDisableDelay), lp.GetDisableDelay)},
|
||||
"smartCost": {"POST", "/smartcostlimit/{value:-?[0-9.]+}", floatPtrHandler(pass(lp.SetSmartCostLimit), lp.GetSmartCostLimit)},
|
||||
"smartCostDelete": {"DELETE", "/smartcostlimit", floatPtrHandler(pass(lp.SetSmartCostLimit), lp.GetSmartCostLimit)},
|
||||
"priority": {"POST", "/priority/{value:[0-9]+}", intHandler(pass(lp.SetPriority), lp.GetPriority)},
|
||||
"batteryBoost": {"POST", "/batteryboost/{value:[01truefalse]}", boolHandler(lp.SetBatteryBoost, lp.GetBatteryBoost)},
|
||||
"mode": {"POST", "/mode/{value:[a-z]+}", handler(eapi.ChargeModeString, pass(lp.SetMode), lp.GetMode)},
|
||||
"limitsoc": {"POST", "/limitsoc/{value:[0-9]+}", intHandler(pass(lp.SetLimitSoc), lp.GetLimitSoc)},
|
||||
"limitenergy": {"POST", "/limitenergy/{value:[0-9.]+}", floatHandler(pass(lp.SetLimitEnergy), lp.GetLimitEnergy)},
|
||||
"mincurrent": {"POST", "/mincurrent/{value:[0-9.]+}", floatHandler(lp.SetMinCurrent, lp.GetMinCurrent)},
|
||||
"maxcurrent": {"POST", "/maxcurrent/{value:[0-9.]+}", floatHandler(lp.SetMaxCurrent, lp.GetMaxCurrent)},
|
||||
"phases": {"POST", "/phases/{value:[0-9]+}", intHandler(lp.SetPhases, lp.GetPhases)},
|
||||
"plan": {"GET", "/plan", planHandler(lp)},
|
||||
"staticPlanPreview": {"GET", "/plan/static/preview/{type:(?:soc|energy)}/{value:[0-9.]+}/{time:[0-9TZ:.+-]+}", staticPlanPreviewHandler(lp)},
|
||||
"repeatingPlanPreview": {"GET", "/plan/repeating/preview/{soc:[0-9]+}/{weekdays:[0-6,]+}/{time:[0-2][0-9]:[0-5][0-9]}/{tz:[a-zA-Z0-9_./:-]+}", repeatingPlanPreviewHandler(lp)},
|
||||
"planenergy": {"POST", "/plan/energy/{value:[0-9.]+}/{time:[0-9TZ:.+-]+}", planEnergyHandler(lp)},
|
||||
"planenergy2": {"DELETE", "/plan/energy", planRemoveHandler(lp)},
|
||||
"vehicle": {"POST", "/vehicle/{name:[a-zA-Z0-9_.:-]+}", vehicleSelectHandler(site, lp)},
|
||||
"vehicle2": {"DELETE", "/vehicle", vehicleRemoveHandler(lp)},
|
||||
"vehicleDetect": {"PATCH", "/vehicle", vehicleDetectHandler(lp)},
|
||||
"remotedemand": {"POST", "/remotedemand/{demand:[a-z]+}/{source:[0-9a-zA-Z_-]+}", remoteDemandHandler(lp)},
|
||||
"enableThreshold": {"POST", "/enable/threshold/{value:-?[0-9.]+}", floatHandler(pass(lp.SetEnableThreshold), lp.GetEnableThreshold)},
|
||||
"enableDelay": {"POST", "/enable/delay/{value:[0-9]+}", durationHandler(pass(lp.SetEnableDelay), lp.GetEnableDelay)},
|
||||
"disableThreshold": {"POST", "/disable/threshold/{value:-?[0-9.]+}", floatHandler(pass(lp.SetDisableThreshold), lp.GetDisableThreshold)},
|
||||
"disableDelay": {"POST", "/disable/delay/{value:[0-9]+}", durationHandler(pass(lp.SetDisableDelay), lp.GetDisableDelay)},
|
||||
"smartCost": {"POST", "/smartcostlimit/{value:-?[0-9.]+}", floatPtrHandler(pass(lp.SetSmartCostLimit), lp.GetSmartCostLimit)},
|
||||
"smartCostDelete": {"DELETE", "/smartcostlimit", floatPtrHandler(pass(lp.SetSmartCostLimit), lp.GetSmartCostLimit)},
|
||||
"priority": {"POST", "/priority/{value:[0-9]+}", intHandler(pass(lp.SetPriority), lp.GetPriority)},
|
||||
"batteryBoost": {"POST", "/batteryboost/{value:[01truefalse]}", boolHandler(lp.SetBatteryBoost, lp.GetBatteryBoost)},
|
||||
}
|
||||
|
||||
for _, r := range routes {
|
||||
|
|
|
|||
|
|
@ -5,11 +5,13 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/core/loadpoint"
|
||||
"github.com/evcc-io/evcc/core/site"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
|
|
@ -44,6 +46,7 @@ func planHandler(lp loadpoint.API) http.HandlerFunc {
|
|||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
maxPower := lp.EffectiveMaxPower()
|
||||
planTime := lp.EffectivePlanTime()
|
||||
id := lp.EffectivePlanId()
|
||||
|
||||
goal, _ := lp.GetPlanGoal()
|
||||
requiredDuration := lp.GetPlanRequiredDuration(goal, maxPower)
|
||||
|
|
@ -54,11 +57,13 @@ func planHandler(lp loadpoint.API) http.HandlerFunc {
|
|||
}
|
||||
|
||||
res := struct {
|
||||
PlanId int `json:"planId"`
|
||||
PlanTime time.Time `json:"planTime"`
|
||||
Duration int64 `json:"duration"`
|
||||
Plan api.Rates `json:"plan"`
|
||||
Power float64 `json:"power"`
|
||||
}{
|
||||
PlanId: id,
|
||||
PlanTime: planTime,
|
||||
Duration: int64(requiredDuration.Seconds()),
|
||||
Plan: plan,
|
||||
|
|
@ -69,8 +74,8 @@ func planHandler(lp loadpoint.API) http.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
// planPreviewHandler returns a plan preview for given parameters
|
||||
func planPreviewHandler(lp loadpoint.API) http.HandlerFunc {
|
||||
// staticPlanPreviewHandler returns a plan preview for given parameters
|
||||
func staticPlanPreviewHandler(lp loadpoint.API) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
|
||||
|
|
@ -126,6 +131,59 @@ func planPreviewHandler(lp loadpoint.API) http.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
func repeatingPlanPreviewHandler(lp loadpoint.API) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
|
||||
hourMinute := vars["time"]
|
||||
tz := vars["tz"]
|
||||
|
||||
var weekdays []int
|
||||
for _, weekdayStr := range strings.Split(vars["weekdays"], ",") {
|
||||
weekday, err := strconv.Atoi(weekdayStr)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, fmt.Errorf("invalid weekdays format"))
|
||||
return
|
||||
}
|
||||
weekdays = append(weekdays, weekday)
|
||||
}
|
||||
|
||||
soc, err := strconv.ParseFloat(vars["soc"], 64)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
planTime, err := util.GetNextOccurrence(weekdays, hourMinute, tz)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
maxPower := lp.EffectiveMaxPower()
|
||||
requiredDuration := lp.GetPlanRequiredDuration(soc, maxPower)
|
||||
plan, err := lp.GetPlan(planTime, requiredDuration)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
res := struct {
|
||||
PlanTime time.Time `json:"planTime"`
|
||||
Duration int64 `json:"duration"`
|
||||
Plan api.Rates `json:"plan"`
|
||||
Power float64 `json:"power"`
|
||||
}{
|
||||
PlanTime: planTime,
|
||||
Duration: int64(requiredDuration.Seconds()),
|
||||
Plan: plan,
|
||||
Power: maxPower,
|
||||
}
|
||||
|
||||
jsonResult(w, res)
|
||||
}
|
||||
}
|
||||
|
||||
// planEnergyHandler updates plan energy and time
|
||||
func planEnergyHandler(lp loadpoint.API) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/core/site"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
|
@ -109,6 +111,36 @@ func planSocHandler(site site.API) http.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
// addRepeatingPlansHandler handles any information regarding weekday, hour, minute, soc and isActive
|
||||
func addRepeatingPlansHandler(site site.API) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
|
||||
v, err := site.Vehicles().ByName(vars["name"])
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
var plansWrapper struct {
|
||||
RepeatingPlans []api.RepeatingPlanStruct `json:"plans"`
|
||||
}
|
||||
|
||||
err = json.NewDecoder(r.Body).Decode(&plansWrapper)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := v.SetRepeatingPlans(plansWrapper.RepeatingPlans); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonResult(w, plansWrapper)
|
||||
}
|
||||
}
|
||||
|
||||
// planSocRemoveHandler removes plan soc and time
|
||||
func planSocRemoveHandler(site site.API) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { start, stop, baseUrl } from "./evcc";
|
||||
import { start, stop, baseUrl, restart } from "./evcc";
|
||||
|
||||
test.use({ baseURL: baseUrl() });
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
|
@ -14,25 +14,41 @@ test.afterEach(async () => {
|
|||
await stop();
|
||||
});
|
||||
|
||||
function getWeekday(offset = 1) {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + offset);
|
||||
return date.toLocaleDateString("en-US", { weekday: "long" });
|
||||
}
|
||||
|
||||
async function setAndVerifyPlan(page, lp, { soc, energy }) {
|
||||
await lp.getByTestId("charging-plan").getByRole("button", { name: "none" }).click();
|
||||
await lp.getByTestId("charging-plan-button").click();
|
||||
|
||||
if (soc) {
|
||||
await page.getByTestId("plan-soc").selectOption(soc);
|
||||
await page.getByTestId("static-plan-soc").selectOption(soc);
|
||||
}
|
||||
if (energy) {
|
||||
// select "25 kWh (+50%)" by providing "25 kWh" as option text
|
||||
const optionText = await page
|
||||
.getByTestId("plan-energy")
|
||||
.getByTestId("static-plan-energy")
|
||||
.locator("option", { hasText: energy })
|
||||
.textContent();
|
||||
await page.getByTestId("plan-energy").selectOption(optionText);
|
||||
await page.getByTestId("static-plan-energy").selectOption(optionText);
|
||||
}
|
||||
await page.getByTestId("plan-active").click();
|
||||
await page.getByTestId("static-plan-active").click();
|
||||
await page.getByRole("button", { name: "Close" }).click();
|
||||
await expect(lp.getByTestId("charging-plan")).toContainText(soc || energy);
|
||||
}
|
||||
|
||||
async function verifyRepeatingPlanAvailable(page, lp, expected) {
|
||||
await lp.getByTestId("charging-plan-button").click();
|
||||
if (expected) {
|
||||
await expect(page.getByTestId("repeating-plan-add")).toBeVisible();
|
||||
} else {
|
||||
await expect(page.getByTestId("repeating-plan-add")).not.toBeVisible();
|
||||
}
|
||||
await page.getByRole("button", { name: "Close" }).click();
|
||||
}
|
||||
|
||||
test.describe("basic functionality", async () => {
|
||||
test("vehicle with soc and capacity, set and restart", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
|
@ -52,10 +68,10 @@ test.describe("basic functionality", async () => {
|
|||
await lp1.getByRole("button", { name: "Solar", exact: true }).click();
|
||||
await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).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.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.getByTestId("static-plan-active").click();
|
||||
await page.getByRole("button", { name: "Close" }).click();
|
||||
|
||||
await expect(lp1.getByTestId("plan-marker")).toBeVisible();
|
||||
|
|
@ -70,13 +86,13 @@ test.describe("basic functionality", async () => {
|
|||
"tomorrow 9:30 AM80%"
|
||||
);
|
||||
await lp1.getByTestId("charging-plan").getByRole("button").click();
|
||||
await expect(page.getByTestId("plan-soc")).toHaveValue("80");
|
||||
await expect(page.getByTestId("static-plan-soc")).toHaveValue("80");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("vehicle variations", async () => {
|
||||
test.describe("guest vehicle", async () => {
|
||||
test("kWh based plan and limit", async ({ page }) => {
|
||||
test("kWh based plan and limit, no repeats", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
const lp1 = await page.getByTestId("loadpoint").first();
|
||||
|
|
@ -88,11 +104,14 @@ test.describe("vehicle variations", async () => {
|
|||
|
||||
// kWh based plan
|
||||
await setAndVerifyPlan(page, lp1, { energy: "25 kWh" });
|
||||
|
||||
// no repeating plans option
|
||||
await verifyRepeatingPlanAvailable(page, lp1, false);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("vehicle no soc no capacity", async () => {
|
||||
test("kWh based plan and limit", async ({ page }) => {
|
||||
test("kWh based plan and limit, no repeats", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
const lp1 = await page.getByTestId("loadpoint").first();
|
||||
|
|
@ -108,11 +127,14 @@ test.describe("vehicle variations", async () => {
|
|||
|
||||
// kWh based plan
|
||||
await setAndVerifyPlan(page, lp1, { energy: "25 kWh" });
|
||||
|
||||
// no repeating plans option
|
||||
await verifyRepeatingPlanAvailable(page, lp1, false);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("vehicle no soc with capacity", async () => {
|
||||
test("kWh based plan and limit", async ({ page }) => {
|
||||
test("kWh based plan and limit, no repeats", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
const lp1 = await page.getByTestId("loadpoint").first();
|
||||
|
|
@ -128,11 +150,14 @@ test.describe("vehicle variations", async () => {
|
|||
|
||||
// kWh based plan
|
||||
await setAndVerifyPlan(page, lp1, { energy: "25 kWh" });
|
||||
|
||||
// no repeating plans option
|
||||
await verifyRepeatingPlanAvailable(page, lp1, false);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("vehicle with soc no capacity", async () => {
|
||||
test("kWh based plan and soc based limit", async ({ page }) => {
|
||||
test("kWh based plan and soc based limit, no repeats", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
const lp1 = await page.getByTestId("loadpoint").first();
|
||||
|
|
@ -148,11 +173,14 @@ test.describe("vehicle variations", async () => {
|
|||
|
||||
// soc based plan
|
||||
await setAndVerifyPlan(page, lp1, { energy: "50 kWh" });
|
||||
|
||||
// no repeating plans option
|
||||
await verifyRepeatingPlanAvailable(page, lp1, false);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("vehicle with soc with capacity", async () => {
|
||||
test("soc based plan and limit", async ({ page }) => {
|
||||
test("soc based plan and limit, with repeats", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
const lp1 = await page.getByTestId("loadpoint").first();
|
||||
|
|
@ -168,11 +196,14 @@ test.describe("vehicle variations", async () => {
|
|||
|
||||
// soc based plan
|
||||
await setAndVerifyPlan(page, lp1, { soc: "60%" });
|
||||
|
||||
// repeating plans option
|
||||
await verifyRepeatingPlanAvailable(page, lp1, true);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("loadpoint with soc, guest vehicle", async () => {
|
||||
test("kWh based plan and soc based limit", async ({ page }) => {
|
||||
test("kWh based plan and soc based limit, no repeats", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
const lp2 = await page.getByTestId("loadpoint").last();
|
||||
|
|
@ -185,11 +216,14 @@ test.describe("vehicle variations", async () => {
|
|||
|
||||
// soc based plan
|
||||
await setAndVerifyPlan(page, lp2, { energy: "50 kWh" });
|
||||
|
||||
// no repeating plans option
|
||||
await verifyRepeatingPlanAvailable(page, lp2, false);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("loadpoint with soc, vehicle with capacity", async () => {
|
||||
test("soc based plan and limit", async ({ page }) => {
|
||||
test("soc based plan and limit, with repeats", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
const lp2 = await page.getByTestId("loadpoint").last();
|
||||
|
|
@ -205,6 +239,9 @@ test.describe("vehicle variations", async () => {
|
|||
|
||||
// soc based plan
|
||||
await setAndVerifyPlan(page, lp2, { soc: "60%" });
|
||||
|
||||
// repeating plans option
|
||||
await verifyRepeatingPlanAvailable(page, lp2, true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -214,12 +251,12 @@ test.describe("preview", async () => {
|
|||
{
|
||||
szenario: "kWh based plan",
|
||||
vehicle: "Vehicle no SoC with Capacity",
|
||||
goalId: "plan-energy",
|
||||
goalId: "static-plan-energy",
|
||||
},
|
||||
{
|
||||
szenario: "soc based plan",
|
||||
vehicle: "Vehicle with SoC with Capacity",
|
||||
goalId: "plan-soc",
|
||||
goalId: "static-plan-soc",
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -235,29 +272,29 @@ test.describe("preview", async () => {
|
|||
await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).click();
|
||||
|
||||
// initial set -> preview plan
|
||||
await page.getByTestId("plan-day").selectOption({ index: 1 });
|
||||
await page.getByTestId("plan-time").fill("09:30");
|
||||
await page.getByTestId("static-plan-day").selectOption({ index: 1 });
|
||||
await page.getByTestId("static-plan-time").fill("09:30");
|
||||
await page.getByTestId(c.goalId).selectOption("80");
|
||||
await expect(page.getByTestId("plan-preview-title")).toHaveText("Preview plan");
|
||||
|
||||
// activate -> active plan
|
||||
await page.getByTestId("plan-active").click();
|
||||
await page.getByTestId("static-plan-active").click();
|
||||
await expect(page.getByTestId("plan-preview-title")).toHaveText("Active plan");
|
||||
|
||||
// change -> preview plan
|
||||
await page.getByTestId(c.goalId).selectOption("90");
|
||||
await expect(page.getByTestId("plan-preview-title")).toHaveText("Preview plan");
|
||||
await expect(page.getByTestId("plan-preview-title")).toHaveText("Active plan");
|
||||
|
||||
// apply -> active plan
|
||||
await expect(page.getByTestId("plan-apply")).toBeVisible();
|
||||
await page.getByTestId("plan-apply").click();
|
||||
await expect(page.getByTestId("static-plan-apply")).toBeVisible();
|
||||
await page.getByTestId("static-plan-apply").click();
|
||||
await expect(page.getByTestId("plan-preview-title")).toHaveText("Active plan");
|
||||
|
||||
// deactivate -> stay in preview
|
||||
await page.getByTestId("plan-time").fill("23:30");
|
||||
await expect(page.getByTestId("plan-preview-title")).toHaveText("Preview plan");
|
||||
await expect(page.getByTestId("plan-apply")).toBeVisible();
|
||||
await page.getByTestId("plan-active").click();
|
||||
await page.getByTestId("static-plan-time").fill("23:30");
|
||||
await expect(page.getByTestId("plan-preview-title")).toHaveText("Active plan");
|
||||
await expect(page.getByTestId("static-plan-apply")).toBeVisible();
|
||||
|
||||
// deactivate
|
||||
await page.getByTestId("static-plan-active").click();
|
||||
await expect(page.getByTestId("plan-preview-title")).toHaveText("Preview plan");
|
||||
});
|
||||
});
|
||||
|
|
@ -277,7 +314,7 @@ test.describe("warnings", async () => {
|
|||
|
||||
await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).click();
|
||||
|
||||
await page.getByTestId("plan-active").click();
|
||||
await page.getByTestId("static-plan-active").click();
|
||||
|
||||
// match this text but with fuzzy date "getByText('Goal will be reached 52:10 h')"
|
||||
await expect(page.getByTestId("plan-warnings")).toHaveText(/Goal will be reached .* later/);
|
||||
|
|
@ -297,12 +334,258 @@ test.describe("warnings", async () => {
|
|||
|
||||
await expect(page.getByTestId("plan-entry-warnings")).not.toBeVisible();
|
||||
|
||||
await page.getByTestId("plan-day").selectOption({ index: 0 });
|
||||
await page.getByTestId("plan-time").fill("00:01");
|
||||
await page.getByTestId("static-plan-day").selectOption({ index: 0 });
|
||||
await page.getByTestId("static-plan-time").fill("00:01");
|
||||
|
||||
await expect(page.getByTestId("plan-entry-warnings")).toContainText(
|
||||
"Pick a time in the future, Marty."
|
||||
);
|
||||
await page.getByTestId("plan-time").fill("00:01");
|
||||
await page.getByTestId("static-plan-time").fill("00:01");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("repeating", async () => {
|
||||
test("add and remove plans", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
const lp1 = await page.getByTestId("loadpoint").first();
|
||||
await lp1
|
||||
.getByTestId("change-vehicle")
|
||||
.locator("select")
|
||||
.selectOption("Vehicle with SoC with Capacity");
|
||||
|
||||
await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).click();
|
||||
const modal = await page.getByTestId("charging-plan-modal");
|
||||
|
||||
// one static plan, no number
|
||||
await expect(modal.getByTestId("plan-entry")).toHaveCount(1);
|
||||
await expect(modal.getByTestId("plan-entry").first()).not.toContainText("#1");
|
||||
|
||||
// add plan
|
||||
await modal.getByRole("button", { name: "Add repeating plan" }).click();
|
||||
await expect(modal.getByTestId("plan-entry")).toHaveCount(2);
|
||||
await expect(modal.getByTestId("plan-entry").first()).toContainText("#1");
|
||||
await expect(modal.getByTestId("plan-entry").last()).toContainText("#2");
|
||||
|
||||
// remove plan
|
||||
await modal.getByTestId("plan-entry").last().getByRole("button", { name: "Remove" }).click();
|
||||
await expect(modal.getByTestId("plan-entry")).toHaveCount(1);
|
||||
await expect(modal.getByTestId("plan-entry").first()).not.toContainText("#1");
|
||||
});
|
||||
|
||||
test("preview static and repeating plan", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
const lp1 = await page.getByTestId("loadpoint").first();
|
||||
await lp1
|
||||
.getByTestId("change-vehicle")
|
||||
.locator("select")
|
||||
.selectOption("Vehicle with SoC with Capacity");
|
||||
|
||||
await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).click();
|
||||
const modal = await page.getByTestId("charging-plan-modal");
|
||||
|
||||
// one static plan
|
||||
await expect(modal.getByTestId("plan-entry")).toHaveCount(1);
|
||||
|
||||
await modal.getByTestId("static-plan-day").selectOption({ index: 1 }); // tomorrow
|
||||
await modal.getByTestId("static-plan-time").fill("09:00");
|
||||
|
||||
await expect(modal.getByTestId("plan-preview-title")).toHaveText("Preview plan");
|
||||
await expect(modal.getByTestId("target-text")).toContainText("9:00 AM");
|
||||
|
||||
// add repeating plan
|
||||
await modal.getByRole("button", { name: "Add repeating plan" }).click();
|
||||
await modal.getByTestId("repeating-plan-weekdays").click();
|
||||
await modal.getByRole("checkbox", { name: "Select all" }).check();
|
||||
await modal.getByTestId("repeating-plan-time").fill("11:11");
|
||||
|
||||
// switch between previews
|
||||
await modal
|
||||
.getByTestId("plan-preview-title")
|
||||
.getByRole("combobox")
|
||||
.selectOption("Preview plan #2");
|
||||
await expect(modal.getByTestId("target-text")).toContainText("11:11 AM");
|
||||
|
||||
await modal
|
||||
.getByTestId("plan-preview-title")
|
||||
.getByRole("combobox")
|
||||
.selectOption("Preview plan #1");
|
||||
await expect(modal.getByTestId("target-text")).toContainText("9:00 AM");
|
||||
|
||||
// activate #1
|
||||
await modal.getByTestId("static-plan-active").click();
|
||||
await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #1");
|
||||
await expect(modal.getByTestId("target-text")).toContainText("9:00 AM");
|
||||
|
||||
// activate #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("target-text")).toContainText("11:11 AM");
|
||||
|
||||
// back to preview if no active plan
|
||||
await modal.getByTestId("repeating-plan-active").click();
|
||||
await expect(modal.getByTestId("plan-preview-title").locator("option:checked")).toHaveText(
|
||||
"Preview plan #1"
|
||||
);
|
||||
await expect(modal.getByTestId("target-text")).toContainText("9:00 AM");
|
||||
});
|
||||
|
||||
test("weekday selection", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
const lp1 = await page.getByTestId("loadpoint").first();
|
||||
await lp1
|
||||
.getByTestId("change-vehicle")
|
||||
.locator("select")
|
||||
.selectOption("Vehicle with SoC with Capacity");
|
||||
|
||||
await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).click();
|
||||
const modal = await page.getByTestId("charging-plan-modal");
|
||||
|
||||
await modal.getByRole("button", { name: "Add repeating plan" }).click();
|
||||
|
||||
// weekday select should have value "Mo-Fr"
|
||||
await expect(modal.getByTestId("repeating-plan-weekdays").getByRole("button")).toHaveText(
|
||||
"Mon – Fri"
|
||||
);
|
||||
|
||||
// select all weekdays
|
||||
await modal.getByTestId("repeating-plan-weekdays").click();
|
||||
await modal.getByRole("checkbox", { name: "Select all" }).click();
|
||||
await expect(modal.getByTestId("repeating-plan-weekdays").getByRole("button")).toHaveText(
|
||||
"Mon – Sun"
|
||||
);
|
||||
|
||||
// select none
|
||||
await modal.getByRole("checkbox", { name: "Select all" }).click();
|
||||
await expect(modal.getByTestId("repeating-plan-weekdays").getByRole("button")).toHaveText("–");
|
||||
|
||||
// select specific weekdays
|
||||
await modal.getByRole("checkbox", { name: "Thursday" }).check();
|
||||
await expect(modal.getByTestId("repeating-plan-weekdays").getByRole("button")).toHaveText(
|
||||
"Thu"
|
||||
);
|
||||
await modal.getByTestId("repeating-plan-weekdays").click(); // close
|
||||
|
||||
// activate
|
||||
await modal.getByTestId("repeating-plan-time").fill("02:22");
|
||||
await modal.getByTestId("repeating-plan-active").click();
|
||||
|
||||
// specific weekday and time
|
||||
await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #2");
|
||||
await expect(modal.getByTestId("target-text")).toContainText("Thu 2:22 AM");
|
||||
});
|
||||
|
||||
test("next plan", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
const yesterday = getWeekday(-1);
|
||||
const tomorrow = getWeekday(1);
|
||||
|
||||
const lp1 = await page.getByTestId("loadpoint").first();
|
||||
await lp1
|
||||
.getByTestId("change-vehicle")
|
||||
.locator("select")
|
||||
.selectOption("Vehicle with SoC with Capacity");
|
||||
|
||||
await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).click();
|
||||
const modal = await page.getByTestId("charging-plan-modal");
|
||||
|
||||
// configure static plan for tomorrow
|
||||
const plan1 = modal.getByTestId("plan-entry").nth(0);
|
||||
await plan1.getByTestId("static-plan-day").selectOption({ index: 1 });
|
||||
await plan1.getByTestId("static-plan-time").fill("09:30");
|
||||
await plan1.getByTestId("static-plan-active").click();
|
||||
|
||||
// add repeating plan for tomorrow
|
||||
await modal.getByRole("button", { name: "Add repeating plan" }).click();
|
||||
const plan2 = modal.getByTestId("plan-entry").nth(1);
|
||||
const days2 = plan2.getByTestId("repeating-plan-weekdays");
|
||||
await days2.click();
|
||||
await days2.getByRole("checkbox", { name: "Select all" }).click();
|
||||
await days2.getByRole("checkbox", { name: "Select all" }).click();
|
||||
await days2.getByRole("checkbox", { name: tomorrow }).check();
|
||||
await days2.click(); // close
|
||||
await plan2.getByTestId("repeating-plan-time").fill("09:20");
|
||||
await plan2.getByTestId("repeating-plan-active").click();
|
||||
|
||||
// add repeating plan for every day
|
||||
await modal.getByRole("button", { name: "Add repeating plan" }).click();
|
||||
const plan3 = modal.getByTestId("plan-entry").last();
|
||||
const days3 = plan3.getByTestId("repeating-plan-weekdays");
|
||||
await days3.click();
|
||||
await days3.getByRole("checkbox", { name: "Select all" }).check();
|
||||
await days3.click(); // close
|
||||
await plan3.getByTestId("repeating-plan-time").fill("09:10");
|
||||
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("target-text")).toContainText("9:10 AM");
|
||||
|
||||
// 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("target-text")).toContainText("9:20 AM");
|
||||
|
||||
// change plan #2 to yesterday
|
||||
await days2.click();
|
||||
await days2.getByRole("checkbox", { name: tomorrow }).click(); // uncheck
|
||||
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("target-text")).toContainText("9:20 AM");
|
||||
|
||||
// apply
|
||||
await plan2.getByTestId("repeating-plan-apply").click();
|
||||
await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #1");
|
||||
await expect(modal.getByTestId("target-text")).toContainText("9:30 AM");
|
||||
});
|
||||
|
||||
test("repeating plan persistence", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
const tomorrow = getWeekday(1);
|
||||
|
||||
let lp1 = await page.getByTestId("loadpoint").first();
|
||||
await lp1
|
||||
.getByTestId("change-vehicle")
|
||||
.locator("select")
|
||||
.selectOption("Vehicle with SoC with Capacity");
|
||||
|
||||
await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).click();
|
||||
let modal = await page.getByTestId("charging-plan-modal");
|
||||
|
||||
await modal.getByRole("button", { name: "Add repeating plan" }).click();
|
||||
const plan = modal.getByTestId("plan-entry").nth(1);
|
||||
await plan.getByTestId("repeating-plan-weekdays").click();
|
||||
await plan.getByRole("checkbox", { name: "Select all" }).click(); // check all
|
||||
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 plan.getByTestId("repeating-plan-active").click();
|
||||
await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #2");
|
||||
await expect(modal.getByTestId("target-text")).toContainText("9:20 AM");
|
||||
|
||||
await restart(CONFIG);
|
||||
await page.goto("/");
|
||||
|
||||
lp1 = await page.getByTestId("loadpoint").first();
|
||||
await lp1
|
||||
.getByTestId("change-vehicle")
|
||||
.locator("select")
|
||||
.selectOption("Vehicle with SoC with Capacity");
|
||||
|
||||
await lp1
|
||||
.getByTestId("charging-plan")
|
||||
.getByRole("button", { name: "tomorrow 9:20 AM" })
|
||||
.click();
|
||||
modal = await page.getByTestId("charging-plan-modal");
|
||||
await expect(modal.getByTestId("plan-entry")).toHaveCount(2);
|
||||
await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #2");
|
||||
await expect(modal.getByTestId("target-text")).toContainText("9:20 AM");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
54
util/time.go
Normal file
54
util/time.go
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
package util
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GetNextOccurrence returns the next occurrence of the given time on the specified weekdays.
|
||||
func GetNextOccurrence(weekdays []int, timeStr string, tz string) (time.Time, error) {
|
||||
loc, err := time.LoadLocation(tz)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("invalid timezone: %w", err)
|
||||
}
|
||||
|
||||
parsedTime, err := time.Parse("15:04", timeStr)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("invalid time format, expected HH:MM: %w", err)
|
||||
}
|
||||
|
||||
hour, minute := parsedTime.Hour(), parsedTime.Minute()
|
||||
|
||||
now := time.Now().In(loc)
|
||||
|
||||
target := time.Date(
|
||||
now.Year(), now.Month(), now.Day(),
|
||||
hour, minute, 0, 0, loc,
|
||||
)
|
||||
|
||||
// If the target time has passed today, start from tomorrow
|
||||
if target.Before(now) {
|
||||
target = target.AddDate(0, 0, 1)
|
||||
}
|
||||
|
||||
// Check the next 7 days for a valid match
|
||||
for i := 0; i < 7; i++ {
|
||||
weekday := int(target.Weekday())
|
||||
if contains(weekdays, weekday) {
|
||||
return target, nil
|
||||
}
|
||||
target = target.AddDate(0, 0, 1)
|
||||
}
|
||||
|
||||
return time.Time{}, fmt.Errorf("no valid weekday found")
|
||||
}
|
||||
|
||||
// helper function to check if a slice contains a value
|
||||
func contains(slice []int, val int) bool {
|
||||
for _, item := range slice {
|
||||
if item == val {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue