evcc-io/assets/js/components/ChargingPlans/Preview.vue
Maschga d07a0741ec
chore: use vite+ (#32297)
Co-authored-by: Michael Geers <michael@geers.tv>
2026-07-31 11:43:57 +02:00

224 lines
5.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="plan">
<div class="justify-content-between mb-2 d-flex justify-content-between">
<div class="text-start">
<div class="label">{{ $t("main.targetChargePlan.chargeDuration") }}</div>
<div
:class="`value d-sm-flex align-items-baseline ${
timeWarning ? 'text-warning' : 'text-primary'
}`"
>
<div>{{ planDuration }}</div>
<div v-if="fmtPower" class="extraValue text-nowrap ms-sm-1">
{{ fmtPower }}
</div>
</div>
</div>
<div v-if="hasTariff" class="text-end" data-testid="tariff-value">
<div class="label">
<span v-if="activeSlot">{{ activeSlotName }}</span>
<span v-else-if="isCo2">{{ $t("main.targetChargePlan.co2Label") }}</span>
<span v-else>{{ $t("main.targetChargePlan.priceLabel") }}</span>
</div>
<div class="value text-primary">
{{ fmtAvgValue }}
</div>
</div>
</div>
<TariffChart
class="mb-3"
:slots="slots"
:target-text="targetText"
:target-offset="targetOffset"
@slot-hovered="slotHovered"
/>
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import formatter from "@/mixins/formatter";
import minuteTicker from "@/mixins/minuteTicker";
import TariffChart from "../Tariff/TariffChart.vue";
import { SMART_COST_TYPE, type CURRENCY, type Rate, type Slot } from "@/types/evcc";
export default defineComponent({
name: "ChargingPlanPreview",
components: { TariffChart },
mixins: [formatter, minuteTicker],
props: {
duration: Number,
power: Number,
rates: Array as PropType<Rate[]>,
plan: Array as PropType<Rate[]>,
smartCostType: String as PropType<SMART_COST_TYPE>,
targetTime: [Date, null],
currency: String as PropType<CURRENCY>,
},
data() {
return {
activeIndex: null as number | null,
startTime: new Date(),
};
},
computed: {
endTime(): Date | null {
if (!this.plan?.length) {
return null;
}
const end = this.plan[this.plan.length - 1]?.end;
return end ? new Date(end) : null;
},
timeWarning(): boolean {
if (this.targetTime && this.endTime) {
return this.targetTime < this.endTime;
}
return false;
},
planDuration(): string {
return this.fmtDuration(this.duration);
},
fmtPower(): string | null {
if (this.duration && this.power && this.duration > 0 && this.power > 0) {
return `@ ${this.fmtW(this.power)}`;
}
return null;
},
isCo2(): boolean {
return this.smartCostType === SMART_COST_TYPE.CO2;
},
hasTariff(): boolean {
return (this.rates?.length || 0) > 1;
},
avgValue(): number | undefined {
let hourSum = 0;
let valueSum = 0;
this.convertDates(this.plan).forEach((slot) => {
const hours = (slot.end.getTime() - slot.start.getTime()) / 3600000;
if (slot.value) {
hourSum += hours;
valueSum += hours * slot.value;
}
});
return hourSum ? valueSum / hourSum : undefined;
},
fmtAvgValue(): string {
if (this.duration === 0) {
return "—";
}
const value = this.activeSlot ? this.activeSlot.value : this.avgValue;
if (value === undefined) {
return this.$t("main.targetChargePlan.unknownPrice");
}
return this.isCo2
? this.fmtCo2Medium(value)
: this.fmtPricePerKWh(value, this.currency);
},
activeSlot(): Slot | null {
return this.activeIndex !== null ? (this.slots[this.activeIndex] ?? null) : null;
},
activeSlotName(): string | null {
if (this.activeSlot) {
const { day, start, end } = this.activeSlot;
const range = `${this.fmtTimeString(start)}–${this.fmtTimeString(end)}`;
return this.$t("main.targetChargePlan.timeRange", { day, range });
}
return null;
},
targetOffset(): number | undefined {
if (!this.targetTime) return undefined;
const start = new Date(this.startTime);
start.setMinutes(start.getMinutes() - (start.getMinutes() % 15));
start.setSeconds(0);
start.setMilliseconds(0);
return (this.targetTime.getTime() - start.getTime()) / (60 * 60 * 1000);
},
targetText(): string | null {
if (!this.targetTime) {
return null;
}
return this.fmtWeekdayTime(this.targetTime);
},
slots(): Slot[] {
const rates = this.convertDates(this.rates);
const plan = this.convertDates(this.plan);
const quarterHour = 15 * 60 * 1000;
const base = new Date(this.startTime);
base.setSeconds(0, 0);
base.setMinutes(base.getMinutes() - (base.getMinutes() % 15));
return Array.from({ length: 96 * 4 }, (_, i) => {
const start = new Date(base.getTime() + quarterHour * i);
const end = new Date(start.getTime() + quarterHour);
const charging = !!this.findSlotInRange(start, end, plan);
const warning =
charging &&
this.targetTime &&
this.endTime &&
end > this.targetTime &&
this.targetTime < this.endTime;
return {
day: this.weekdayShort(start),
value: this.findSlotInRange(start, end, rates)?.value,
start,
end,
charging,
toLate: this.targetTime && this.targetTime <= start,
warning,
isTarget: this.targetTime && start <= this.targetTime && end > this.targetTime,
};
});
},
},
watch: {
rates(): void {
this.startTime = new Date();
},
everyMinute(): void {
this.startTime = new Date();
},
},
methods: {
convertDates(list: Rate[] | undefined): Rate[] {
if (!list?.length) {
return [];
}
return list.map((item) => {
return {
start: new Date(item.start),
end: new Date(item.end),
value: item.value,
};
});
},
findSlotInRange(start: Date, end: Date, slots: Rate[]): Rate | undefined {
return slots.find((s) => {
if (s.start.getTime() < start.getTime()) {
return s.end.getTime() > start.getTime();
}
return s.start.getTime() < end.getTime();
});
},
slotHovered(index: number): void {
this.activeIndex = index;
},
},
});
</script>
<style scoped>
.value {
font-size: 18px;
font-weight: bold;
}
.extraValue {
color: var(--evcc-gray);
font-size: 14px;
}
.label {
color: var(--evcc-gray);
text-transform: uppercase;
}
</style>