Sessions UI: visualization, month/year charts (#15902)
This commit is contained in:
parent
1bd521fc24
commit
cfa35f295b
38 changed files with 3271 additions and 715 deletions
|
|
@ -1,667 +0,0 @@
|
|||
<template>
|
||||
<div class="container px-4 safe-area-inset">
|
||||
<TopHeader :title="$t('sessions.title')" />
|
||||
<div class="row">
|
||||
<main class="col-12">
|
||||
<div
|
||||
class="d-flex align-items-baseline justify-content-between my-3 my-md-5 month-header"
|
||||
>
|
||||
<router-link
|
||||
class="d-flex text-decoration-none align-items-center"
|
||||
:class="{ 'pe-none': !hasPrev, 'text-muted': !hasPrev }"
|
||||
:to="{ query: { ...$route.query, ...prevYearMonth } }"
|
||||
>
|
||||
<shopicon-regular-angledoubleleftsmall
|
||||
size="s"
|
||||
class="me-1"
|
||||
></shopicon-regular-angledoubleleftsmall>
|
||||
<span class="d-none d-sm-block">{{ prevMonthName }}</span>
|
||||
<span class="d-block d-sm-none">{{ prevMonthNameShort }}</span>
|
||||
</router-link>
|
||||
<h2 class="text-center">{{ headline }}</h2>
|
||||
<router-link
|
||||
class="d-flex text-decoration-none align-items-center"
|
||||
:class="{ 'pe-none': !hasNext, 'text-muted': !hasNext }"
|
||||
:to="{ query: { ...$route.query, ...nextYearMonth } }"
|
||||
>
|
||||
<span class="d-none d-sm-block">{{ nextMonthName }}</span>
|
||||
<span class="d-block d-sm-none">{{ nextMonthNameShort }}</span>
|
||||
<shopicon-regular-angledoublerightsmall
|
||||
size="s"
|
||||
class="ms-1"
|
||||
></shopicon-regular-angledoublerightsmall>
|
||||
</router-link>
|
||||
</div>
|
||||
<div v-if="currentSessions.length === 0" data-testid="sessions-nodata" class="my-5">
|
||||
<p>{{ $t("sessions.noData") }}</p>
|
||||
</div>
|
||||
<div v-else class="my-3 my-md-5 table-outer">
|
||||
<table class="table text-nowrap">
|
||||
<thead class="sticky-top">
|
||||
<tr data-testid="sessions-head">
|
||||
<th scope="col" class="align-top ps-0">
|
||||
{{ $t("sessions.date") }}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="align-top d-none d-md-table-cell"
|
||||
data-testid="loadpoint"
|
||||
>
|
||||
{{ $t("sessions.loadpoint") }}
|
||||
<CustomSelect
|
||||
:selected="loadpointFilter"
|
||||
:options="loadpointFilterOptions"
|
||||
data-testid="filter-loadpoint"
|
||||
@change="changeLoadpointFilter"
|
||||
>
|
||||
<span
|
||||
class="fw-normal text-decoration-underline text-nowrap text-gray pe-none"
|
||||
>
|
||||
{{ loadpointFilter || $t("sessions.filter.filter") }}
|
||||
</span>
|
||||
</CustomSelect>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="align-top d-none d-md-table-cell"
|
||||
data-testid="vehicle"
|
||||
>
|
||||
{{ $t("sessions.vehicle") }}
|
||||
<CustomSelect
|
||||
:selected="vehicleFilter"
|
||||
:options="vehicleFilterOptions"
|
||||
data-testid="filter-vehicle"
|
||||
@change="changeVehicleFilter"
|
||||
>
|
||||
<span
|
||||
class="fw-normal text-decoration-underline text-nowrap text-gray pe-none"
|
||||
>
|
||||
{{ vehicleFilter || $t("sessions.filter.filter") }}
|
||||
</span>
|
||||
</CustomSelect>
|
||||
</th>
|
||||
<th scope="col" class="align-top d-md-none text-truncate">
|
||||
<div class="d-flex flex-wrap text-truncate">
|
||||
<div class="me-2 text-truncate">
|
||||
{{ $t("sessions.loadpoint") }}
|
||||
</div>
|
||||
<CustomSelect
|
||||
:selected="loadpointFilter"
|
||||
:options="loadpointFilterOptions"
|
||||
data-testid="filter-loadpoint"
|
||||
@change="changeLoadpointFilter"
|
||||
>
|
||||
<span
|
||||
class="fw-normal text-decoration-underline text-nowrap text-gray pe-none text-truncate"
|
||||
>
|
||||
{{
|
||||
loadpointFilter || $t("sessions.filter.filter")
|
||||
}}
|
||||
</span>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<div class="text-truncate d-flex flex-wrap">
|
||||
<div class="me-2 text-truncate">
|
||||
{{ $t("sessions.vehicle") }}
|
||||
</div>
|
||||
<CustomSelect
|
||||
:selected="vehicleFilter"
|
||||
:options="vehicleFilterOptions"
|
||||
data-testid="filter-vehicle"
|
||||
@change="changeVehicleFilter"
|
||||
>
|
||||
<span
|
||||
class="fw-normal text-decoration-underline text-nowrap text-gray pe-none text-truncate"
|
||||
>
|
||||
{{ vehicleFilter || $t("sessions.filter.filter") }}
|
||||
</span>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
v-for="(column, index) in columnsPerBreakpoint"
|
||||
:key="column.name"
|
||||
scope="col"
|
||||
:data-testid="`sessions-head-${column.name}`"
|
||||
class="align-top text-end"
|
||||
>
|
||||
<CustomSelect
|
||||
v-if="tooMuchColumns"
|
||||
:selected="column.name"
|
||||
:options="columnOptions"
|
||||
data-testid="column"
|
||||
@change="selectColumnPosition(index, $event.target.value)"
|
||||
>
|
||||
<span class="text-decoration-underline">
|
||||
{{ $t(`sessions.${column.name}`) }}
|
||||
</span>
|
||||
</CustomSelect>
|
||||
<span v-else>
|
||||
{{ $t(`sessions.${column.name}`) }}
|
||||
</span>
|
||||
<div class="text-gray fw-normal">{{ column.unit }}</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tfoot class="sticky-bottom">
|
||||
<tr data-testid="sessions-foot">
|
||||
<th scope="col" class="align-top ps-0">
|
||||
{{ $t("sessions.total") }}
|
||||
</th>
|
||||
<th scope="col" class="d-none d-md-table-cell"></th>
|
||||
<th scope="col" class="d-none d-md-table-cell"></th>
|
||||
<th scope="col" class="d-md-none"></th>
|
||||
<th
|
||||
v-for="column in columnsPerBreakpoint"
|
||||
:key="column.name"
|
||||
:data-testid="`sessions-foot-${column.name}`"
|
||||
scope="col"
|
||||
class="align-top text-end"
|
||||
>
|
||||
{{ column.format(column.total) }}
|
||||
</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(session, id) in filteredSessions"
|
||||
:key="id"
|
||||
role="button"
|
||||
data-testid="sessions-entry"
|
||||
@click="showDetails(session.id)"
|
||||
>
|
||||
<td class="ps-0">
|
||||
{{ fmtFullDateTime(new Date(session.created), true) }}
|
||||
</td>
|
||||
<td class="d-none d-md-table-cell">
|
||||
{{ session.loadpoint }}
|
||||
</td>
|
||||
<td class="d-none d-md-table-cell">
|
||||
{{ session.vehicle }}
|
||||
</td>
|
||||
<td class="d-md-none text-truncate">
|
||||
<div>{{ session.loadpoint }}</div>
|
||||
<div>{{ session.vehicle }}</div>
|
||||
</td>
|
||||
<td
|
||||
v-for="column in columnsPerBreakpoint"
|
||||
:key="column.name"
|
||||
class="text-end"
|
||||
>
|
||||
<span v-if="column.value(session) === null" class="text-gray">
|
||||
-
|
||||
</span>
|
||||
<span v-else>{{ column.format(column.value(session)) }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="d-grid gap-2 d-md-block mt-3 mb-5">
|
||||
<a
|
||||
v-if="currentSessions.length"
|
||||
class="btn btn-outline-secondary text-nowrap me-md-2"
|
||||
:href="csvLink"
|
||||
download
|
||||
>
|
||||
{{ $t("sessions.csvMonth", { month: headline }) }}
|
||||
</a>
|
||||
<a
|
||||
v-if="sessions.length"
|
||||
class="btn btn-outline-secondary text-nowrap"
|
||||
:href="csvTotalLink"
|
||||
download
|
||||
>
|
||||
{{ $t("sessions.csvTotal") }}
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
<ChargingSessionModal
|
||||
:session="selectedSession"
|
||||
:vehicles="vehicleList"
|
||||
:currency="currency"
|
||||
@session-changed="loadSessions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Modal from "bootstrap/js/dist/modal";
|
||||
import "@h2d2/shopicons/es/regular/angledoubleleftsmall";
|
||||
import "@h2d2/shopicons/es/regular/angledoublerightsmall";
|
||||
import formatter, { POWER_UNIT } from "../mixins/formatter";
|
||||
import api from "../api";
|
||||
import store from "../store";
|
||||
import CustomSelect from "../components/CustomSelect.vue";
|
||||
import ChargingSessionModal from "../components/ChargingSessionModal.vue";
|
||||
import breakpoint from "../mixins/breakpoint";
|
||||
import settings from "../settings";
|
||||
import TopHeader from "../components/TopHeader.vue";
|
||||
|
||||
const COLUMNS_PER_BREAKPOINT = {
|
||||
xs: 1,
|
||||
sm: 2,
|
||||
md: 3,
|
||||
lg: 4,
|
||||
xl: 5,
|
||||
xxl: 6,
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "ChargingSessions",
|
||||
components: { ChargingSessionModal, CustomSelect, TopHeader },
|
||||
mixins: [formatter, breakpoint],
|
||||
props: {
|
||||
notifications: Array,
|
||||
month: { type: Number, default: () => new Date().getMonth() + 1 },
|
||||
year: { type: Number, default: () => new Date().getFullYear() },
|
||||
loadpointFilter: { type: String, default: "" },
|
||||
vehicleFilter: { type: String, default: "" },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
sessions: [],
|
||||
selectedSessionId: undefined,
|
||||
selectedColumns: settings.sessionColumns,
|
||||
};
|
||||
},
|
||||
head() {
|
||||
return { title: `${this.$t("sessions.title")} | evcc` };
|
||||
},
|
||||
computed: {
|
||||
topNavigation: function () {
|
||||
const vehicleLogins = store.state.auth ? store.state.auth.vehicles : {};
|
||||
return { vehicleLogins, ...this.collectProps(TopNavigation, store.state) };
|
||||
},
|
||||
currentSessions() {
|
||||
const sessionsWithDefaults = this.sessions.map((session) => {
|
||||
const loadpoint = session.loadpoint || this.$t("main.loadpoint.fallbackName");
|
||||
const vehicle = session.vehicle || this.$t("main.vehicle.unknown");
|
||||
return { ...session, loadpoint, vehicle };
|
||||
});
|
||||
|
||||
return sessionsWithDefaults.filter((session) => {
|
||||
const date = new Date(session.created);
|
||||
return date.getFullYear() === this.year && date.getMonth() + 1 === this.month;
|
||||
});
|
||||
},
|
||||
filteredSessions() {
|
||||
return this.currentSessions.filter(this.filterByLoadpoint).filter(this.filterByVehicle);
|
||||
},
|
||||
maxColumns() {
|
||||
return COLUMNS_PER_BREAKPOINT[this.breakpoint] || 1;
|
||||
},
|
||||
columns() {
|
||||
const columns = [
|
||||
{
|
||||
name: "energy",
|
||||
unit: "kWh",
|
||||
total: this.chargedEnergy,
|
||||
value: (session) => session.chargedEnergy,
|
||||
format: (value) => this.fmtWh(value * 1e3, POWER_UNIT.KW, false),
|
||||
},
|
||||
{
|
||||
name: "solar",
|
||||
unit: "%",
|
||||
total: this.solarPercentage,
|
||||
value: (session) => session.solarPercentage,
|
||||
format: (value) => this.fmtNumber(value, 1),
|
||||
},
|
||||
{
|
||||
name: "price",
|
||||
unit: this.fmtCurrencySymbol(this.currency),
|
||||
total: this.price,
|
||||
value: (session) => session.price,
|
||||
format: (value) => this.fmtMoney(value, this.currency),
|
||||
},
|
||||
{
|
||||
name: "avgPrice",
|
||||
unit: this.pricePerKWhUnit(this.currency),
|
||||
total: this.pricePerKWh,
|
||||
value: (session) => session.pricePerKWh,
|
||||
format: (value) => this.fmtPricePerKWh(value, this.currency, false, false),
|
||||
},
|
||||
{
|
||||
name: "co2",
|
||||
unit: "g/kWh",
|
||||
total: this.co2PerKWh,
|
||||
value: (session) => session.co2PerKWh,
|
||||
format: (value) => this.fmtNumber(value, 0),
|
||||
},
|
||||
{
|
||||
name: "chargeDuration",
|
||||
unit: "h:mm",
|
||||
total: this.chargeDuration,
|
||||
value: (session) => session.chargeDuration,
|
||||
format: (value) => this.fmtDurationNs(value, false, "h"),
|
||||
},
|
||||
{
|
||||
name: "avgPower",
|
||||
unit: "kW",
|
||||
total: this.avgPower,
|
||||
value: (session) => {
|
||||
if (session.chargedEnergy && session.chargeDuration) {
|
||||
return session.chargedEnergy / this.nsToHours(session.chargeDuration);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
format: (value) =>
|
||||
value ? this.fmtW(value * 1e3, POWER_UNIT.KW, false, 1) : undefined,
|
||||
},
|
||||
];
|
||||
// only columns with values are shown
|
||||
return columns.filter((column) => {
|
||||
if (column.name === "energy") return true;
|
||||
return this.currentSessions.some((s) => column.value(s));
|
||||
});
|
||||
},
|
||||
tooMuchColumns() {
|
||||
return this.columns.length > this.maxColumns;
|
||||
},
|
||||
sortedColumns() {
|
||||
const columns = [...this.columns];
|
||||
let sorted = [];
|
||||
for (let name of this.selectedColumns) {
|
||||
if (!name && columns.length) {
|
||||
sorted.push(columns.shift());
|
||||
} else if (columns.some((c) => c.name === name)) {
|
||||
const column = columns.find((c) => c.name === name);
|
||||
sorted.push(column);
|
||||
let index = columns.indexOf(column);
|
||||
columns.splice(index, 1);
|
||||
}
|
||||
}
|
||||
return sorted.concat(columns);
|
||||
},
|
||||
columnsPerBreakpoint() {
|
||||
return this.sortedColumns.slice(0, this.maxColumns);
|
||||
},
|
||||
columnOptions() {
|
||||
return this.columns.map((column) => {
|
||||
return {
|
||||
name: this.$t(`sessions.${column.name}`),
|
||||
value: column.name,
|
||||
disabled: this.columnsPerBreakpoint.find((c) => c.name === column.name),
|
||||
};
|
||||
});
|
||||
},
|
||||
vehicleFilterOptions() {
|
||||
const options = [
|
||||
{
|
||||
name: this.$t("sessions.filter.allVehicles"),
|
||||
value: "",
|
||||
count: this.filterCountForVehicle(),
|
||||
},
|
||||
];
|
||||
this.vehicles.forEach((name) => {
|
||||
const count = this.filterCountForVehicle(name);
|
||||
options.push({ name, value: name, count });
|
||||
});
|
||||
return options;
|
||||
},
|
||||
loadpointFilterOptions() {
|
||||
const options = [
|
||||
{
|
||||
name: this.$t("sessions.filter.allLoadpoints"),
|
||||
value: "",
|
||||
count: this.filterCountForLoadpoint(),
|
||||
},
|
||||
];
|
||||
this.loadpoints.forEach((name) => {
|
||||
const count = this.filterCountForLoadpoint(name);
|
||||
options.push({ name, value: name, count });
|
||||
});
|
||||
return options;
|
||||
},
|
||||
chargedEnergy() {
|
||||
return this.filteredSessions.reduce((total, s) => total + s.chargedEnergy, 0);
|
||||
},
|
||||
chargeDuration() {
|
||||
return this.filteredSessions.reduce((total, s) => total + s.chargeDuration, 0);
|
||||
},
|
||||
price() {
|
||||
return this.filteredSessions.reduce((total, s) => total + s.price, 0);
|
||||
},
|
||||
avgPower() {
|
||||
const { energy, hours } = this.filteredSessions
|
||||
.filter((s) => s.chargedEnergy && s.chargeDuration)
|
||||
.reduce(
|
||||
(total, s) => {
|
||||
total.energy += s.chargedEnergy;
|
||||
total.hours += this.nsToHours(s.chargeDuration);
|
||||
return total;
|
||||
},
|
||||
{ energy: 0, hours: 0 }
|
||||
);
|
||||
if (energy && hours) {
|
||||
return energy / hours;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
pricePerKWh() {
|
||||
const total = this.filteredSessions
|
||||
.filter((s) => s.price !== null)
|
||||
.reduce(
|
||||
(total, s) => ({
|
||||
price: total.price + s.price,
|
||||
chargedEnergy: total.chargedEnergy + s.chargedEnergy,
|
||||
}),
|
||||
{ price: 0, chargedEnergy: 0 }
|
||||
);
|
||||
return total.price / total.chargedEnergy;
|
||||
},
|
||||
co2PerKWh() {
|
||||
const total = this.filteredSessions
|
||||
.filter((s) => s.co2PerKWh !== null)
|
||||
.reduce(
|
||||
(total, s) => ({
|
||||
emittedCo2: total.emittedCo2 + s.chargedEnergy * s.co2PerKWh,
|
||||
chargedEnergy: total.chargedEnergy + s.chargedEnergy,
|
||||
}),
|
||||
{ emittedCo2: 0, chargedEnergy: 0 }
|
||||
);
|
||||
if (total.chargedEnergy && total.emittedCo2) {
|
||||
return total.emittedCo2 / total.chargedEnergy;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
solarPercentage() {
|
||||
const total = this.filteredSessions
|
||||
.filter((s) => s.solarPercentage !== null)
|
||||
.reduce(
|
||||
(total, s) => ({
|
||||
chargedSolarEnergy:
|
||||
total.chargedSolarEnergy + s.chargedEnergy * (s.solarPercentage / 100),
|
||||
chargedEnergy: total.chargedEnergy + s.chargedEnergy,
|
||||
}),
|
||||
{ chargedSolarEnergy: 0, chargedEnergy: 0 }
|
||||
);
|
||||
|
||||
return (100 / total.chargedEnergy) * total.chargedSolarEnergy;
|
||||
},
|
||||
loadpoints() {
|
||||
return [...new Set(this.currentSessions.map((s) => s.loadpoint))];
|
||||
},
|
||||
vehicles() {
|
||||
return [...new Set(this.currentSessions.map((s) => s.vehicle))];
|
||||
},
|
||||
vehicleList() {
|
||||
const vehicles = store.state.vehicles || {};
|
||||
return Object.entries(vehicles).map(([name, vehicle]) => ({ name, ...vehicle }));
|
||||
},
|
||||
selectedSession() {
|
||||
return this.sessions.find((s) => s.id == this.selectedSessionId);
|
||||
},
|
||||
currency() {
|
||||
return store.state.currency;
|
||||
},
|
||||
headline() {
|
||||
const date = new Date();
|
||||
date.setMonth(this.month - 1, 1);
|
||||
date.setFullYear(this.year);
|
||||
return this.fmtMonthYear(date);
|
||||
},
|
||||
csvLink() {
|
||||
return this.csvHrefLink(this.year, this.month);
|
||||
},
|
||||
csvTotalLink() {
|
||||
return this.csvHrefLink();
|
||||
},
|
||||
prevDate() {
|
||||
const date = new Date();
|
||||
date.setFullYear(this.year);
|
||||
date.setMonth(this.month - 2, 1);
|
||||
return date;
|
||||
},
|
||||
prevYearMonth() {
|
||||
return { year: this.prevDate.getFullYear(), month: this.prevDate.getMonth() + 1 };
|
||||
},
|
||||
prevMonthName() {
|
||||
return this.fmtMonth(this.prevDate);
|
||||
},
|
||||
prevMonthNameShort() {
|
||||
return this.fmtMonth(this.prevDate, true);
|
||||
},
|
||||
nextDate() {
|
||||
const date = new Date();
|
||||
date.setFullYear(this.year);
|
||||
date.setMonth(this.month, 1);
|
||||
return date;
|
||||
},
|
||||
nextYearMonth() {
|
||||
return { year: this.nextDate.getFullYear(), month: this.nextDate.getMonth() + 1 };
|
||||
},
|
||||
nextMonthName() {
|
||||
return this.fmtMonth(this.nextDate);
|
||||
},
|
||||
nextMonthNameShort() {
|
||||
return this.fmtMonth(this.nextDate, true);
|
||||
},
|
||||
hasNext() {
|
||||
const now = new Date();
|
||||
return this.year < now.getFullYear() || this.month < now.getMonth() + 1;
|
||||
},
|
||||
hasPrev() {
|
||||
const length = this.sessions.length;
|
||||
if (length === 0) {
|
||||
return false;
|
||||
}
|
||||
const first = new Date(this.sessions[length - 1].created);
|
||||
return this.year > first.getFullYear() || this.month > first.getMonth() + 1;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.loadSessions();
|
||||
},
|
||||
methods: {
|
||||
nsToHours(ns) {
|
||||
return ns / 1e9 / 3600;
|
||||
},
|
||||
filterByLoadpoint(session) {
|
||||
return !this.loadpointFilter || session.loadpoint === this.loadpointFilter;
|
||||
},
|
||||
filterByVehicle(session) {
|
||||
return !this.vehicleFilter || session.vehicle === this.vehicleFilter;
|
||||
},
|
||||
filterCountForVehicle(vehicle) {
|
||||
return this.currentSessions
|
||||
.filter(this.filterByLoadpoint)
|
||||
.filter((s) => !vehicle || s.vehicle === vehicle).length;
|
||||
},
|
||||
filterCountForLoadpoint(loadpoint) {
|
||||
return this.currentSessions
|
||||
.filter(this.filterByVehicle)
|
||||
.filter((s) => !loadpoint || s.loadpoint === loadpoint).length;
|
||||
},
|
||||
selectColumnPosition(index, value) {
|
||||
this.selectedColumns[index] = value;
|
||||
settings.sessionColumns = [...this.selectedColumns];
|
||||
},
|
||||
changeLoadpointFilter(event) {
|
||||
const loadpoint = event.target.value || undefined;
|
||||
this.$router.push({ query: { ...this.$route.query, loadpoint } });
|
||||
},
|
||||
changeVehicleFilter(event) {
|
||||
const vehicle = event.target.value || undefined;
|
||||
this.$router.push({ query: { ...this.$route.query, vehicle } });
|
||||
},
|
||||
async loadSessions() {
|
||||
const response = await api.get("sessions");
|
||||
this.sessions = response.data?.result;
|
||||
},
|
||||
showDetails(sessionId) {
|
||||
this.selectedSessionId = sessionId;
|
||||
const modal = Modal.getOrCreateInstance(document.getElementById("sessionDetailsModal"));
|
||||
modal.show();
|
||||
},
|
||||
csvHrefLink(year, month) {
|
||||
const params = new URLSearchParams({
|
||||
format: "csv",
|
||||
lang: this.$i18n?.locale,
|
||||
});
|
||||
if (year && month) {
|
||||
params.append("year", year);
|
||||
params.append("month", month);
|
||||
}
|
||||
return `./api/sessions?${params.toString()}`;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style scoped>
|
||||
.table {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
}
|
||||
.table thead,
|
||||
.table tfoot {
|
||||
background: var(--evcc-background);
|
||||
}
|
||||
.table tfoot th {
|
||||
border-top-width: 2px;
|
||||
}
|
||||
.table thead th {
|
||||
border-bottom-width: 2px;
|
||||
}
|
||||
.table tbody tr:last-child td {
|
||||
border-bottom-width: 0;
|
||||
}
|
||||
|
||||
.sticky-top,
|
||||
.sticky-bottom {
|
||||
z-index: 1;
|
||||
}
|
||||
.sticky-top th {
|
||||
padding-top: max(0.5rem, env(safe-area-inset-top));
|
||||
}
|
||||
.table-outer {
|
||||
position: relative;
|
||||
top: calc(max(0.5rem, env(safe-area-inset-top)) * -1);
|
||||
}
|
||||
.month-header {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
.sticky-bottom th {
|
||||
padding-bottom: max(0.5rem, env(safe-area-inset-bottom));
|
||||
border-bottom: none;
|
||||
}
|
||||
@media (max-width: 576px) {
|
||||
.table td,
|
||||
.table th {
|
||||
width: 50%;
|
||||
}
|
||||
.table td:first-child,
|
||||
.table th:first-child,
|
||||
.table td:last-child,
|
||||
.table th:last-child {
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
.table td.text-truncate,
|
||||
.table th.text-truncate {
|
||||
max-width: 1px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
708
assets/js/views/Sessions.vue
Normal file
708
assets/js/views/Sessions.vue
Normal file
|
|
@ -0,0 +1,708 @@
|
|||
<template>
|
||||
<div class="container px-4 safe-area-inset">
|
||||
<TopHeader :title="$t('sessions.title')" />
|
||||
<div class="row">
|
||||
<main class="col-12">
|
||||
<div class="header-outer sticky-top">
|
||||
<div class="container px-4">
|
||||
<div
|
||||
class="row py-3 py-sm-3 d-flex flex-column flex-sm-row gap-3 gap-lg-0 mb-lg-2"
|
||||
>
|
||||
<div class="col-lg-5 d-flex mb-lg-0">
|
||||
<PeriodSelector
|
||||
:period="period"
|
||||
:periodOptions="periodOptions"
|
||||
@update:period="changePeriod"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="showDateNavigator" class="col-lg-6 offset-lg-1">
|
||||
<DateNavigator
|
||||
:month="month"
|
||||
:year="year"
|
||||
:startDate="startDate"
|
||||
:showMonth="showMonthNavigation"
|
||||
:showYear="showYearNavigation"
|
||||
@update-date="updateDate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-3 mb-5 justify-content-between flex-wrap pt-1">
|
||||
<IconSelectGroup>
|
||||
<template v-for="largeScreen in [true, false]">
|
||||
<IconSelectItem
|
||||
v-for="{ value, label, disabled, active } in typeOptions"
|
||||
:key="value + largeScreen"
|
||||
:label="largeScreen ? label : undefined"
|
||||
:class="{
|
||||
'd-none d-lg-block': largeScreen,
|
||||
'd-block d-lg-none': !largeScreen,
|
||||
}"
|
||||
:disabled="disabled"
|
||||
:active="active"
|
||||
@click="updateType(value)"
|
||||
>
|
||||
<component :is="typeIcons[value]"></component>
|
||||
</IconSelectItem>
|
||||
</template>
|
||||
</IconSelectGroup>
|
||||
<IconSelectGroup>
|
||||
<template v-for="largeScreen in [true, false]">
|
||||
<IconSelectItem
|
||||
v-for="group in Object.values(groups)"
|
||||
:key="group + largeScreen"
|
||||
:active="selectedGroup === group"
|
||||
:label="
|
||||
largeScreen
|
||||
? $t(`sessions.groupBy.${group.toLowerCase()}`)
|
||||
: undefined
|
||||
"
|
||||
:class="{
|
||||
'd-none d-lg-block': largeScreen,
|
||||
'd-block d-lg-none': !largeScreen,
|
||||
}"
|
||||
@click="updateGroup(group)"
|
||||
>
|
||||
<component :is="groupIcons[group]"></component>
|
||||
</IconSelectItem>
|
||||
</template>
|
||||
</IconSelectGroup>
|
||||
</div>
|
||||
|
||||
<h3
|
||||
class="fw-normal my-0 d-flex gap-2 flex-wrap d-flex align-items-baseline overflow-hidden justify-content-between"
|
||||
>
|
||||
<span class="d-block no-wrap text-truncate">{{ historyTitle }}</span>
|
||||
<small class="d-block no-wrap text-truncate">{{ historySubTitle }}</small>
|
||||
</h3>
|
||||
<EnergyHistoryChart
|
||||
v-if="activeType === types.SOLAR"
|
||||
class="mb-5"
|
||||
:sessions="currentSessions"
|
||||
:color-mappings="colorMappings"
|
||||
:group-by="selectedGroup"
|
||||
:period="period"
|
||||
/>
|
||||
<CostHistoryChart
|
||||
v-else
|
||||
class="mb-5"
|
||||
:sessions="currentTypeSessions"
|
||||
:color-mappings="colorMappings"
|
||||
:group-by="selectedGroup"
|
||||
:cost-type="activeType"
|
||||
:period="period"
|
||||
:suggested-max-cost="suggestedMaxCost"
|
||||
/>
|
||||
<div v-if="showExtraCharts" class="row align-items-start">
|
||||
<div class="col-12 col-lg-6 mb-5">
|
||||
<h3 class="fw-normal my-4">{{ firstExtraTitle }}</h3>
|
||||
<div v-if="activeType === types.SOLAR">
|
||||
<SolarYearChart
|
||||
v-if="showSolarYearChart"
|
||||
:period="period"
|
||||
:sessions="currentSessions"
|
||||
/>
|
||||
<SolarGroupedChart
|
||||
v-else
|
||||
:sessions="currentSessions"
|
||||
:color-mappings="colorMappings"
|
||||
:group-by="selectedGroup"
|
||||
/>
|
||||
</div>
|
||||
<AvgCostGroupedChart
|
||||
v-else
|
||||
:sessions="currentTypeSessions"
|
||||
:color-mappings="colorMappings"
|
||||
:suggested-max-price="suggestedMaxCost"
|
||||
:group-by="selectedGroup"
|
||||
:cost-type="activeType"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6 mb-5">
|
||||
<h3 class="fw-normal my-4">{{ secondExtraTitle }}</h3>
|
||||
<EnergyGroupedChart
|
||||
v-if="activeType === types.SOLAR"
|
||||
:sessions="currentSessions"
|
||||
:color-mappings="colorMappings"
|
||||
:group-by="selectedGroup"
|
||||
/>
|
||||
<CostGroupedChart
|
||||
v-else
|
||||
:sessions="currentTypeSessions"
|
||||
:color-mappings="colorMappings"
|
||||
:group-by="selectedGroup"
|
||||
:cost-type="activeType"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showTable">
|
||||
<SessionTable
|
||||
:sessions="currentSessions"
|
||||
:vehicleFilter="vehicleFilter"
|
||||
:loadpointFilter="loadpointFilter"
|
||||
:currency="currency"
|
||||
@show-session="showDetails"
|
||||
/>
|
||||
<div class="d-grid gap-2 d-md-block mt-3 mb-5">
|
||||
<a
|
||||
v-if="currentSessions.length"
|
||||
class="btn btn-sm btn-outline-secondary text-nowrap me-md-2"
|
||||
:href="csvLink"
|
||||
download
|
||||
>
|
||||
{{ $t("sessions.csvMonth", { month: headline }) }}
|
||||
</a>
|
||||
<a
|
||||
v-if="sessions.length"
|
||||
class="btn btn-sm btn-outline-secondary text-nowrap"
|
||||
:href="csvTotalLink"
|
||||
download
|
||||
>
|
||||
{{ $t("sessions.csvTotal") }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
class="btn btn-link text-muted mb-5"
|
||||
@click="changePeriod(periods.MONTH)"
|
||||
>
|
||||
{{ $t("sessions.showIndividualEntries") }}
|
||||
</button>
|
||||
</main>
|
||||
<SessionDetailsModal
|
||||
:session="selectedSession"
|
||||
:vehicles="vehicleList"
|
||||
:currency="currency"
|
||||
@session-changed="loadSessions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Modal from "bootstrap/js/dist/modal";
|
||||
import "@h2d2/shopicons/es/regular/cablecharge";
|
||||
import "@h2d2/shopicons/es/regular/car3";
|
||||
import "@h2d2/shopicons/es/regular/sun";
|
||||
import formatter, { POWER_UNIT } from "../mixins/formatter";
|
||||
import api from "../api";
|
||||
import store from "../store";
|
||||
import SessionDetailsModal from "../components/Sessions/SessionDetailsModal.vue";
|
||||
import SessionTable from "../components/Sessions/SessionTable.vue";
|
||||
import EnergyHistoryChart from "../components/Sessions/EnergyHistoryChart.vue";
|
||||
import EnergyGroupedChart from "../components/Sessions/EnergyGroupedChart.vue";
|
||||
import SolarGroupedChart from "../components/Sessions/SolarGroupedChart.vue";
|
||||
import SolarYearChart from "../components/Sessions/SolarYearChart.vue";
|
||||
import CostHistoryChart from "../components/Sessions/CostHistoryChart.vue";
|
||||
import CostGroupedChart from "../components/Sessions/CostGroupedChart.vue";
|
||||
import AvgCostGroupedChart from "../components/Sessions/AvgCostGroupedChart.vue";
|
||||
import TopHeader from "../components/TopHeader.vue";
|
||||
import IconSelectGroup from "../components/IconSelectGroup.vue";
|
||||
import IconSelectItem from "../components/IconSelectItem.vue";
|
||||
import SelectGroup from "../components/SelectGroup.vue";
|
||||
import CustomSelect from "../components/CustomSelect.vue";
|
||||
import colors from "../colors";
|
||||
import settings from "../settings";
|
||||
import PeriodSelector from "../components/Sessions/PeriodSelector.vue";
|
||||
import DateNavigator from "../components/Sessions/DateNavigator.vue";
|
||||
import DynamicPriceIcon from "../components/MaterialIcon/DynamicPrice.vue";
|
||||
import TotalIcon from "../components/MaterialIcon/Total.vue";
|
||||
import { TYPES, GROUPS, PERIODS } from "../components/Sessions/types";
|
||||
export default {
|
||||
name: "Sessions",
|
||||
components: {
|
||||
SessionDetailsModal,
|
||||
SessionTable,
|
||||
TopHeader,
|
||||
EnergyHistoryChart,
|
||||
EnergyGroupedChart,
|
||||
IconSelectGroup,
|
||||
IconSelectItem,
|
||||
SelectGroup,
|
||||
CustomSelect,
|
||||
SolarGroupedChart,
|
||||
SolarYearChart,
|
||||
PeriodSelector,
|
||||
DateNavigator,
|
||||
DynamicPriceIcon,
|
||||
CostHistoryChart,
|
||||
CostGroupedChart,
|
||||
AvgCostGroupedChart,
|
||||
},
|
||||
mixins: [formatter],
|
||||
props: {
|
||||
notifications: Array,
|
||||
month: { type: Number, default: () => new Date().getMonth() + 1 },
|
||||
year: { type: Number, default: () => new Date().getFullYear() },
|
||||
period: { type: String, default: PERIODS.MONTH },
|
||||
loadpointFilter: { type: String, default: "" },
|
||||
vehicleFilter: { type: String, default: "" },
|
||||
offline: Boolean,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
sessions: [],
|
||||
selectedType: settings.sessionsType || TYPES.SOLAR,
|
||||
selectedGroup: settings.sessionsGroup || GROUPS.NONE,
|
||||
selectedSessionId: undefined,
|
||||
periods: PERIODS,
|
||||
types: TYPES,
|
||||
groups: GROUPS,
|
||||
};
|
||||
},
|
||||
head() {
|
||||
return { title: `${this.$t("sessions.title")} | evcc` };
|
||||
},
|
||||
computed: {
|
||||
currency() {
|
||||
return store.state.currency || "EUR";
|
||||
},
|
||||
energyTitle() {
|
||||
return this.$t("sessions.energyTitle", { percent: this.solarPercentageFmt });
|
||||
},
|
||||
historyTitle() {
|
||||
if (this.activeType === TYPES.SOLAR) {
|
||||
return this.energyTitle;
|
||||
}
|
||||
return this.costTitle;
|
||||
},
|
||||
historySubTitle() {
|
||||
if (this.activeType === TYPES.SOLAR) {
|
||||
return this.energySubTitle;
|
||||
}
|
||||
return this.costSubTitle;
|
||||
},
|
||||
firstExtraTitle() {
|
||||
if (this.activeType === TYPES.SOLAR) {
|
||||
return this.solarTitle;
|
||||
}
|
||||
return this.avgCostTitle;
|
||||
},
|
||||
secondExtraTitle() {
|
||||
if (this.activeType === TYPES.SOLAR) {
|
||||
return this.energyGroupedTitle;
|
||||
}
|
||||
return this.costGroupedTitle;
|
||||
},
|
||||
solarPercentageFmt() {
|
||||
return this.fmtPercentage(
|
||||
this.totalEnergy > 0 ? (100 / this.totalEnergy) * this.selfEnergy : 0
|
||||
);
|
||||
},
|
||||
energySumFmt() {
|
||||
return this.fmtWh(this.totalEnergy * 1e3, POWER_UNIT.AUTO);
|
||||
},
|
||||
energySubTitle() {
|
||||
return this.$t("sessions.energySubTitle", { energy: this.energySumFmt });
|
||||
},
|
||||
solarTitle() {
|
||||
return this.selectedGroup === GROUPS.NONE
|
||||
? this.$t("sessions.solarTitle")
|
||||
: this.$t("sessions.solarTitleByGroup", { byGroup: this.byGroupTitle });
|
||||
},
|
||||
byGroupTitle() {
|
||||
if (this.selectedGroup === GROUPS.LOADPOINT) {
|
||||
return this.$t("sessions.byGroupLoadpoint");
|
||||
} else if (this.selectedGroup === GROUPS.VEHICLE) {
|
||||
return this.$t("sessions.byGroupVehicle");
|
||||
}
|
||||
return "";
|
||||
},
|
||||
energyGroupedTitle() {
|
||||
if (this.selectedGroup === GROUPS.NONE) {
|
||||
return this.$t("sessions.energyGroupedTitle", { energy: this.energySumFmt });
|
||||
}
|
||||
return this.$t("sessions.energyGroupedTitleByGroup", { byGroup: this.byGroupTitle });
|
||||
},
|
||||
avgCostTitle() {
|
||||
const type = this.activeType === TYPES.PRICE ? "Price" : "Co2";
|
||||
return this.$t(`sessions.avg${type}TitleByGroup`, { byGroup: this.byGroupTitle });
|
||||
},
|
||||
costGroupedTitle() {
|
||||
const type = this.activeType === TYPES.PRICE ? "Price" : "Co2";
|
||||
return this.$t(`sessions.grouped${type}TitleByGroup`, { byGroup: this.byGroupTitle });
|
||||
},
|
||||
periodOptions() {
|
||||
return Object.entries(PERIODS).map(([key, value]) => ({
|
||||
name: this.$t(`sessions.period.${key.toLowerCase()}`),
|
||||
value,
|
||||
}));
|
||||
},
|
||||
typeOptions() {
|
||||
const options = Object.values(TYPES).map((value) => {
|
||||
const disabled =
|
||||
(value === TYPES.PRICE && !this.typePriceAvailable) ||
|
||||
(value === TYPES.CO2 && !this.typeCo2Available);
|
||||
const active = this.activeType === value;
|
||||
const label = this.$t(`sessions.type.${value}`);
|
||||
return { label, value, disabled, active };
|
||||
});
|
||||
return options;
|
||||
},
|
||||
totalEnergy() {
|
||||
return this.currentSessions.reduce((acc, session) => acc + session.chargedEnergy, 0);
|
||||
},
|
||||
selfEnergy() {
|
||||
return this.currentSessions.reduce(
|
||||
(acc, session) => acc + (session.chargedEnergy / 100) * session.solarPercentage,
|
||||
0
|
||||
);
|
||||
},
|
||||
currentSessionsWithPrice() {
|
||||
return this.currentSessions.filter((s) => s.price !== null);
|
||||
},
|
||||
currentTypeSessions() {
|
||||
if (this.activeType === TYPES.PRICE) {
|
||||
return this.currentSessionsWithPrice;
|
||||
} else if (this.activeType === TYPES.CO2) {
|
||||
return this.currentSessionsWithCo2;
|
||||
} else {
|
||||
return this.currentSessions;
|
||||
}
|
||||
},
|
||||
totalPrice() {
|
||||
return this.currentSessionsWithPrice.reduce((acc, s) => acc + s.price, 0);
|
||||
},
|
||||
pricePerKWh() {
|
||||
const list = this.currentSessionsWithPrice;
|
||||
const energy = list.reduce((acc, s) => acc + s.chargedEnergy, 0);
|
||||
return energy ? this.totalPrice / energy : null;
|
||||
},
|
||||
currentSessionsWithCo2() {
|
||||
return this.currentSessions.filter((s) => s.co2PerKWh !== null);
|
||||
},
|
||||
totalCo2() {
|
||||
const list = this.currentSessionsWithCo2;
|
||||
return list.reduce((acc, s) => acc + s.co2PerKWh * s.chargedEnergy, 0);
|
||||
},
|
||||
co2PerKWh() {
|
||||
const list = this.currentSessionsWithCo2;
|
||||
const energy = list.reduce((acc, s) => acc + s.chargedEnergy, 0);
|
||||
return energy ? this.totalCo2 / energy : null;
|
||||
},
|
||||
costTitle() {
|
||||
const value =
|
||||
this.activeType === TYPES.PRICE
|
||||
? this.fmtPricePerKWh(this.pricePerKWh, this.currency)
|
||||
: this.fmtCo2Medium(this.co2PerKWh);
|
||||
const type = this.activeType === TYPES.PRICE ? "Price" : "Co2";
|
||||
return this.$t(`sessions.history${type}Title`, { value });
|
||||
},
|
||||
costSubTitle() {
|
||||
const type = this.activeType === TYPES.PRICE ? "Price" : "Co2";
|
||||
const value =
|
||||
this.activeType === TYPES.PRICE
|
||||
? this.fmtMoney(this.totalPrice, this.currency, true, true)
|
||||
: this.fmtGrams(this.totalCo2);
|
||||
return this.$t(`sessions.history${type}SubTitle`, { value });
|
||||
},
|
||||
activeType() {
|
||||
if (this.selectedType === TYPES.PRICE && this.typePriceAvailable) {
|
||||
return TYPES.PRICE;
|
||||
} else if (this.selectedType === TYPES.CO2 && this.typeCo2Available) {
|
||||
return TYPES.CO2;
|
||||
}
|
||||
return TYPES.SOLAR;
|
||||
},
|
||||
showCostCharts() {
|
||||
return this.typePriceAvailable || this.typeCo2Available;
|
||||
},
|
||||
typePriceAvailable() {
|
||||
return this.currentSessionsWithPrice.length > 0;
|
||||
},
|
||||
typeCo2Available() {
|
||||
return this.currentSessionsWithCo2.length > 0;
|
||||
},
|
||||
startDate() {
|
||||
return new Date(this.sessions[0]?.created || Date.now());
|
||||
},
|
||||
topNavigation: function () {
|
||||
const vehicleLogins = store.state.auth ? store.state.auth.vehicles : {};
|
||||
return { vehicleLogins, ...this.collectProps(TopNavigation, store.state) };
|
||||
},
|
||||
sessionsWithDefaults() {
|
||||
return this.sessions.map((session) => {
|
||||
const loadpoint = session.loadpoint || this.$t("main.loadpoint.fallbackName");
|
||||
const vehicle = session.vehicle || this.$t("main.vehicle.unknown");
|
||||
return { ...session, loadpoint, vehicle };
|
||||
});
|
||||
},
|
||||
currentSessions() {
|
||||
return this.sessionsWithDefaults.filter((session) => {
|
||||
const date = new Date(session.created);
|
||||
switch (this.period) {
|
||||
case PERIODS.MONTH:
|
||||
return (
|
||||
date.getFullYear() === this.year && date.getMonth() + 1 === this.month
|
||||
);
|
||||
case PERIODS.YEAR:
|
||||
return date.getFullYear() === this.year;
|
||||
case PERIODS.TOTAL:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
},
|
||||
vehicleList() {
|
||||
const vehicles = store.state.vehicles || {};
|
||||
return Object.entries(vehicles).map(([name, vehicle]) => ({ name, ...vehicle }));
|
||||
},
|
||||
selectedSession() {
|
||||
return this.sessions.find((s) => s.id == this.selectedSessionId);
|
||||
},
|
||||
monthName() {
|
||||
const date = new Date();
|
||||
date.setMonth(this.month - 1, 1);
|
||||
return this.fmtMonth(date);
|
||||
},
|
||||
headline() {
|
||||
const date = new Date();
|
||||
date.setMonth(this.month - 1, 1);
|
||||
date.setFullYear(this.year);
|
||||
return this.fmtMonthYear(date);
|
||||
},
|
||||
csvLink() {
|
||||
return this.csvHrefLink(this.year, this.month);
|
||||
},
|
||||
csvTotalLink() {
|
||||
return this.csvHrefLink();
|
||||
},
|
||||
colorMappings() {
|
||||
const lastThreeMonths = new Date();
|
||||
lastThreeMonths.setMonth(lastThreeMonths.getMonth() - 3);
|
||||
|
||||
// Aggregate energy to get sorted list of loadpoints/vehicles for coloring
|
||||
const aggregateEnergy = (group) => {
|
||||
return this.sessionsWithDefaults.reduce((acc, session) => {
|
||||
if (new Date(session.created) >= lastThreeMonths) {
|
||||
const key = session[group];
|
||||
acc[key] = (acc[key] || 0) + session.chargedEnergy;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
// Assign colors based on energy usage
|
||||
const assignColors = (energyAggregation, colorType) => {
|
||||
const result = {};
|
||||
let colorIndex = 0;
|
||||
|
||||
// Assign colors by used energy in the last three months
|
||||
const sortedEntries = Object.entries(energyAggregation).sort((a, b) => b[1] - a[1]);
|
||||
sortedEntries.forEach(([key]) => {
|
||||
if (!result[key]) {
|
||||
result[key] = colors.palette[colorIndex % colors.palette.length];
|
||||
colorIndex++;
|
||||
}
|
||||
});
|
||||
|
||||
// Assign colors to remaining entries
|
||||
this.sessionsWithDefaults.forEach((session) => {
|
||||
const key = session[colorType];
|
||||
if (!result[key]) {
|
||||
result[key] = colors.palette[colorIndex % colors.palette.length];
|
||||
colorIndex++;
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const loadpointEnergy = aggregateEnergy("loadpoint");
|
||||
const loadpointColors = assignColors(loadpointEnergy, "loadpoint");
|
||||
|
||||
const vehicleEnergy = aggregateEnergy("vehicle");
|
||||
const vehicleColors = assignColors(vehicleEnergy, "vehicle");
|
||||
|
||||
const solar = { self: colors.self, grid: colors.grid };
|
||||
const cost = { price: colors.price, co2: colors.co2 };
|
||||
|
||||
return {
|
||||
loadpoint: loadpointColors,
|
||||
vehicle: vehicleColors,
|
||||
solar,
|
||||
cost,
|
||||
};
|
||||
},
|
||||
groupIcons() {
|
||||
return {
|
||||
[GROUPS.NONE]: TotalIcon,
|
||||
[GROUPS.LOADPOINT]: "shopicon-regular-cablecharge",
|
||||
[GROUPS.VEHICLE]: "shopicon-regular-car3",
|
||||
};
|
||||
},
|
||||
typeIcons() {
|
||||
return {
|
||||
[TYPES.SOLAR]: "shopicon-regular-sun",
|
||||
[TYPES.PRICE]: DynamicPriceIcon,
|
||||
[TYPES.CO2]: "shopicon-regular-eco1",
|
||||
};
|
||||
},
|
||||
costTypeIcons() {
|
||||
return {
|
||||
[TYPES.PRICE]: DynamicPriceIcon,
|
||||
[TYPES.CO2]: "shopicon-regular-eco1",
|
||||
};
|
||||
},
|
||||
showTable() {
|
||||
return this.period === PERIODS.MONTH;
|
||||
},
|
||||
showMonthNavigation() {
|
||||
return this.period === PERIODS.MONTH;
|
||||
},
|
||||
showYearNavigation() {
|
||||
return [PERIODS.MONTH, PERIODS.YEAR].includes(this.period);
|
||||
},
|
||||
showDateNavigator() {
|
||||
return this.showMonthNavigation || this.showYearNavigation;
|
||||
},
|
||||
groupEntriesAvailable() {
|
||||
if (this.selectedGroup === GROUPS.NONE || !this.currentSessions.length) return false;
|
||||
return new Set(this.currentSessions.map((s) => s[this.selectedGroup])).size > 1;
|
||||
},
|
||||
showSolarYearChart() {
|
||||
return this.period !== PERIODS.MONTH && this.selectedGroup === GROUPS.NONE;
|
||||
},
|
||||
showExtraCharts() {
|
||||
const hasMultipleEntries =
|
||||
new Set(this.currentTypeSessions.map((s) => s[this.selectedGroup])).size > 1;
|
||||
const isGrouped = [GROUPS.LOADPOINT, GROUPS.VEHICLE].includes(this.selectedGroup);
|
||||
const isSolar = this.activeType === TYPES.SOLAR;
|
||||
const isNotMonth = this.period !== PERIODS.MONTH;
|
||||
|
||||
return (isGrouped && hasMultipleEntries) || (isSolar && isNotMonth && !isGrouped);
|
||||
},
|
||||
suggestedMaxPrice() {
|
||||
// returns the 90th percentile of all prices
|
||||
const sessionsWithPrice = this.sessions.filter((s) => s.pricePerKWh !== null);
|
||||
const prices = sessionsWithPrice.map((s) => s.pricePerKWh);
|
||||
return this.percentile(prices, 90);
|
||||
},
|
||||
suggestedMaxCo2() {
|
||||
// returns the 90th percentile of all co2 emissions
|
||||
const sessionsWithCo2 = this.sessions.filter((s) => s.co2PerKWh !== null);
|
||||
const co2 = sessionsWithCo2.map((s) => s.co2PerKWh);
|
||||
return this.percentile(co2, 90);
|
||||
},
|
||||
suggestedMaxCost() {
|
||||
return this.activeType === TYPES.PRICE ? this.suggestedMaxPrice : this.suggestedMaxCo2;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
offline: function () {
|
||||
this.loadSessions();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.loadSessions();
|
||||
},
|
||||
methods: {
|
||||
changePeriod(newPeriod) {
|
||||
let month = this.month;
|
||||
let year = this.year;
|
||||
let period = newPeriod;
|
||||
switch (period) {
|
||||
case PERIODS.TOTAL:
|
||||
month = undefined;
|
||||
year = undefined;
|
||||
break;
|
||||
case PERIODS.YEAR:
|
||||
month = undefined;
|
||||
break;
|
||||
default:
|
||||
period = undefined;
|
||||
}
|
||||
this.$router.push({ query: { ...this.$route.query, period, month, year } });
|
||||
},
|
||||
async loadSessions() {
|
||||
const response = await api.get("sessions");
|
||||
// ensure sessions are sorted by created date
|
||||
const sortedSessions = response.data?.result.sort((a, b) => {
|
||||
return new Date(a.created) - new Date(b.created);
|
||||
});
|
||||
this.sessions = sortedSessions;
|
||||
},
|
||||
showDetails(sessionId) {
|
||||
this.selectedSessionId = sessionId;
|
||||
const modal = Modal.getOrCreateInstance(document.getElementById("sessionDetailsModal"));
|
||||
modal.show();
|
||||
},
|
||||
csvHrefLink(year, month) {
|
||||
const params = new URLSearchParams({
|
||||
format: "csv",
|
||||
lang: this.$i18n?.locale,
|
||||
});
|
||||
if (year && month) {
|
||||
params.append("year", year);
|
||||
params.append("month", month);
|
||||
}
|
||||
return `./api/sessions?${params.toString()}`;
|
||||
},
|
||||
updateType(type) {
|
||||
this.selectedType = type;
|
||||
settings.sessionsType = type;
|
||||
},
|
||||
updateGroup(group) {
|
||||
this.selectedGroup = group;
|
||||
settings.sessionsGroup = group;
|
||||
},
|
||||
updateDate({ year, month }) {
|
||||
this.$router.push({ query: { ...this.$route.query, year, month } });
|
||||
},
|
||||
percentile(arr, p) {
|
||||
if (arr.length === 0) return null;
|
||||
const sorted = arr.sort((a, b) => a - b);
|
||||
const index = (p / 100) * (sorted.length - 1);
|
||||
return sorted[Math.floor(index)];
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header-outer {
|
||||
--vertical-shift: 0rem;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: 0 calc(calc(1.5rem + var(--vertical-shift)) * -1);
|
||||
-webkit-backdrop-filter: blur(35px);
|
||||
backdrop-filter: blur(35px);
|
||||
background-color: #0000;
|
||||
box-shadow: 0 1px 8px 0px var(--evcc-background);
|
||||
}
|
||||
|
||||
@media (min-width: 576px) {
|
||||
.header-outer {
|
||||
--vertical-shift: calc((100vw - 540px) / 2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.header-outer {
|
||||
--vertical-shift: calc((100vw - 720px) / 2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
.header-outer {
|
||||
--vertical-shift: calc((100vw - 960px) / 2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.header-outer {
|
||||
--vertical-shift: calc((100vw - 1140px) / 2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1400px) {
|
||||
.header-outer {
|
||||
--vertical-shift: calc((100vw - 1320px) / 2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue