Sessions: ui refinement, mobile table, breadcrumb, filters (#8793)
Some checks failed
Some checks failed
This commit is contained in:
parent
7f069b699f
commit
7b89a0af5f
9 changed files with 548 additions and 232 deletions
48
assets/js/components/CustomSelect.vue
Normal file
48
assets/js/components/CustomSelect.vue
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<template>
|
||||
<label class="position-relative d-block">
|
||||
<select :value="selected" class="custom-select" @change="change">
|
||||
<option
|
||||
v-for="{ name, value, count } in options"
|
||||
:key="value"
|
||||
:value="value"
|
||||
:disabled="count === 0"
|
||||
>
|
||||
{{ text(name, count) }}
|
||||
</option>
|
||||
</select>
|
||||
<slot></slot>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "CustomSelect",
|
||||
props: {
|
||||
options: { type: Array },
|
||||
selected: { type: String },
|
||||
},
|
||||
emits: ["change"],
|
||||
methods: {
|
||||
text(name, count) {
|
||||
if (count === undefined) {
|
||||
return name;
|
||||
}
|
||||
return `${name} (${count})`;
|
||||
},
|
||||
change(event) {
|
||||
this.$emit("change", event);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style scoped>
|
||||
.custom-select {
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
-webkit-appearance: menulist-button;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -175,9 +175,8 @@ export default {
|
|||
return Math.round(this.selfConsumptionPercent) || 0;
|
||||
},
|
||||
effectivePriceFormatted() {
|
||||
const [value, unit] = this.fmtPricePerKWh(this.effectivePrice, this.currency).split(
|
||||
" "
|
||||
);
|
||||
const value = this.fmtPricePerKWh(this.effectivePrice, this.currency, false, false);
|
||||
const unit = this.pricePerKWhUnit(this.currency);
|
||||
return { value, unit };
|
||||
},
|
||||
startDate() {
|
||||
|
|
|
|||
|
|
@ -197,22 +197,31 @@ export default {
|
|||
const symbols = { EUR: "€", USD: "$" };
|
||||
return symbols[currency] || currency;
|
||||
},
|
||||
fmtPricePerKWh: function (amout = 0, currency = "EUR", short = false) {
|
||||
let unit = currency;
|
||||
fmtPricePerKWh: function (amout = 0, currency = "EUR", short = false, withUnit = true) {
|
||||
let value = amout;
|
||||
let minimumFractionDigits = 1;
|
||||
let maximumFractionDigits = 3;
|
||||
if (["EUR", "USD"].includes(currency)) {
|
||||
value *= 100;
|
||||
unit = "ct";
|
||||
minimumFractionDigits = 1;
|
||||
maximumFractionDigits = 1;
|
||||
}
|
||||
return `${new Intl.NumberFormat(this.$i18n.locale, {
|
||||
const price = new Intl.NumberFormat(this.$i18n.locale, {
|
||||
style: "decimal",
|
||||
minimumFractionDigits,
|
||||
maximumFractionDigits,
|
||||
}).format(value)} ${unit}${short ? "" : "/kWh"}`;
|
||||
}).format(value);
|
||||
if (withUnit) {
|
||||
return `${price} ${this.pricePerKWhUnit(currency, short)}`;
|
||||
}
|
||||
return price;
|
||||
},
|
||||
pricePerKWhUnit: function (currency = "EUR", short = false) {
|
||||
let unit = currency;
|
||||
if (["EUR", "USD"].includes(currency)) {
|
||||
unit = "ct";
|
||||
}
|
||||
return `${unit}${short ? "" : "/kWh"}`;
|
||||
},
|
||||
fmtTimeAgo: function (elapsed) {
|
||||
const units = {
|
||||
|
|
|
|||
|
|
@ -26,3 +26,23 @@ describe("fmtKWh", () => {
|
|||
expect(fmt.fmtKWh(56789.123, false, true, 2)).eq("56.789,12 Wh");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fmtPricePerKWh", () => {
|
||||
test("should format with units", () => {
|
||||
expect(fmt.fmtPricePerKWh(0.2, "EUR")).eq("20,0 ct/kWh");
|
||||
expect(fmt.fmtPricePerKWh(0.2, "EUR", true)).eq("20,0 ct");
|
||||
expect(fmt.fmtPricePerKWh(0.234, "USD")).eq("23,4 ct/kWh");
|
||||
expect(fmt.fmtPricePerKWh(1234, "SEK")).eq("1.234,0 SEK/kWh");
|
||||
expect(fmt.fmtPricePerKWh(0.2, "EUR", false, false)).eq("20,0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pricePerKWhUnit", () => {
|
||||
test("should return correct unit", () => {
|
||||
expect(fmt.pricePerKWhUnit("EUR")).eq("ct/kWh");
|
||||
expect(fmt.pricePerKWhUnit("EUR", true)).eq("ct");
|
||||
expect(fmt.pricePerKWhUnit("USD")).eq("ct/kWh");
|
||||
expect(fmt.pricePerKWhUnit("SEK")).eq("SEK/kWh");
|
||||
expect(fmt.pricePerKWhUnit("SEK", true)).eq("SEK");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div class="app overflow-hidden">
|
||||
<div class="app">
|
||||
<metainfo>
|
||||
<template #title="{ content }">{{ content ? `${content} | evcc` : `evcc` }}</template>
|
||||
</metainfo>
|
||||
|
|
|
|||
|
|
@ -1,18 +1,19 @@
|
|||
<template>
|
||||
<div class="container px-4">
|
||||
<header class="d-flex justify-content-between align-items-center py-3">
|
||||
<h1 class="mb-1 pt-1 d-flex text-nowrap">
|
||||
<router-link class="dropdown-item mx-2 me-2" to="/">
|
||||
<shopicon-bold-arrowback size="s" class="back"></shopicon-bold-arrowback>
|
||||
<h1 class="mb-1 pt-1 d-flex text-nowrap text-truncate">
|
||||
<router-link class="evcc-default-text" to="/">
|
||||
<shopicon-regular-home size="s" class="home"></shopicon-regular-home>
|
||||
</router-link>
|
||||
{{ $t("sessions.title") }}
|
||||
<div size="s" class="mx-2 flex-grow-0 flex-shrink-0 fw-normal">/</div>
|
||||
<span class="text-truncate">{{ $t("sessions.title") }}</span>
|
||||
</h1>
|
||||
<TopNavigation />
|
||||
</header>
|
||||
|
||||
<div class="row">
|
||||
<main class="col-12">
|
||||
<div class="d-flex align-items-baseline justify-content-between my-5">
|
||||
<div class="d-flex align-items-baseline justify-content-between my-3 my-md-5">
|
||||
<router-link
|
||||
class="d-flex text-decoration-none align-items-center"
|
||||
:class="{ 'pe-none': !hasPrev, 'text-muted': !hasPrev }"
|
||||
|
|
@ -42,9 +43,9 @@
|
|||
<div v-if="currentSessions.length === 0" data-testid="sessions-nodata" class="my-5">
|
||||
<p>{{ $t("sessions.noData") }}</p>
|
||||
</div>
|
||||
<div v-else class="table-responsive my-3">
|
||||
<div v-else class="my-3 my-md-5 table-outer">
|
||||
<table class="table text-nowrap">
|
||||
<thead>
|
||||
<thead class="sticky-top">
|
||||
<tr data-testid="sessions-head">
|
||||
<th scope="col" class="align-top ps-0">
|
||||
{{ $t("sessions.date") }}
|
||||
|
|
@ -52,114 +53,154 @@
|
|||
<th
|
||||
v-if="showLoadpoints"
|
||||
scope="col"
|
||||
class="align-top"
|
||||
class="align-top d-none d-md-table-cell"
|
||||
data-testid="loadpoint"
|
||||
>
|
||||
{{ $t("sessions.loadpoint") }}
|
||||
<label class="position-relative d-block">
|
||||
<select
|
||||
:value="loadpointFilter"
|
||||
class="custom-select"
|
||||
@change="changeLoadpointFilter"
|
||||
>
|
||||
<option
|
||||
v-for="{
|
||||
name,
|
||||
value,
|
||||
count,
|
||||
} in loadpointFilterOptions"
|
||||
:key="value"
|
||||
:value="value"
|
||||
:disabled="count === 0"
|
||||
>
|
||||
{{ name }} ({{ count }})
|
||||
</option>
|
||||
</select>
|
||||
<CustomSelect
|
||||
:selected="loadpointFilter"
|
||||
:options="loadpointFilterOptions"
|
||||
data-testid="filter-loadpoint"
|
||||
@change="changeLoadpointFilter"
|
||||
>
|
||||
<span
|
||||
class="fw-normal text-decoration-underline text-nowrap text-muted pe-none"
|
||||
class="fw-normal text-decoration-underline text-nowrap text-gray pe-none"
|
||||
>
|
||||
{{ loadpointFilter || $t("sessions.filter.filter") }}
|
||||
</span>
|
||||
</label>
|
||||
</CustomSelect>
|
||||
</th>
|
||||
<th
|
||||
v-if="showVehicles"
|
||||
scope="col"
|
||||
class="align-top"
|
||||
class="align-top d-none d-md-table-cell"
|
||||
data-testid="vehicle"
|
||||
>
|
||||
{{ $t("sessions.vehicle") }}
|
||||
<label class="position-relative d-block">
|
||||
<select
|
||||
:value="vehicleFilter"
|
||||
class="custom-select"
|
||||
@change="changeVehicleFilter"
|
||||
>
|
||||
<option
|
||||
v-for="{
|
||||
name,
|
||||
value,
|
||||
count,
|
||||
} in vehicleFilterOptions"
|
||||
:key="value"
|
||||
:value="value"
|
||||
:disabled="count === 0"
|
||||
>
|
||||
{{ name }} ({{ count }})
|
||||
</option>
|
||||
</select>
|
||||
<CustomSelect
|
||||
:selected="vehicleFilter"
|
||||
:options="vehicleFilterOptions"
|
||||
data-testid="filter-vehicle"
|
||||
@change="changeVehicleFilter"
|
||||
>
|
||||
<span
|
||||
class="fw-normal text-decoration-underline text-nowrap text-muted pe-none"
|
||||
class="fw-normal text-decoration-underline text-nowrap text-gray pe-none"
|
||||
>
|
||||
{{ vehicleFilter || $t("sessions.filter.filter") }}
|
||||
</span>
|
||||
</label>
|
||||
</CustomSelect>
|
||||
</th>
|
||||
<th scope="col" class="align-top text-end">
|
||||
{{ $t("sessions.energy") }}
|
||||
<div class="text-muted fw-normal">
|
||||
{{ fmtKWh(chargedEnergy * 1e3, chargedEnergy >= 1) }}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
v-if="hasSolarPercentage"
|
||||
scope="col"
|
||||
class="align-top text-end"
|
||||
>
|
||||
{{ $t("sessions.solar") }}
|
||||
<th scope="col" class="align-top d-md-none text-truncate">
|
||||
<div
|
||||
v-if="solarPercentage != null"
|
||||
class="text-muted fw-normal"
|
||||
v-if="showLoadpoints"
|
||||
class="d-flex flex-wrap text-truncate"
|
||||
>
|
||||
{{ fmtNumber(solarPercentage, 1) }}%
|
||||
<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>
|
||||
</th>
|
||||
<th v-if="hasPrice" scope="col" class="align-top text-end">
|
||||
{{ $t("sessions.price") }}
|
||||
<div v-if="price != null" class="text-muted fw-normal">
|
||||
{{ fmtMoney(price, currency) }}
|
||||
{{ fmtCurrencySymbol(currency) }}
|
||||
</div>
|
||||
</th>
|
||||
<th v-if="hasPrice" scope="col" class="align-top text-end">
|
||||
{{ $t("sessions.avgPrice") }}
|
||||
<div v-if="pricePerKWh != null" class="text-muted fw-normal">
|
||||
{{ fmtPricePerKWh(pricePerKWh, currency) }}
|
||||
<div
|
||||
class="text-truncate"
|
||||
:class="{ 'd-flex flex-wrap': showLoadpoints }"
|
||||
>
|
||||
<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-if="hasCo2"
|
||||
v-for="column in columns"
|
||||
:key="column.name"
|
||||
scope="col"
|
||||
class="align-top text-end pe-0"
|
||||
data-testid="co2"
|
||||
:data-testid="`sessions-head-${column.name}`"
|
||||
class="align-top text-end d-none d-md-table-cell"
|
||||
>
|
||||
{{ $t("sessions.co2") }}
|
||||
<div v-if="co2PerKWh != null" class="text-muted fw-normal">
|
||||
{{ fmtCo2Medium(co2PerKWh) }}
|
||||
</div>
|
||||
{{ $t(`sessions.${column.name}`) }}<br />
|
||||
<div class="text-gray fw-normal">{{ column.unit }}</div>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="align-top text-end d-md-none"
|
||||
data-testid="sessions-head-mobile"
|
||||
>
|
||||
<span v-if="columns.length === 1">
|
||||
{{ $t(`sessions.${mobileColumn.name}`) }}
|
||||
</span>
|
||||
<CustomSelect
|
||||
v-else
|
||||
:selected="selectedMobileColumn"
|
||||
:options="mobileColumnOptions"
|
||||
data-testid="mobile-column"
|
||||
@change="changeMobileColumn"
|
||||
>
|
||||
<span class="text-decoration-underline">
|
||||
{{ $t(`sessions.${mobileColumn.name}`) }}
|
||||
</span>
|
||||
</CustomSelect>
|
||||
<div class="text-gray fw-normal">{{ mobileColumn.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
|
||||
v-if="showLoadpoints"
|
||||
scope="col"
|
||||
class="d-none d-md-table-cell"
|
||||
></th>
|
||||
<th
|
||||
v-if="showVehicles"
|
||||
scope="col"
|
||||
class="d-none d-md-table-cell"
|
||||
></th>
|
||||
<th scope="col" class="d-md-none"></th>
|
||||
<th
|
||||
v-for="column in columns"
|
||||
:key="column.name"
|
||||
:data-testid="`sessions-foot-${column.name}`"
|
||||
scope="col"
|
||||
class="align-top text-end d-none d-md-table-cell"
|
||||
>
|
||||
{{ column.format(column.total) }}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
data-testid="sessions-foot-mobile"
|
||||
class="align-top text-end d-md-none"
|
||||
>
|
||||
{{ mobileColumn.format(mobileColumn.total) }}
|
||||
</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(session, id) in filteredSessions"
|
||||
|
|
@ -169,55 +210,47 @@
|
|||
@click="showDetails(session.id)"
|
||||
>
|
||||
<td class="ps-0">
|
||||
<u>{{ fmtFullDateTime(new Date(session.created), true) }}</u>
|
||||
{{ fmtFullDateTime(new Date(session.created), true) }}
|
||||
</td>
|
||||
<td v-if="showLoadpoints">
|
||||
<td v-if="showLoadpoints" class="d-none d-md-table-cell">
|
||||
{{ session.loadpoint }}
|
||||
</td>
|
||||
<td v-if="showVehicles">
|
||||
<td v-if="showVehicles" class="d-none d-md-table-cell">
|
||||
{{ session.vehicle }}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
{{
|
||||
fmtKWh(
|
||||
session.chargedEnergy * 1e3,
|
||||
session.chargedEnergy >= 1
|
||||
)
|
||||
}}
|
||||
<td class="d-md-none text-truncate">
|
||||
<div v-if="showLoadpoints">{{ session.loadpoint }}</div>
|
||||
<div>{{ session.vehicle }}</div>
|
||||
</td>
|
||||
<td v-if="hasSolarPercentage" class="text-end">
|
||||
<span v-if="session.solarPercentage != null">
|
||||
{{ fmtNumber(session.solarPercentage, 1) }}%
|
||||
<td
|
||||
v-for="column in columns"
|
||||
:key="column.name"
|
||||
class="text-end d-none d-md-table-cell"
|
||||
>
|
||||
<span v-if="column.value(session) === null" class="text-gray">
|
||||
-
|
||||
</span>
|
||||
<span v-else class="text-muted">-</span>
|
||||
<span v-else>{{ column.format(column.value(session)) }}</span>
|
||||
</td>
|
||||
<td v-if="hasPrice" class="text-end">
|
||||
<span v-if="session.price != null">
|
||||
{{ fmtMoney(session.price, currency) }}
|
||||
{{ fmtCurrencySymbol(currency) }}
|
||||
<td class="text-end d-md-none">
|
||||
<span
|
||||
v-if="mobileColumn.value(session) === null"
|
||||
class="text-gray"
|
||||
>
|
||||
-
|
||||
</span>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</td>
|
||||
<td v-if="hasPrice" class="text-end">
|
||||
<span v-if="session.pricePerKWh != null">
|
||||
{{ fmtPricePerKWh(session.pricePerKWh, currency) }}
|
||||
</span>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</td>
|
||||
<td v-if="hasCo2" class="text-end pe-0">
|
||||
<span v-if="session.co2PerKWh != null">
|
||||
{{ fmtCo2Medium(session.co2PerKWh) }}
|
||||
</span>
|
||||
<span v-else class="text-muted">-</span>
|
||||
<span v-else>{{
|
||||
mobileColumn.format(mobileColumn.value(session))
|
||||
}}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="d-grid gap-2 d-sm-block mt-3 mb-5">
|
||||
<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-sm-2"
|
||||
class="btn btn-outline-secondary text-nowrap me-md-2"
|
||||
:href="csvLink"
|
||||
download
|
||||
>
|
||||
|
|
@ -245,17 +278,18 @@
|
|||
<script>
|
||||
import Modal from "bootstrap/js/dist/modal";
|
||||
import TopNavigation from "../components/TopNavigation.vue";
|
||||
import "@h2d2/shopicons/es/bold/arrowback";
|
||||
import "@h2d2/shopicons/es/regular/home";
|
||||
import "@h2d2/shopicons/es/regular/angledoubleleftsmall";
|
||||
import "@h2d2/shopicons/es/regular/angledoublerightsmall";
|
||||
import formatter from "../mixins/formatter";
|
||||
import api from "../api";
|
||||
import store from "../store";
|
||||
import CustomSelect from "../components/CustomSelect.vue";
|
||||
import ChargingSessionModal from "../components/ChargingSessionModal.vue";
|
||||
|
||||
export default {
|
||||
name: "ChargingSessions",
|
||||
components: { TopNavigation, ChargingSessionModal },
|
||||
components: { TopNavigation, ChargingSessionModal, CustomSelect },
|
||||
mixins: [formatter],
|
||||
props: {
|
||||
notifications: Array,
|
||||
|
|
@ -268,6 +302,7 @@ export default {
|
|||
return {
|
||||
sessions: [],
|
||||
selectedSessionId: undefined,
|
||||
selectedMobileColumn: undefined,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
|
|
@ -286,6 +321,62 @@ export default {
|
|||
filteredSessions() {
|
||||
return this.currentSessions.filter(this.filterByLoadpoint).filter(this.filterByVehicle);
|
||||
},
|
||||
columns() {
|
||||
const columns = [
|
||||
{
|
||||
name: "energy",
|
||||
unit: "kWh",
|
||||
total: this.chargedEnergy,
|
||||
value: (session) => session.chargedEnergy,
|
||||
format: (value) => this.fmtKWh(value * 1e3, true, 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),
|
||||
},
|
||||
];
|
||||
// only columns with values are shown
|
||||
return columns.filter((column) => {
|
||||
if (column.name === "energy") return true;
|
||||
return this.currentSessions.some((s) => column.value(s));
|
||||
});
|
||||
},
|
||||
mobileColumn() {
|
||||
const column = this.columns.find((column) => column.name === this.selectedMobileColumn);
|
||||
return column || this.columns[0];
|
||||
},
|
||||
mobileColumnOptions() {
|
||||
return this.columns.map((column) => {
|
||||
return {
|
||||
name: this.$t(`sessions.${column.name}`),
|
||||
value: column.name,
|
||||
};
|
||||
});
|
||||
},
|
||||
vehicleFilterOptions() {
|
||||
const options = [
|
||||
{
|
||||
|
|
@ -320,15 +411,6 @@ export default {
|
|||
price() {
|
||||
return this.filteredSessions.reduce((total, s) => total + s.price, 0);
|
||||
},
|
||||
hasPrice() {
|
||||
return this.filteredSessions.find((s) => s.price != null) != null;
|
||||
},
|
||||
hasSolarPercentage() {
|
||||
return this.filteredSessions.find((s) => s.solarPercentage != null) != null;
|
||||
},
|
||||
hasCo2() {
|
||||
return this.filteredSessions.find((s) => s.co2PerKWh != null) != null;
|
||||
},
|
||||
showVehicles() {
|
||||
return this.hasMultipleVehicles || this.vehicleFilter;
|
||||
},
|
||||
|
|
@ -344,21 +426,42 @@ export default {
|
|||
return new Set(loadpoints).size > 1;
|
||||
},
|
||||
pricePerKWh() {
|
||||
return this.price / this.chargedEnergy;
|
||||
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 emittedCo2 = this.filteredSessions.reduce(
|
||||
(total, s) => total + s.chargedEnergy * s.co2PerKWh,
|
||||
0
|
||||
);
|
||||
return emittedCo2 / this.chargedEnergy;
|
||||
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 }
|
||||
);
|
||||
return total.emittedCo2 / total.chargedEnergy;
|
||||
},
|
||||
solarPercentage() {
|
||||
const chargedSolarEnergy = this.filteredSessions.reduce(
|
||||
(total, s) => total + s.chargedEnergy * (s.solarPercentage / 100),
|
||||
0
|
||||
);
|
||||
return (100 / this.chargedEnergy) * chargedSolarEnergy;
|
||||
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))];
|
||||
|
|
@ -454,6 +557,9 @@ export default {
|
|||
.filter(this.filterByVehicle)
|
||||
.filter((s) => !loadpoint || s.loadpoint === loadpoint).length;
|
||||
},
|
||||
changeMobileColumn(event) {
|
||||
this.selectedMobileColumn = event.target.value;
|
||||
},
|
||||
changeLoadpointFilter(event) {
|
||||
const loadpoint = event.target.value || undefined;
|
||||
this.$router.push({ query: { ...this.$route.query, loadpoint } });
|
||||
|
|
@ -486,11 +592,11 @@ export default {
|
|||
};
|
||||
</script>
|
||||
<style scoped>
|
||||
.back {
|
||||
width: 22px;
|
||||
.home {
|
||||
height: 22px;
|
||||
width: 22px;
|
||||
position: relative;
|
||||
top: -2px;
|
||||
top: -3px;
|
||||
}
|
||||
.custom-select {
|
||||
left: 0;
|
||||
|
|
@ -501,4 +607,43 @@ export default {
|
|||
opacity: 0;
|
||||
-webkit-appearance: menulist-button;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
@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>
|
||||
|
|
|
|||
|
|
@ -312,7 +312,7 @@ vehicle = "Fahrzeug"
|
|||
[sessions.filter]
|
||||
allLoadpoints = "Alle Ladepunkte"
|
||||
allVehicles = "Alle Fahrzeuge"
|
||||
filter = "filtern"
|
||||
filter = "Filtern"
|
||||
|
||||
[settings]
|
||||
title = "Einstellungen"
|
||||
|
|
|
|||
|
|
@ -287,7 +287,7 @@ avgPrice = "Ø Price"
|
|||
co2 = "Ø CO₂"
|
||||
csvMonth = "Download {month} CSV"
|
||||
csvTotal = "Download total CSV"
|
||||
date = "Finished"
|
||||
date = "Start"
|
||||
downloadCsv = "Download as CSV"
|
||||
energy = "Charged"
|
||||
loadpoint = "Charging point"
|
||||
|
|
@ -296,6 +296,7 @@ price = "Σ Price"
|
|||
reallyDelete = "Do you really want to delete this session?"
|
||||
solar = "Solar"
|
||||
title = "Charging Sessions"
|
||||
total = "Total"
|
||||
vehicle = "Vehicle"
|
||||
|
||||
[sessions.csv]
|
||||
|
|
@ -312,7 +313,7 @@ vehicle = "Vehicle"
|
|||
[sessions.filter]
|
||||
allLoadpoints = "all charging points"
|
||||
allVehicles = "all vehicles"
|
||||
filter = "filter"
|
||||
filter = "Filter"
|
||||
|
||||
[settings]
|
||||
title = "Settings"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
const { test, expect } = require("@playwright/test");
|
||||
const { test, expect, devices } = require("@playwright/test");
|
||||
const { start, stop } = require("./evcc");
|
||||
|
||||
const mobile = devices["iPhone 12 Mini"].viewport;
|
||||
const desktop = devices["Desktop Chrome"].viewport;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await start("basics.evcc.yaml", "sessions.sql");
|
||||
});
|
||||
|
|
@ -30,14 +33,65 @@ test.describe("basics", async () => {
|
|||
await expect(page.getByTestId("sessions-nodata")).toHaveCount(0);
|
||||
await expect(page.getByRole("table")).toBeVisible();
|
||||
await expect(page.getByTestId("sessions-head")).toHaveCount(1);
|
||||
await expect(page.getByText("Charged 20.0 kWh")).toBeVisible();
|
||||
await expect(page.getByText("Solar 67.3%")).toBeVisible();
|
||||
await expect(page.getByText("Σ Price 5.50 €")).toBeVisible();
|
||||
await expect(page.getByText("Ø Price 27.5 ct/kWh")).toBeVisible();
|
||||
|
||||
await expect(page.getByTestId("sessions-head-energy")).toContainText("ChargedkWh");
|
||||
await expect(page.getByTestId("sessions-foot-energy")).toBeVisible("20.0");
|
||||
|
||||
await expect(page.getByTestId("sessions-head-solar")).toContainText("Solar%");
|
||||
await expect(page.getByTestId("sessions-foot-solar")).toBeVisible("67.3");
|
||||
|
||||
await expect(page.getByTestId("sessions-head-price")).toContainText("Σ Price€");
|
||||
await expect(page.getByTestId("sessions-foot-price")).toBeVisible("5.50");
|
||||
|
||||
await expect(page.getByTestId("sessions-head-avgPrice")).toContainText("Ø Pricect/kWh");
|
||||
await expect(page.getByTestId("sessions-foot-avgPrice")).toBeVisible("27.5");
|
||||
|
||||
await expect(page.getByTestId("sessions-entry")).toHaveCount(4);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("mobile basics", async () => {
|
||||
test("column select", async ({ page }) => {
|
||||
await page.setViewportSize(mobile);
|
||||
await page.goto("/#/sessions?year=2023&month=5");
|
||||
|
||||
// hidden columns
|
||||
await expect(page.getByTestId("sessions-head-energy")).not.toBeVisible();
|
||||
await expect(page.getByTestId("sessions-foot-energy")).not.toBeVisible();
|
||||
await expect(page.getByTestId("sessions-head-solar")).not.toBeVisible();
|
||||
await expect(page.getByTestId("sessions-foot-solar")).not.toBeVisible();
|
||||
await expect(page.getByTestId("sessions-head-price")).not.toBeVisible();
|
||||
await expect(page.getByTestId("sessions-foot-price")).not.toBeVisible();
|
||||
await expect(page.getByTestId("sessions-head-avgPrice")).not.toBeVisible();
|
||||
await expect(page.getByTestId("sessions-foot-avgPrice")).not.toBeVisible();
|
||||
|
||||
await expect(page.getByTestId("sessions-head-mobile")).toContainText("ChargedkWh");
|
||||
await expect(page.getByTestId("sessions-foot-mobile")).toBeVisible("20.0");
|
||||
|
||||
await page.getByTestId("mobile-column").selectOption("Solar");
|
||||
await expect(page.getByTestId("sessions-head-mobile")).toContainText("Solar%");
|
||||
await expect(page.getByTestId("sessions-foot-mobile")).toBeVisible("67.3");
|
||||
|
||||
await page.getByTestId("mobile-column").selectOption("Σ Price");
|
||||
await expect(page.getByTestId("sessions-head-mobile")).toContainText("Σ Price€");
|
||||
await expect(page.getByTestId("sessions-foot-mobile")).toBeVisible("5.50");
|
||||
|
||||
await page.getByTestId("mobile-column").selectOption("Ø Price");
|
||||
await expect(page.getByTestId("sessions-head-mobile")).toContainText("Ø Pricect/kWh");
|
||||
await expect(page.getByTestId("sessions-foot-mobile")).toBeVisible("27.5");
|
||||
});
|
||||
|
||||
test("keep selection when paging", async ({ page }) => {
|
||||
await page.setViewportSize(mobile);
|
||||
await page.goto("/#/sessions?year=2023&month=5");
|
||||
|
||||
await page.getByTestId("mobile-column").selectOption("Solar");
|
||||
await page.getByRole("link", { name: "Apr" }).click();
|
||||
await page.getByRole("link", { name: "May" }).click();
|
||||
await expect(page.getByTestId("sessions-head-mobile")).toContainText("Solar%");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("paging", async () => {
|
||||
test("prev/next links", async ({ page }) => {
|
||||
await page.goto("/#/sessions?year=2023&month=5");
|
||||
|
|
@ -58,81 +112,121 @@ test.describe("paging", async () => {
|
|||
});
|
||||
});
|
||||
|
||||
test.describe("filter", async () => {
|
||||
test("by vehicle", async ({ page }) => {
|
||||
await page.goto("/#/sessions?year=2023&month=5");
|
||||
await expect(page.getByTestId("sessions-entry")).toHaveCount(4);
|
||||
for (const [name, viewport] of Object.entries({ desktop, mobile })) {
|
||||
test.describe(`filter on ${name}`, async () => {
|
||||
test("by vehicle", async ({ page }) => {
|
||||
await page.setViewportSize(viewport);
|
||||
await page.goto("/#/sessions?year=2023&month=5");
|
||||
await expect(page.getByTestId("sessions-entry")).toHaveCount(4);
|
||||
|
||||
await page.getByLabel("all vehicles").selectOption("blauer e-Golf (2)");
|
||||
await expect(page.getByTestId("sessions-entry")).toHaveCount(2);
|
||||
await expect(page.getByTestId("sessions-entry").nth(0)).toHaveText(/blauer e-Golf/);
|
||||
await expect(page.getByTestId("sessions-entry").nth(1)).toHaveText(/blauer e-Golf/);
|
||||
await page
|
||||
.getByTestId("filter-vehicle")
|
||||
.locator("visible=true")
|
||||
.selectOption("blauer e-Golf (2)");
|
||||
await expect(page.getByTestId("sessions-entry")).toHaveCount(2);
|
||||
await expect(page.getByTestId("sessions-entry").nth(0)).toHaveText(/blauer e-Golf/);
|
||||
await expect(page.getByTestId("sessions-entry").nth(1)).toHaveText(/blauer e-Golf/);
|
||||
|
||||
await page.getByLabel("all vehicles").selectOption("weißes Model 3 (2)");
|
||||
await expect(page.getByTestId("sessions-entry")).toHaveCount(2);
|
||||
await expect(page.getByTestId("sessions-entry").nth(0)).toHaveText(/weißes Model 3/);
|
||||
await expect(page.getByTestId("sessions-entry").nth(1)).toHaveText(/weißes Model 3/);
|
||||
await page
|
||||
.getByTestId("filter-vehicle")
|
||||
.locator("visible=true")
|
||||
.selectOption("weißes Model 3 (2)");
|
||||
await expect(page.getByTestId("sessions-entry")).toHaveCount(2);
|
||||
await expect(page.getByTestId("sessions-entry").nth(0)).toHaveText(/weißes Model 3/);
|
||||
await expect(page.getByTestId("sessions-entry").nth(1)).toHaveText(/weißes Model 3/);
|
||||
|
||||
await page.getByLabel("all vehicles").selectOption("all vehicles (4)");
|
||||
await expect(page.getByTestId("sessions-entry")).toHaveCount(4);
|
||||
await page
|
||||
.getByTestId("filter-vehicle")
|
||||
.locator("visible=true")
|
||||
.selectOption("all vehicles (4)");
|
||||
await expect(page.getByTestId("sessions-entry")).toHaveCount(4);
|
||||
});
|
||||
|
||||
test("by loadpoint", async ({ page }) => {
|
||||
await page.setViewportSize(viewport);
|
||||
await page.goto("/#/sessions?year=2023&month=5");
|
||||
await expect(page.getByTestId("sessions-entry")).toHaveCount(4);
|
||||
|
||||
await page
|
||||
.getByTestId("filter-loadpoint")
|
||||
.locator("visible=true")
|
||||
.selectOption("Carport (3)");
|
||||
await expect(page.getByTestId("sessions-entry")).toHaveCount(3);
|
||||
await expect(page.getByTestId("sessions-entry").nth(0)).toHaveText(/Carport/);
|
||||
await expect(page.getByTestId("sessions-entry").nth(1)).toHaveText(/Carport/);
|
||||
await expect(page.getByTestId("sessions-entry").nth(2)).toHaveText(/Carport/);
|
||||
|
||||
await page.getByTestId("filter-loadpoint").locator("visible=true").selectOption("Garage (1)");
|
||||
await expect(page.getByTestId("sessions-entry")).toHaveCount(1);
|
||||
await expect(page.getByTestId("sessions-entry").nth(0)).toHaveText(/Garage/);
|
||||
|
||||
await page
|
||||
.getByTestId("filter-loadpoint")
|
||||
.locator("visible=true")
|
||||
.selectOption("all charging points (4)");
|
||||
await expect(page.getByTestId("sessions-entry")).toHaveCount(4);
|
||||
});
|
||||
|
||||
test("by vehicle and loadpoint", async ({ page }) => {
|
||||
await page.setViewportSize(viewport);
|
||||
await page.goto("/#/sessions?year=2023&month=5");
|
||||
|
||||
await page
|
||||
.getByTestId("filter-loadpoint")
|
||||
.locator("visible=true")
|
||||
.selectOption("Carport (3)");
|
||||
await page
|
||||
.getByTestId("filter-vehicle")
|
||||
.locator("visible=true")
|
||||
.selectOption("weißes Model 3 (1)");
|
||||
await expect(page.getByTestId("sessions-entry")).toHaveCount(1);
|
||||
await expect(page.getByTestId("sessions-entry")).toHaveText(/Carport/);
|
||||
await expect(page.getByTestId("sessions-entry")).toHaveText(/weißes Model 3/);
|
||||
|
||||
await page
|
||||
.getByTestId("filter-vehicle")
|
||||
.locator("visible=true")
|
||||
.selectOption("blauer e-Golf (2)");
|
||||
await expect(page.getByTestId("sessions-entry")).toHaveCount(2);
|
||||
await expect(page.getByTestId("sessions-entry").nth(0)).toHaveText(/Carport/);
|
||||
await expect(page.getByTestId("sessions-entry").nth(0)).toHaveText(/blauer e-Golf/);
|
||||
await expect(page.getByTestId("sessions-entry").nth(1)).toHaveText(/Carport/);
|
||||
await expect(page.getByTestId("sessions-entry").nth(1)).toHaveText(/blauer e-Golf/);
|
||||
});
|
||||
|
||||
test("by vehicle and loadpoint disabled options", async ({ page }) => {
|
||||
await page.setViewportSize(viewport);
|
||||
await page.goto("/#/sessions?year=2023&month=5");
|
||||
|
||||
await page
|
||||
.getByTestId("filter-vehicle")
|
||||
.locator("visible=true")
|
||||
.selectOption("blauer e-Golf (2)");
|
||||
const option = page
|
||||
.getByTestId("filter-loadpoint")
|
||||
.locator("visible=true")
|
||||
.locator("option[value=Garage]");
|
||||
await expect(option).toHaveAttribute("disabled", "");
|
||||
await expect(option).toHaveText("Garage (0)");
|
||||
});
|
||||
|
||||
test("keep filter when paging", async ({ page }) => {
|
||||
await page.setViewportSize(viewport);
|
||||
await page.goto("/#/sessions?year=2023&month=5");
|
||||
|
||||
await page
|
||||
.getByTestId("filter-loadpoint")
|
||||
.locator("visible=true")
|
||||
.selectOption("Carport (3)");
|
||||
await expect(page.getByTestId("sessions-entry")).toHaveCount(3);
|
||||
await page.getByRole("link", { name: "Jun" }).click();
|
||||
await page.getByRole("link", { name: "May" }).click();
|
||||
await expect(page.getByTestId("sessions-entry")).toHaveCount(3);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("by loadpoint", async ({ page }) => {
|
||||
await page.goto("/#/sessions?year=2023&month=5");
|
||||
await expect(page.getByTestId("sessions-entry")).toHaveCount(4);
|
||||
|
||||
await page.getByLabel("all charging points").selectOption("Carport (3)");
|
||||
await expect(page.getByTestId("sessions-entry")).toHaveCount(3);
|
||||
await expect(page.getByTestId("sessions-entry").nth(0)).toHaveText(/Carport/);
|
||||
await expect(page.getByTestId("sessions-entry").nth(1)).toHaveText(/Carport/);
|
||||
await expect(page.getByTestId("sessions-entry").nth(2)).toHaveText(/Carport/);
|
||||
|
||||
await page.getByLabel("all charging points").selectOption("Garage (1)");
|
||||
await expect(page.getByTestId("sessions-entry")).toHaveCount(1);
|
||||
await expect(page.getByTestId("sessions-entry").nth(0)).toHaveText(/Garage/);
|
||||
|
||||
await page.getByLabel("all charging points").selectOption("all charging points (4)");
|
||||
await expect(page.getByTestId("sessions-entry")).toHaveCount(4);
|
||||
});
|
||||
|
||||
test("by vehicle and loadpoint", async ({ page }) => {
|
||||
await page.goto("/#/sessions?year=2023&month=5");
|
||||
|
||||
await page.getByLabel("all charging points").selectOption("Carport (3)");
|
||||
await page.getByLabel("all vehicles").selectOption("weißes Model 3 (1)");
|
||||
await expect(page.getByTestId("sessions-entry")).toHaveCount(1);
|
||||
await expect(page.getByTestId("sessions-entry")).toHaveText(/Carport/);
|
||||
await expect(page.getByTestId("sessions-entry")).toHaveText(/weißes Model 3/);
|
||||
|
||||
await page.getByLabel("all vehicles").selectOption("blauer e-Golf (2)");
|
||||
await expect(page.getByTestId("sessions-entry")).toHaveCount(2);
|
||||
await expect(page.getByTestId("sessions-entry").nth(0)).toHaveText(/Carport/);
|
||||
await expect(page.getByTestId("sessions-entry").nth(0)).toHaveText(/blauer e-Golf/);
|
||||
await expect(page.getByTestId("sessions-entry").nth(1)).toHaveText(/Carport/);
|
||||
await expect(page.getByTestId("sessions-entry").nth(1)).toHaveText(/blauer e-Golf/);
|
||||
});
|
||||
|
||||
test("by vehicle and loadpoint disabled options", async ({ page }) => {
|
||||
await page.goto("/#/sessions?year=2023&month=5");
|
||||
|
||||
await page.getByLabel("all vehicles").selectOption("blauer e-Golf (2)");
|
||||
const option = page.locator("option[value=Garage]");
|
||||
await expect(option).toHaveAttribute("disabled", "");
|
||||
await expect(option).toHaveText("Garage (0)");
|
||||
});
|
||||
|
||||
test("keep filter when paging", async ({ page }) => {
|
||||
await page.goto("/#/sessions?year=2023&month=5");
|
||||
|
||||
await page.getByLabel("all charging points").selectOption("Carport (3)");
|
||||
await expect(page.getByTestId("sessions-entry")).toHaveCount(3);
|
||||
await page.getByRole("link", { name: "June" }).click();
|
||||
await page.getByRole("link", { name: "May" }).click();
|
||||
await expect(page.getByTestId("sessions-entry")).toHaveCount(3);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("columns", async () => {
|
||||
test.describe("columns desktop", async () => {
|
||||
test("show vehicle column if multiple different exist", async ({ page }) => {
|
||||
await page.goto("/#/sessions?year=2023&month=5");
|
||||
await expect(page.getByTestId("vehicle")).toBeVisible();
|
||||
|
|
@ -151,11 +245,11 @@ test.describe("columns", async () => {
|
|||
});
|
||||
test("show co2 column it has values", async ({ page }) => {
|
||||
await page.goto("/#/sessions?year=2023&month=3");
|
||||
await expect(page.getByTestId("co2")).toBeVisible();
|
||||
await expect(page.getByTestId("sessions-head-co2")).toBeVisible();
|
||||
});
|
||||
test("hide co2 column if it doesnt have values", async ({ page }) => {
|
||||
await page.goto("/#/sessions?year=2023&month=5");
|
||||
await expect(page.getByTestId("co2")).toHaveCount(0);
|
||||
await expect(page.getByTestId("sessions-head-co2")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue