evcc-io/assets/js/components/ChargingPlans/PlansSettings.vue

376 lines
10 KiB
Vue

<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-if="alreadyReached">
{{ $t("main.targetCharge.goalReached") }}
</span>
<span v-else>
{{ nextPlanTitle }}
</span>
</div>
</h5>
<ChargingPlanWarnings v-bind="chargingPlanWarningsProps" />
<ChargingPlanPreview v-bind="chargingPlanPreviewProps" />
</div>
</template>
<script lang="ts">
import "@h2d2/shopicons/es/regular/plus";
import Preview from "./Preview.vue";
import PlanStaticSettings from "./PlanStaticSettings.vue";
import RepeatingSettings from "./PlansRepeatingSettings.vue";
import Warnings from "./Warnings.vue";
import formatter from "../../mixins/formatter.js";
import collector from "../../mixins/collector.js";
import api, { allowClientError } from "../../api.js";
import CustomSelect from "../Helper/CustomSelect.vue";
import deepEqual from "../../utils/deepEqual.js";
import convertRates from "../../utils/convertRates";
import { defineComponent, type PropType } from "vue";
import type { Vehicle, PartialBy, Timeout, Tariff, SelectOption, CURRENCY } from "../../types/evcc";
import type {
StaticPlan,
RepeatingPlan,
PlanWrapper,
StaticSocPlan,
StaticEnergyPlan,
PlanResponse,
} from "./types.js";
const TARIFF_CACHE_TIME = 300000; // 5 minutes
export default defineComponent({
name: "ChargingPlansSettings",
components: {
ChargingPlanPreview: Preview,
ChargingPlanStaticSettings: PlanStaticSettings,
ChargingPlansRepeatingSettings: RepeatingSettings,
ChargingPlanWarnings: Warnings,
CustomSelect,
},
mixins: [formatter, collector],
props: {
id: [String, Number],
staticPlan: Object as PropType<StaticPlan>,
repeatingPlans: { type: Array as PropType<RepeatingPlan[]>, default: () => [] },
effectiveLimitSoc: Number,
effectivePlanTime: String,
effectivePlanSoc: Number,
planEnergy: Number,
limitEnergy: Number,
socBasedPlanning: Boolean,
socPerKwh: Number,
rangePerSoc: Number,
smartCostType: String,
currency: String as PropType<CURRENCY>,
mode: String,
capacity: Number,
vehicle: Object as PropType<Vehicle>,
vehicleLimitSoc: Number,
planOverrun: Number,
},
emits: ["static-plan-removed", "static-plan-updated", "repeating-plans-updated"],
data() {
return {
staticPlanPreview: {} as StaticPlan,
tariff: {} as Tariff,
plan: {} as PlanWrapper,
activeTab: "time",
debounceTimer: null as Timeout,
selectedPreviewId: 1,
nextPlanId: 0,
};
},
computed: {
noActivePlan(): boolean {
return !this.staticPlan && this.repeatingPlans.every((plan) => !plan.active);
},
multiplePlans(): boolean {
return this.repeatingPlans.length !== 0;
},
selectedPreviewPlanTitle(): string {
return this.previewPlanOptions[this.selectedPreviewId - 1]?.name;
},
chargingPlanWarningsProps(): any {
return this.collectProps(Warnings);
},
chargingPlanPreviewProps(): any {
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(): SelectOption<number>[] {
const name = (n: number) => `${this.$t("main.targetCharge.preview")} #${n}`;
// static plan
const options = [{ value: 1, name: name(1) }] as SelectOption<number>[];
// repeating plans
this.repeatingPlans.forEach((plan, index) => {
const number = index + 2;
options.push({
value: number,
name: name(number),
disabled: !plan.weekdays.length,
});
});
return options;
},
alreadyReached(): boolean {
return this.plan.duration === 0;
},
nextPlanTitle(): string {
return `${this.$t("main.targetCharge.nextPlan")} #${this.nextPlanId}`;
},
},
watch: {
effectivePlanTime(newValue: string) {
if (null !== newValue) {
this.updatePlanDebounced();
}
},
staticPlan: {
deep: true,
handler(vNew: StaticPlan, vOld: StaticPlan) {
if (!deepEqual(vNew, vOld)) {
this.updatePlanDebounced();
}
},
},
repeatingPlans: {
deep: true,
handler(vNew: RepeatingPlan[], vOld: RepeatingPlan[]) {
if (!deepEqual(vNew, vOld)) {
this.adjustPreviewId();
this.updatePlanDebounced();
}
},
},
},
mounted(): void {
this.updatePlanDebounced();
},
methods: {
selectPreviewPlan(id: number): void {
this.selectedPreviewId = id;
this.updatePlanDebounced();
},
async updatePlanDebounced() {
if (this.noActivePlan) {
await this.updatePlanPreviewDebounced();
} else {
await this.updateActivePlanDebounced();
}
},
adjustPreviewId(): void {
if (this.selectedPreviewId > this.previewPlanOptions.length) {
this.selectedPreviewId = this.previewPlanOptions.length;
}
},
async updateActivePlan(): Promise<void> {
await this.updateTariff();
try {
const res = await this.apiFetchPlan(`loadpoints/${this.id}/plan`);
this.plan = res?.data.result ?? ({} as PlanWrapper);
this.nextPlanId = this.plan.planId;
} catch (e) {
console.error(e);
}
},
async fetchStaticPreviewSoc(plan: StaticSocPlan): Promise<PlanResponse | undefined> {
const timeISO = plan.time.toISOString();
return await this.apiFetchPlan(
`loadpoints/${this.id}/plan/static/preview/soc/${plan.soc}/${timeISO}`
);
},
async fetchRepeatingPreview(
plan: PartialBy<RepeatingPlan, "active">
): Promise<PlanResponse | undefined> {
return await this.apiFetchPlan(
`loadpoints/${this.id}/plan/repeating/preview/${plan.soc}/${plan.weekdays}/${plan.time}/${encodeURIComponent(plan.tz)}`
);
},
async fetchStaticPreviewEnergy(plan: StaticEnergyPlan): Promise<PlanResponse | undefined> {
const timeISO = plan.time.toISOString();
return await this.apiFetchPlan(
`loadpoints/${this.id}/plan/static/preview/energy/${plan.energy}/${timeISO}`
);
},
async apiFetchPlan(url: string): Promise<PlanResponse | undefined> {
try {
const res = (await api.get(url, {
validateStatus: (code) => [200, 404].includes(code),
})) as PlanResponse;
if (res.status === 404) {
return { data: { result: {} as PlanWrapper } } as PlanResponse;
}
return res;
} catch (e) {
console.error(e);
return;
}
},
async updatePreviewPlan(): Promise<void> {
// only show preview if no plan is active
if (!this.noActivePlan) return;
await this.updateTariff();
try {
let planRes: PlanResponse | undefined = undefined;
if (this.selectedPreviewId < 2 && this.staticPlanPreview) {
// static plan
let plan = this.staticPlanPreview;
if (this.socBasedPlanning) {
plan = plan as StaticSocPlan;
planRes = await this.fetchStaticPreviewSoc({
soc: plan.soc,
time: plan.time,
});
} else {
plan = plan as StaticEnergyPlan;
planRes = await this.fetchStaticPreviewEnergy({
energy: plan.energy,
time: plan.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 ?? ({} as PlanWrapper);
} catch (e) {
console.error(e);
}
},
async updateTariff(): Promise<void> {
// cache tariff for 5 minutes
if (
this.tariff?.lastUpdate &&
Date.now() - this.tariff.lastUpdate.getTime() <= TARIFF_CACHE_TIME
) {
return;
}
const res = await api.get(`tariff/planner`, allowClientError);
this.tariff = {
rates: res.status === 404 ? [] : convertRates(res.data.result.rates),
lastUpdate: new Date(),
};
},
async updatePlanPreviewDebounced(): Promise<void> {
if (!this.debounceTimer) {
await this.updatePreviewPlan();
return;
}
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(async () => await this.updatePreviewPlan(), 1000);
},
async updateActivePlanDebounced(): Promise<void> {
if (!this.debounceTimer) {
await this.updateActivePlan();
return;
}
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(async () => await this.updateActivePlan(), 1000);
},
removeStaticPlan(index: number): void {
this.$emit("static-plan-removed", index);
},
updateStaticPlan(plan: StaticPlan): void {
this.$emit("static-plan-updated", plan);
},
updateRepeatingPlans(plans: RepeatingPlan[]): void {
this.$emit("repeating-plans-updated", plans);
},
previewStaticPlan(plan: StaticPlan): void {
this.staticPlanPreview = plan;
this.updatePlanPreviewDebounced();
},
},
});
</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>