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
|
|
@ -18,6 +18,7 @@ module.exports = {
|
||||||
"vue/attribute-hyphenation": "off",
|
"vue/attribute-hyphenation": "off",
|
||||||
"vue/multi-word-component-names": "off",
|
"vue/multi-word-component-names": "off",
|
||||||
"vue/no-reserved-component-names": "off",
|
"vue/no-reserved-component-names": "off",
|
||||||
|
/*"vue/no-undef-properties": "warn",*/
|
||||||
"no-param-reassign": "error",
|
"no-param-reassign": "error",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,9 @@
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--evcc-green: #baffcb;
|
--evcc-green: #baffcb;
|
||||||
--evcc-dark-green: #0fde41;
|
--evcc-dark-green: #0fde41ff;
|
||||||
--evcc-darker-green: #0ba631;
|
--evcc-darker-green: #0ba631ff;
|
||||||
--evcc-darkest-green: #076f20;
|
--evcc-darkest-green: #076f20ff;
|
||||||
--evcc-darkest-green-rgb: 11, 166, 49;
|
--evcc-darkest-green-rgb: 11, 166, 49;
|
||||||
--evcc-yellow: #faf000;
|
--evcc-yellow: #faf000;
|
||||||
--evcc-dark-yellow: #bbb400;
|
--evcc-dark-yellow: #bbb400;
|
||||||
|
|
@ -90,7 +90,7 @@
|
||||||
--evcc-grid: var(--bs-gray-medium);
|
--evcc-grid: var(--bs-gray-medium);
|
||||||
--evcc-background: var(--bs-gray-deep);
|
--evcc-background: var(--bs-gray-deep);
|
||||||
--evcc-box: var(--bs-gray-dark);
|
--evcc-box: var(--bs-gray-dark);
|
||||||
--evcc-box-border: var(--vs-gray-darker);
|
--evcc-box-border: var(--bs-gray-darker);
|
||||||
--evcc-default-text: var(--bs-white);
|
--evcc-default-text: var(--bs-white);
|
||||||
--evcc-gray: var(--bs-gray-light);
|
--evcc-gray: var(--bs-gray-light);
|
||||||
--evcc-accent1: var(--evcc-yellow);
|
--evcc-accent1: var(--evcc-yellow);
|
||||||
|
|
@ -195,6 +195,10 @@ a:hover {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.evcc-background {
|
||||||
|
background-color: var(--evcc-background);
|
||||||
|
}
|
||||||
|
|
||||||
.btn-primary,
|
.btn-primary,
|
||||||
.btn-primary:focus {
|
.btn-primary:focus {
|
||||||
background-color: var(--bs-primary);
|
background-color: var(--bs-primary);
|
||||||
|
|
@ -217,7 +221,11 @@ a:hover {
|
||||||
color: inherit !important;
|
color: inherit !important;
|
||||||
background-color: inherit !important;
|
background-color: inherit !important;
|
||||||
border-color: inherit !important;
|
border-color: inherit !important;
|
||||||
opacity: 0.2;
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .btn-disabled {
|
||||||
|
opacity: 0.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-outline-primary {
|
.btn-outline-primary {
|
||||||
|
|
|
||||||
62
assets/js/colors.js
Normal file
62
assets/js/colors.js
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { reactive } from "vue";
|
||||||
|
|
||||||
|
// alternatives
|
||||||
|
// const COLORS = [ "#40916C", "#52B788", "#74C69D", "#95D5B2", "#B7E4C7", "#D8F3DC", "#081C15", "#1B4332", "#2D6A4F"];
|
||||||
|
// const COLORS = ["#577590", "#43AA8B", "#90BE6D", "#F9C74F", "#F8961E", "#F3722C", "#F94144"];
|
||||||
|
// const COLORS = ["#0077b6", "#00b4d8", "#90e0ef", "#caf0f8", "#03045e"];
|
||||||
|
// const COLORS = [ "#0077B6FF", "#0096C7FF", "#00B4D8FF", "#48CAE4FF", "#90E0EFFF", "#ADE8F4FF", "#CAF0F8FF", "#03045EFF", "#023E8AFF",
|
||||||
|
// const COLORS = [ "#0077B6FF", "#00B4D8FF", "#90E0EFFF", "#40A578FF", "#9DDE8BFF", "#F8961EFF", "#F9C74FFF", "#E6FF94FF"];
|
||||||
|
|
||||||
|
const colors = reactive({
|
||||||
|
text: null,
|
||||||
|
muted: null,
|
||||||
|
border: null,
|
||||||
|
self: null,
|
||||||
|
grid: null,
|
||||||
|
co2PerKWh: null,
|
||||||
|
pricePerKWh: null,
|
||||||
|
price: "#FF912FFF",
|
||||||
|
co2: "#00916EFF",
|
||||||
|
background: null,
|
||||||
|
selfPalette: ["#0fde41ff", "#0ba631ff", "#076f20ff", "#054e18ff", "#043611ff", "#02230bff"],
|
||||||
|
palette: [
|
||||||
|
"#03C1EFFF",
|
||||||
|
"#FD6158FF",
|
||||||
|
"#31AB4AFF",
|
||||||
|
"#0AAFBFFF",
|
||||||
|
"#FF922EFF",
|
||||||
|
"#0F662DFF",
|
||||||
|
"#0470D4FF",
|
||||||
|
"#FFBD2FFF",
|
||||||
|
"#77C93EFF",
|
||||||
|
"#41517AFF",
|
||||||
|
"#4E1D10FF",
|
||||||
|
"#813504FF",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateCssColors() {
|
||||||
|
const style = window.getComputedStyle(document.documentElement);
|
||||||
|
colors.text = style.getPropertyValue("--evcc-default-text");
|
||||||
|
colors.muted = style.getPropertyValue("--bs-gray-medium");
|
||||||
|
colors.border = style.getPropertyValue("--bs-border-color-translucent");
|
||||||
|
colors.self = style.getPropertyValue("--evcc-self");
|
||||||
|
colors.grid = style.getPropertyValue("--evcc-grid");
|
||||||
|
colors.background = style.getPropertyValue("--evcc-background");
|
||||||
|
colors.pricePerKWh = style.getPropertyValue("--evcc-grid");
|
||||||
|
colors.co2PerKWh = style.getPropertyValue("--evcc-grid");
|
||||||
|
}
|
||||||
|
|
||||||
|
// update colors on theme change
|
||||||
|
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", updateCssColors);
|
||||||
|
updateCssColors();
|
||||||
|
|
||||||
|
export const dimColor = (color) => {
|
||||||
|
return color.toLowerCase().replace(/ff$/, "20");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fullColor = (color) => {
|
||||||
|
return color.toLowerCase().replace(/20$/, "ff");
|
||||||
|
};
|
||||||
|
|
||||||
|
export default colors;
|
||||||
|
|
@ -48,6 +48,7 @@
|
||||||
:id="id"
|
:id="id"
|
||||||
class="w-50"
|
class="w-50"
|
||||||
v-model="value"
|
v-model="value"
|
||||||
|
equal-width
|
||||||
:options="[
|
:options="[
|
||||||
{ value: false, name: $t('config.options.boolean.no') },
|
{ value: false, name: $t('config.options.boolean.no') },
|
||||||
{ value: true, name: $t('config.options.boolean.yes') },
|
{ value: true, name: $t('config.options.boolean.yes') },
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
name: $t(`settings.theme.${value}`),
|
name: $t(`settings.theme.${value}`),
|
||||||
}))
|
}))
|
||||||
"
|
"
|
||||||
|
equal-width
|
||||||
/>
|
/>
|
||||||
</FormRow>
|
</FormRow>
|
||||||
<FormRow id="settingsLanguage" :label="$t('settings.language.label')">
|
<FormRow id="settingsLanguage" :label="$t('settings.language.label')">
|
||||||
|
|
@ -36,6 +37,7 @@
|
||||||
name: $t(`settings.unit.${value}`),
|
name: $t(`settings.unit.${value}`),
|
||||||
}))
|
}))
|
||||||
"
|
"
|
||||||
|
equal-width
|
||||||
/>
|
/>
|
||||||
</FormRow>
|
</FormRow>
|
||||||
<FormRow id="telemetryEnabled" :label="$t('settings.telemetry.label')">
|
<FormRow id="telemetryEnabled" :label="$t('settings.telemetry.label')">
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,7 @@
|
||||||
{ name: '2-phases', value: '2' },
|
{ name: '2-phases', value: '2' },
|
||||||
{ name: '3-phases', value: undefined },
|
{ name: '3-phases', value: undefined },
|
||||||
]"
|
]"
|
||||||
|
equal-width
|
||||||
/>
|
/>
|
||||||
</FormRow>
|
</FormRow>
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
|
|
|
||||||
18
assets/js/components/IconSelectGroup.vue
Normal file
18
assets/js/components/IconSelectGroup.vue
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<template>
|
||||||
|
<div class="root border d-flex gap-1" role="group">
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: "IconSelectGroup",
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.root {
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
54
assets/js/components/IconSelectItem.vue
Normal file
54
assets/js/components/IconSelectItem.vue
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn gap-2 btn-sm"
|
||||||
|
:class="{ active, withLabel: !!label }"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="$emit('click', value)"
|
||||||
|
>
|
||||||
|
<slot></slot>
|
||||||
|
<span v-if="label" class="text-nowrap text-truncate">
|
||||||
|
{{ label }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: "IconSelectItem",
|
||||||
|
emits: ["click"],
|
||||||
|
props: {
|
||||||
|
value: String,
|
||||||
|
active: Boolean,
|
||||||
|
label: String,
|
||||||
|
disabled: Boolean,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 2rem;
|
||||||
|
color: var(--evcc-default-text);
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.btn:hover {
|
||||||
|
color: var(--evcc-gray);
|
||||||
|
}
|
||||||
|
.btn.active {
|
||||||
|
color: var(--evcc-background);
|
||||||
|
background: var(--evcc-default-text);
|
||||||
|
}
|
||||||
|
.btn.withLabel {
|
||||||
|
width: auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
17
assets/js/components/MaterialIcon/Total.vue
Normal file
17
assets/js/components/MaterialIcon/Total.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<template>
|
||||||
|
<svg :style="svgStyle" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4.83 4.61A1 1 0 0 1 5.75 4h12.5a1 1 0 1 1 0 2H8.11l4.95 5.115a1 1 0 0 1 .04 1.346L7.924 18.5H18.25a1 1 0 1 1 0 2H5.75a1 1 0 0 1-.76-1.65l5.999-6.999L5.03 5.695a1 1 0 0 1-.2-1.085"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import icon from "../../mixins/icon";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "Total",
|
||||||
|
mixins: [icon],
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="mode-group border d-inline-flex" role="group">
|
<div class="mode-group border d-inline-flex" :class="{ large }" role="group">
|
||||||
<button
|
<button
|
||||||
v-for="(option, i) in options"
|
v-for="(option, i) in options"
|
||||||
:id="i === 0 ? id : null"
|
:id="i === 0 ? id : null"
|
||||||
:key="option.value"
|
:key="option.value"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-sm flex-grow-1 flex-shrink-1"
|
class="btn btn-sm flex-grow-1 flex-shrink-1"
|
||||||
:class="{ active: option.value === modelValue }"
|
:class="{ active: option.value === modelValue, 'btn--equal': equalWidth }"
|
||||||
:disabled="option.disabled"
|
:disabled="option.disabled"
|
||||||
@click="$emit('update:modelValue', option.value)"
|
@click="$emit('update:modelValue', option.value)"
|
||||||
>
|
>
|
||||||
|
|
@ -22,6 +22,8 @@ export default {
|
||||||
id: String,
|
id: String,
|
||||||
options: Array,
|
options: Array,
|
||||||
modelValue: [Number, String],
|
modelValue: [Number, String],
|
||||||
|
equalWidth: Boolean,
|
||||||
|
large: Boolean,
|
||||||
},
|
},
|
||||||
emits: ["update:modelValue"],
|
emits: ["update:modelValue"],
|
||||||
};
|
};
|
||||||
|
|
@ -32,11 +34,10 @@ export default {
|
||||||
border: 2px solid var(--evcc-default-text);
|
border: 2px solid var(--evcc-default-text);
|
||||||
border-radius: 17px;
|
border-radius: 17px;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
|
background: var(--evcc-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
/* equal width buttons */
|
|
||||||
flex-basis: 0;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 0.1em 0.8em;
|
padding: 0.1em 0.8em;
|
||||||
|
|
@ -45,6 +46,9 @@ export default {
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
.btn--equal {
|
||||||
|
flex-basis: 0;
|
||||||
|
}
|
||||||
.btn:hover {
|
.btn:hover {
|
||||||
color: var(--evcc-gray);
|
color: var(--evcc-gray);
|
||||||
}
|
}
|
||||||
|
|
@ -52,7 +56,11 @@ export default {
|
||||||
color: var(--evcc-background);
|
color: var(--evcc-background);
|
||||||
background: var(--evcc-default-text);
|
background: var(--evcc-default-text);
|
||||||
}
|
}
|
||||||
.btn-group {
|
.modal-group.large {
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
.large .btn {
|
||||||
|
height: 32px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
141
assets/js/components/Sessions/AvgCostGroupedChart.vue
Normal file
141
assets/js/components/Sessions/AvgCostGroupedChart.vue
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
<template>
|
||||||
|
<div v-if="chartData.labels.length > 1" class="row">
|
||||||
|
<div class="col-12 col-md-6 mb-3">
|
||||||
|
<PolarArea :data="chartData" :options="options" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6 d-flex align-items-center py-0 py-md-3">
|
||||||
|
<LegendList :legends="legends" grid />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { PolarArea } from "vue-chartjs";
|
||||||
|
import { RadialLinearScale, ArcElement, Legend, Tooltip } from "chart.js";
|
||||||
|
import { registerChartComponents, commonOptions, tooltipLabelColor } from "./chartConfig";
|
||||||
|
import formatter from "../../mixins/formatter";
|
||||||
|
import colors, { dimColor } from "../../colors";
|
||||||
|
import { TYPES, GROUPS } from "./types";
|
||||||
|
import LegendList from "./LegendList.vue";
|
||||||
|
|
||||||
|
registerChartComponents([RadialLinearScale, ArcElement, Legend, Tooltip]);
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "AvgCostGroupedChart",
|
||||||
|
components: { PolarArea, LegendList },
|
||||||
|
props: {
|
||||||
|
sessions: { type: Array, default: () => [] },
|
||||||
|
currency: { type: String, default: "EUR" },
|
||||||
|
groupBy: { type: String, default: GROUPS.LOADPOINT },
|
||||||
|
colorMappings: { type: Object, default: () => ({ loadpoint: {}, vehicle: {} }) },
|
||||||
|
suggestedMax: { type: Number, default: 0 },
|
||||||
|
costType: { type: String, default: TYPES.PRICE },
|
||||||
|
},
|
||||||
|
mixins: [formatter],
|
||||||
|
computed: {
|
||||||
|
chartData() {
|
||||||
|
console.log(`update ${this.costType} grouped data`);
|
||||||
|
const aggregatedData = {};
|
||||||
|
|
||||||
|
this.sessions.forEach((session) => {
|
||||||
|
const groupKey = session[this.groupBy];
|
||||||
|
if (!aggregatedData[groupKey]) {
|
||||||
|
aggregatedData[groupKey] = { energy: 0, cost: 0 };
|
||||||
|
}
|
||||||
|
const chargedEnergy = session.chargedEnergy;
|
||||||
|
if (this.costType === TYPES.CO2) {
|
||||||
|
aggregatedData[groupKey].energy += chargedEnergy;
|
||||||
|
aggregatedData[groupKey].cost += session.co2PerKWh * chargedEnergy;
|
||||||
|
} else if (this.costType === TYPES.PRICE) {
|
||||||
|
aggregatedData[groupKey].energy += chargedEnergy;
|
||||||
|
aggregatedData[groupKey].cost += session.price;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedEntries = Object.entries(aggregatedData).sort(
|
||||||
|
(a, b) => b[1].cost - a[1].cost
|
||||||
|
);
|
||||||
|
const labels = sortedEntries.map(([label]) => label);
|
||||||
|
const data = sortedEntries.map(([, value]) => value.cost / value.energy);
|
||||||
|
|
||||||
|
const borderColors = labels.map((label) => this.colorMappings[this.groupBy][label]);
|
||||||
|
const backgroundColors = borderColors.map((color) => dimColor(color));
|
||||||
|
return {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: data,
|
||||||
|
borderColor: borderColors,
|
||||||
|
backgroundColor: backgroundColors,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
legends() {
|
||||||
|
return this.chartData.labels.map((label, index) => ({
|
||||||
|
label: label,
|
||||||
|
color: this.chartData.datasets[0].borderColor[index],
|
||||||
|
value: this.formatValue(this.chartData.datasets[0].data[index]),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
options() {
|
||||||
|
return {
|
||||||
|
...commonOptions,
|
||||||
|
locale: this.$i18n?.locale,
|
||||||
|
aspectRatio: 1,
|
||||||
|
borderRadius: 8,
|
||||||
|
borderWidth: 3,
|
||||||
|
color: colors.text,
|
||||||
|
spacing: 0,
|
||||||
|
radius: "100%",
|
||||||
|
plugins: {
|
||||||
|
...commonOptions.plugins,
|
||||||
|
tooltip: {
|
||||||
|
...commonOptions.plugins.tooltip,
|
||||||
|
intersect: false,
|
||||||
|
mode: "index",
|
||||||
|
position: "topBottomCenter",
|
||||||
|
callbacks: {
|
||||||
|
title: () => null,
|
||||||
|
label: (tooltipItem) => {
|
||||||
|
const { label, raw = 0 } = tooltipItem;
|
||||||
|
return (
|
||||||
|
label +
|
||||||
|
": " +
|
||||||
|
(this.costType === TYPES.CO2
|
||||||
|
? this.fmtCo2Long(raw)
|
||||||
|
: this.fmtPricePerKWh(raw, this.currency))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
labelColor: tooltipLabelColor(true),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
r: {
|
||||||
|
suggestedMin: 0,
|
||||||
|
suggestedMax: this.suggestedMax,
|
||||||
|
beginAtZero: false,
|
||||||
|
ticks: {
|
||||||
|
color: colors.muted,
|
||||||
|
backdropColor: colors.background,
|
||||||
|
font: { size: 10 },
|
||||||
|
callback: this.formatValue,
|
||||||
|
maxTicksLimit: 6,
|
||||||
|
},
|
||||||
|
angleLines: { display: false },
|
||||||
|
grid: { color: colors.border },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
formatValue(value) {
|
||||||
|
return this.costType === TYPES.CO2
|
||||||
|
? this.fmtCo2Short(value)
|
||||||
|
: this.fmtPricePerKWh(value, this.currency, true);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
110
assets/js/components/Sessions/CostGroupedChart.vue
Normal file
110
assets/js/components/Sessions/CostGroupedChart.vue
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
<template>
|
||||||
|
<div class="row" v-if="chartData.labels.length > 1">
|
||||||
|
<div class="col-12 col-md-6 mb-3">
|
||||||
|
<Doughnut :data="chartData" :options="options" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6 d-flex align-items-center">
|
||||||
|
<LegendList :legends="legends" grid />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { Doughnut } from "vue-chartjs";
|
||||||
|
import { DoughnutController, ArcElement, LinearScale, Legend, Tooltip } from "chart.js";
|
||||||
|
import LegendList from "./LegendList.vue";
|
||||||
|
import { registerChartComponents, commonOptions, tooltipLabelColor } from "./chartConfig";
|
||||||
|
import formatter from "../../mixins/formatter";
|
||||||
|
import colors from "../../colors";
|
||||||
|
import { TYPES, GROUPS } from "./types";
|
||||||
|
|
||||||
|
registerChartComponents([DoughnutController, ArcElement, LinearScale, Legend, Tooltip]);
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "CostGroupedChart",
|
||||||
|
components: { Doughnut, LegendList },
|
||||||
|
props: {
|
||||||
|
sessions: { type: Array, default: () => [] },
|
||||||
|
groupBy: { type: String, default: GROUPS.LOADPOINT },
|
||||||
|
colorMappings: { type: Object, default: () => ({ loadpoint: {}, vehicle: {} }) },
|
||||||
|
currency: { type: String, default: "EUR" },
|
||||||
|
costType: { type: String, default: TYPES.PRICE },
|
||||||
|
},
|
||||||
|
mixins: [formatter],
|
||||||
|
computed: {
|
||||||
|
chartData() {
|
||||||
|
console.log(`update ${this.costType} grouped data`);
|
||||||
|
const aggregatedData = {};
|
||||||
|
|
||||||
|
this.sessions.forEach((session) => {
|
||||||
|
const groupKey = session[this.groupBy];
|
||||||
|
if (!aggregatedData[groupKey]) {
|
||||||
|
aggregatedData[groupKey] = 0;
|
||||||
|
}
|
||||||
|
if (this.costType === TYPES.PRICE) {
|
||||||
|
aggregatedData[groupKey] += session.price;
|
||||||
|
} else if (this.costType === TYPES.CO2) {
|
||||||
|
aggregatedData[groupKey] += session.co2PerKWh * session.chargedEnergy;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedEntries = Object.entries(aggregatedData).sort((a, b) => b[1] - a[1]);
|
||||||
|
const labels = sortedEntries.map(([label]) => label);
|
||||||
|
const data = sortedEntries.map(([, value]) => value);
|
||||||
|
const backgroundColor = labels.map((label) => this.colorMappings[this.groupBy][label]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [{ data, backgroundColor }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
legends() {
|
||||||
|
const total = this.chartData.datasets[0].data.reduce((acc, curr) => acc + curr, 0);
|
||||||
|
const fmtShare = (value) => this.fmtPercentage((100 / total) * value, 1);
|
||||||
|
return this.chartData.labels.map((label, index) => ({
|
||||||
|
label: label,
|
||||||
|
color: this.chartData.datasets[0].backgroundColor[index],
|
||||||
|
value: [
|
||||||
|
this.formatValue(this.chartData.datasets[0].data[index]),
|
||||||
|
fmtShare(this.chartData.datasets[0].data[index]),
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
options() {
|
||||||
|
return {
|
||||||
|
...commonOptions,
|
||||||
|
locale: this.$i18n?.locale,
|
||||||
|
aspectRatio: 1,
|
||||||
|
borderRadius: 10,
|
||||||
|
color: colors.text,
|
||||||
|
borderWidth: 3,
|
||||||
|
borderColor: colors.background,
|
||||||
|
cutout: "70%",
|
||||||
|
radius: "95%",
|
||||||
|
animation: { duration: 250 },
|
||||||
|
plugins: {
|
||||||
|
...commonOptions.plugins,
|
||||||
|
tooltip: {
|
||||||
|
...commonOptions.plugins.tooltip,
|
||||||
|
mode: "index",
|
||||||
|
position: "center",
|
||||||
|
intersect: false,
|
||||||
|
callbacks: {
|
||||||
|
label: (tooltipItem) => this.formatValue(tooltipItem.raw || 0),
|
||||||
|
labelColor: tooltipLabelColor(false),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
formatValue(value) {
|
||||||
|
if (this.costType === TYPES.PRICE) {
|
||||||
|
return this.fmtMoney(value, this.currency, true, true);
|
||||||
|
}
|
||||||
|
return this.fmtGrams(value);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
321
assets/js/components/Sessions/CostHistoryChart.vue
Normal file
321
assets/js/components/Sessions/CostHistoryChart.vue
Normal file
|
|
@ -0,0 +1,321 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div style="position: relative; height: 300px" class="my-3">
|
||||||
|
<Bar :data="chartData" :options="options" />
|
||||||
|
</div>
|
||||||
|
<LegendList :legends="legends" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { Bar } from "vue-chartjs";
|
||||||
|
import { BarController, BarElement, CategoryScale, LinearScale, Legend, Tooltip } from "chart.js";
|
||||||
|
import { registerChartComponents, commonOptions, tooltipLabelColor } from "./chartConfig";
|
||||||
|
import LegendList from "./LegendList.vue";
|
||||||
|
import formatter from "../../mixins/formatter";
|
||||||
|
import colors from "../../colors";
|
||||||
|
import { TYPES, GROUPS, PERIODS } from "./types";
|
||||||
|
|
||||||
|
registerChartComponents([BarController, BarElement, CategoryScale, LinearScale, Legend, Tooltip]);
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "CostHistoryChart",
|
||||||
|
components: { Bar, LegendList },
|
||||||
|
props: {
|
||||||
|
sessions: { type: Array, default: () => [] },
|
||||||
|
groupBy: { type: String, default: GROUPS.NONE },
|
||||||
|
costType: { type: String, default: TYPES.PRICE },
|
||||||
|
period: { type: String, default: PERIODS.TOTAL },
|
||||||
|
currency: { type: String, default: "EUR" },
|
||||||
|
colorMappings: { type: Object, default: () => ({ loadpoint: {}, vehicle: {} }) },
|
||||||
|
suggestedMaxCost: { type: Number, default: 0 },
|
||||||
|
},
|
||||||
|
mixins: [formatter],
|
||||||
|
computed: {
|
||||||
|
firstDay() {
|
||||||
|
if (this.sessions.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new Date(this.sessions[0].created);
|
||||||
|
},
|
||||||
|
month() {
|
||||||
|
return this.firstDay?.getMonth() + 1;
|
||||||
|
},
|
||||||
|
year() {
|
||||||
|
return this.firstDay?.getFullYear();
|
||||||
|
},
|
||||||
|
lastDay() {
|
||||||
|
if (this.sessions.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new Date(this.sessions[this.sessions.length - 1].created);
|
||||||
|
},
|
||||||
|
chartData() {
|
||||||
|
console.log("update cost history data");
|
||||||
|
const result = {};
|
||||||
|
const groups = new Set();
|
||||||
|
|
||||||
|
if (this.sessions.length > 0) {
|
||||||
|
//const lastDay = new Date(this.year, this.month, 0);
|
||||||
|
//const daysInMonth = this.lastDay.getDate();
|
||||||
|
let xFrom, xTo;
|
||||||
|
if (this.period === PERIODS.TOTAL) {
|
||||||
|
xFrom = this.firstDay.getFullYear();
|
||||||
|
xTo = this.lastDay.getFullYear();
|
||||||
|
} else if (this.period === PERIODS.YEAR) {
|
||||||
|
xFrom = 1;
|
||||||
|
xTo = 12;
|
||||||
|
} else {
|
||||||
|
xFrom = 1;
|
||||||
|
xTo = new Date(
|
||||||
|
this.lastDay.getFullYear(),
|
||||||
|
this.lastDay.getMonth() + 1,
|
||||||
|
0
|
||||||
|
).getDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
// initialize result with empty arrays
|
||||||
|
for (let i = xFrom; i <= xTo; i++) {
|
||||||
|
result[i] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate with actual data
|
||||||
|
this.sessions.forEach((session) => {
|
||||||
|
let index;
|
||||||
|
const date = new Date(session.created);
|
||||||
|
if (this.period === PERIODS.MONTH) {
|
||||||
|
index = date.getDate();
|
||||||
|
} else if (this.period === PERIODS.YEAR) {
|
||||||
|
index = date.getMonth() + 1;
|
||||||
|
} else {
|
||||||
|
index = date.getFullYear();
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupKey =
|
||||||
|
this.groupBy === GROUPS.NONE ? this.costType : session[this.groupBy];
|
||||||
|
groups.add(groupKey);
|
||||||
|
|
||||||
|
const value =
|
||||||
|
this.costType === TYPES.PRICE
|
||||||
|
? session.price
|
||||||
|
: session.co2PerKWh * session.chargedEnergy;
|
||||||
|
|
||||||
|
result[index][groupKey] = (result[index][groupKey] || 0) + value;
|
||||||
|
|
||||||
|
result[index].totalCost = (result[index].totalCost || 0) + value;
|
||||||
|
result[index].totalKWh = (result[index].totalKWh || 0) + session.chargedEnergy;
|
||||||
|
result[index].avgCost = result[index].totalCost / result[index].totalKWh;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const datasets = Array.from(groups).map((group) => {
|
||||||
|
const colorGroup = this.groupBy === GROUPS.NONE ? "cost" : this.groupBy;
|
||||||
|
const backgroundColor = this.colorMappings[colorGroup][group];
|
||||||
|
const label =
|
||||||
|
this.groupBy === GROUPS.NONE ? this.$t(`sessions.group.${group}`) : group;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "bar",
|
||||||
|
backgroundColor,
|
||||||
|
label,
|
||||||
|
data: Object.values(result).map((index) => index[group] || 0),
|
||||||
|
borderRadius: (context) => {
|
||||||
|
const threshold = 0.04; // 400 Wh
|
||||||
|
const { dataIndex, datasetIndex } = context;
|
||||||
|
const currentValue = context.dataset.data[dataIndex];
|
||||||
|
const previousValuesExist = context.chart.data.datasets
|
||||||
|
.filter((dataset) => dataset.type === "bar")
|
||||||
|
.slice(datasetIndex + 1)
|
||||||
|
.some((dataset) => (dataset?.data[dataIndex] || 0) > threshold);
|
||||||
|
return currentValue > threshold && !previousValuesExist
|
||||||
|
? { topLeft: 10, topRight: 10 }
|
||||||
|
: { topLeft: 0, topRight: 0 };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// add average price line
|
||||||
|
const costColor = this.costType === TYPES.PRICE ? colors.pricePerKWh : colors.co2PerKWh;
|
||||||
|
datasets.push({
|
||||||
|
type: "line",
|
||||||
|
label:
|
||||||
|
this.costType === TYPES.PRICE
|
||||||
|
? this.pricePerKWhUnit(this.currency, false)
|
||||||
|
: "gCO₂e/kWh",
|
||||||
|
data: Object.values(result).map((index) => index.avgCost || null),
|
||||||
|
yAxisID: "y1",
|
||||||
|
tension: 0.25,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 6,
|
||||||
|
borderColor: costColor,
|
||||||
|
backgroundColor: costColor,
|
||||||
|
borderWidth: 2,
|
||||||
|
spanGaps: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels: Object.keys(result),
|
||||||
|
datasets: datasets,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
legends() {
|
||||||
|
return this.chartData.datasets.map((dataset) => {
|
||||||
|
let value = null;
|
||||||
|
|
||||||
|
// line chart handling
|
||||||
|
if (dataset.type === "line") {
|
||||||
|
const items = dataset.data.filter((v) => v !== null);
|
||||||
|
const min = Math.min(...items);
|
||||||
|
const max = Math.max(...items);
|
||||||
|
const format = (value, withUnit) => {
|
||||||
|
return this.costType === TYPES.PRICE
|
||||||
|
? this.fmtPricePerKWh(value, this.currency, true, withUnit)
|
||||||
|
: this.fmtGrams(value, withUnit);
|
||||||
|
};
|
||||||
|
value = `${format(min, false)} – ${format(max, true)}`;
|
||||||
|
} else {
|
||||||
|
const total = dataset.data.reduce((acc, curr) => acc + curr, 0);
|
||||||
|
value =
|
||||||
|
this.costType === TYPES.PRICE
|
||||||
|
? this.fmtMoney(total, this.currency, true, true)
|
||||||
|
: this.fmtGrams(total);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
label: dataset.label,
|
||||||
|
color: dataset.backgroundColor,
|
||||||
|
value,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
options() {
|
||||||
|
return {
|
||||||
|
...commonOptions,
|
||||||
|
locale: this.$i18n?.locale,
|
||||||
|
color: colors.text,
|
||||||
|
borderSkipped: false,
|
||||||
|
maxBarThickness: 40,
|
||||||
|
animation: false,
|
||||||
|
plugins: {
|
||||||
|
...commonOptions.plugins,
|
||||||
|
tooltip: {
|
||||||
|
...commonOptions.plugins.tooltip,
|
||||||
|
mode: "index",
|
||||||
|
intersect: false,
|
||||||
|
positioner: (context) => {
|
||||||
|
const { chart, tooltipPosition } = context;
|
||||||
|
const { tooltip } = chart;
|
||||||
|
const { width, height } = tooltip;
|
||||||
|
const { x, y } = tooltipPosition();
|
||||||
|
const { innerWidth, innerHeight } = window;
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: Math.min(x, innerWidth - width),
|
||||||
|
y: Math.min(y, innerHeight - height),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
callbacks: {
|
||||||
|
title: (tooltipItem) => {
|
||||||
|
const { label } = tooltipItem[0];
|
||||||
|
if (this.period === PERIODS.TOTAL) {
|
||||||
|
return label;
|
||||||
|
} else if (this.period === PERIODS.YEAR) {
|
||||||
|
const date = new Date(this.year, label - 1, 1);
|
||||||
|
return this.fmtMonth(date);
|
||||||
|
} else {
|
||||||
|
const date = new Date(this.year, this.month, label);
|
||||||
|
return this.fmtDayMonth(date);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label: (tooltipItem) => {
|
||||||
|
const datasetLabel = tooltipItem.dataset.label || "";
|
||||||
|
const value = tooltipItem.raw;
|
||||||
|
|
||||||
|
// line datasets have null values
|
||||||
|
if (tooltipItem.dataset.type === "line") {
|
||||||
|
if (value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
"⌀ " +
|
||||||
|
(this.costType === TYPES.PRICE
|
||||||
|
? this.fmtPricePerKWh(value, this.currency, false)
|
||||||
|
: this.fmtCo2Medium(value))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
datasetLabel +
|
||||||
|
": " +
|
||||||
|
(this.costType === TYPES.PRICE
|
||||||
|
? this.fmtMoney(value || 0, this.currency, true, true)
|
||||||
|
: this.fmtGrams(value || 0))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
labelColor: tooltipLabelColor(false),
|
||||||
|
labelTextColor: (item) => {
|
||||||
|
return !item.raw ? colors.muted : "#fff";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
itemSort: function (a, b) {
|
||||||
|
return b.datasetIndex - a.datasetIndex;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
stacked: true,
|
||||||
|
border: { display: false },
|
||||||
|
grid: { display: false },
|
||||||
|
ticks: { color: colors.muted },
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
stacked: true,
|
||||||
|
position: "right",
|
||||||
|
border: { display: false },
|
||||||
|
grid: { color: colors.border },
|
||||||
|
title: {
|
||||||
|
text: "kgCO₂e",
|
||||||
|
display: this.costType === TYPES.CO2,
|
||||||
|
color: colors.muted,
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
callback: (value) =>
|
||||||
|
this.costType === TYPES.PRICE
|
||||||
|
? this.fmtMoney(value, this.currency, false, true)
|
||||||
|
: this.fmtNumber(value / 1e3, 0),
|
||||||
|
color: colors.muted,
|
||||||
|
maxTicksLimit: 6,
|
||||||
|
},
|
||||||
|
min: 0,
|
||||||
|
},
|
||||||
|
y1: {
|
||||||
|
position: "left",
|
||||||
|
border: { display: false },
|
||||||
|
suggestedMax: this.suggestedMaxCost,
|
||||||
|
grid: {
|
||||||
|
drawOnChartArea: false,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
text:
|
||||||
|
this.costType === TYPES.CO2
|
||||||
|
? "gCO₂e/kWh"
|
||||||
|
: this.pricePerKWhUnit(this.currency, false),
|
||||||
|
display: true,
|
||||||
|
color: colors.muted,
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
callback: (value) =>
|
||||||
|
this.costType === TYPES.PRICE
|
||||||
|
? this.fmtPricePerKWh(value, this.currency, false, false)
|
||||||
|
: this.fmtNumber(value, 0),
|
||||||
|
color: colors.muted,
|
||||||
|
maxTicksLimit: 6,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
215
assets/js/components/Sessions/DateNavigator.vue
Normal file
215
assets/js/components/Sessions/DateNavigator.vue
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="d-sm-flex justify-content-lg-end gap-lg-4"
|
||||||
|
:class="showMonth ? 'justify-content-between' : 'justify-content-end'"
|
||||||
|
>
|
||||||
|
<div v-if="showMonth" class="d-none d-sm-flex justify-content-between">
|
||||||
|
<DateNavigatorButton
|
||||||
|
prev
|
||||||
|
:disabled="!hasPrevMonth"
|
||||||
|
:onClick="emitPrevMonth"
|
||||||
|
data-testid="navigate-prev-month"
|
||||||
|
/>
|
||||||
|
<CustomSelect
|
||||||
|
id="sessionsMonth"
|
||||||
|
:options="monthOptions"
|
||||||
|
:selected="month"
|
||||||
|
@change="emitMonth($event.target.value)"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm border-0 text-truncate h-100"
|
||||||
|
style="width: 8em"
|
||||||
|
data-testid="navigate-month"
|
||||||
|
>
|
||||||
|
{{ monthName }}
|
||||||
|
</button>
|
||||||
|
</CustomSelect>
|
||||||
|
<DateNavigatorButton
|
||||||
|
next
|
||||||
|
:disabled="!hasNextMonth"
|
||||||
|
:onClick="emitNextMonth"
|
||||||
|
data-testid="navigate-next-month"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="showMonth" class="d-flex d-sm-none justify-content-between">
|
||||||
|
<DateNavigatorButton
|
||||||
|
prev
|
||||||
|
:disabled="!hasPrevMonth"
|
||||||
|
:onClick="emitPrevMonth"
|
||||||
|
data-testid="navigate-prev-year-month"
|
||||||
|
/>
|
||||||
|
<CustomSelect
|
||||||
|
id="sessionsMonthYear"
|
||||||
|
:options="monthYearOptions"
|
||||||
|
:selected="month"
|
||||||
|
@change="emitMonthYear($event.target.value)"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm border-0 h-100 text-truncate"
|
||||||
|
data-testid="navigate-month-year"
|
||||||
|
>
|
||||||
|
{{ monthYearName }}
|
||||||
|
</button>
|
||||||
|
</CustomSelect>
|
||||||
|
<DateNavigatorButton
|
||||||
|
next
|
||||||
|
:disabled="!hasNextMonth"
|
||||||
|
:onClick="emitNextMonth"
|
||||||
|
data-testid="navigate-next-year-month"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="showYear"
|
||||||
|
class="justify-content-between"
|
||||||
|
:class="showMonth ? 'd-none d-sm-flex' : 'd-flex'"
|
||||||
|
>
|
||||||
|
<DateNavigatorButton
|
||||||
|
prev
|
||||||
|
:disabled="!hasPrevYear"
|
||||||
|
:onClick="emitPrevYear"
|
||||||
|
data-testid="navigate-prev-year"
|
||||||
|
/>
|
||||||
|
<CustomSelect
|
||||||
|
id="sessionsYear"
|
||||||
|
:options="yearOptions"
|
||||||
|
:selected="year"
|
||||||
|
@change="emitYear($event.target.value)"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm border-0 h-100"
|
||||||
|
style="width: 4em"
|
||||||
|
data-testid="navigate-year"
|
||||||
|
>
|
||||||
|
{{ year }}
|
||||||
|
</button>
|
||||||
|
</CustomSelect>
|
||||||
|
<DateNavigatorButton
|
||||||
|
next
|
||||||
|
:disabled="!hasNextYear"
|
||||||
|
:onClick="emitNextYear"
|
||||||
|
data-testid="navigate-next-year"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import CustomSelect from "../CustomSelect.vue";
|
||||||
|
import DateNavigatorButton from "./DateNavigatorButton.vue";
|
||||||
|
import formatter from "../../mixins/formatter";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "DateNavigator",
|
||||||
|
components: {
|
||||||
|
CustomSelect,
|
||||||
|
DateNavigatorButton,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
month: Number,
|
||||||
|
year: Number,
|
||||||
|
startDate: Date,
|
||||||
|
showMonth: Boolean,
|
||||||
|
showYear: Boolean,
|
||||||
|
},
|
||||||
|
mixins: [formatter],
|
||||||
|
emits: ["update-date"],
|
||||||
|
computed: {
|
||||||
|
hasPrevMonth() {
|
||||||
|
return (
|
||||||
|
this.year > this.startDate.getFullYear() ||
|
||||||
|
this.month > this.startDate.getMonth() + 1
|
||||||
|
);
|
||||||
|
},
|
||||||
|
hasNextMonth() {
|
||||||
|
const now = new Date();
|
||||||
|
return this.year < now.getFullYear() || this.month < now.getMonth() + 1;
|
||||||
|
},
|
||||||
|
hasPrevYear() {
|
||||||
|
return this.year > this.startDate.getFullYear();
|
||||||
|
},
|
||||||
|
hasNextYear() {
|
||||||
|
return this.year < new Date().getFullYear();
|
||||||
|
},
|
||||||
|
monthOptions() {
|
||||||
|
return Array.from({ length: 12 }, (_, i) => i + 1).map((month) => ({
|
||||||
|
name: this.fmtMonth(new Date(this.year, month - 1, 1)),
|
||||||
|
value: month,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
monthYearOptions() {
|
||||||
|
const first = this.startDate;
|
||||||
|
const last = new Date();
|
||||||
|
const yearMonths = [];
|
||||||
|
for (let year = first.getFullYear(); year <= last.getFullYear(); year++) {
|
||||||
|
const startMonth = year === first.getFullYear() ? first.getMonth() + 1 : 1;
|
||||||
|
const endMonth = year === last.getFullYear() ? last.getMonth() + 1 : 12;
|
||||||
|
for (let month = startMonth; month <= endMonth; month++) {
|
||||||
|
yearMonths.push({
|
||||||
|
name: this.fmtMonthYear(new Date(year, month - 1, 1)),
|
||||||
|
value: `${year}-${month}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return yearMonths;
|
||||||
|
},
|
||||||
|
yearOptions() {
|
||||||
|
const first = this.startDate;
|
||||||
|
const last = new Date();
|
||||||
|
const years = [];
|
||||||
|
for (let year = first.getFullYear(); year <= last.getFullYear(); year++) {
|
||||||
|
years.push({ name: year, value: year });
|
||||||
|
}
|
||||||
|
return years;
|
||||||
|
},
|
||||||
|
monthName() {
|
||||||
|
const date = new Date();
|
||||||
|
date.setMonth(this.month - 1, 1);
|
||||||
|
return this.fmtMonth(date);
|
||||||
|
},
|
||||||
|
monthYearName() {
|
||||||
|
const date = new Date();
|
||||||
|
date.setMonth(this.month - 1, 1);
|
||||||
|
date.setFullYear(this.year);
|
||||||
|
return this.fmtMonthYear(date);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
emitPrevMonth() {
|
||||||
|
const prevMonthDate = new Date(this.year, this.month - 2, 1);
|
||||||
|
this.$emit("update-date", {
|
||||||
|
year: prevMonthDate.getFullYear(),
|
||||||
|
month: prevMonthDate.getMonth() + 1,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
emitNextMonth() {
|
||||||
|
const nextMonthDate = new Date(this.year, this.month, 1);
|
||||||
|
this.$emit("update-date", {
|
||||||
|
year: nextMonthDate.getFullYear(),
|
||||||
|
month: nextMonthDate.getMonth() + 1,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
emitPrevYear() {
|
||||||
|
this.$emit("update-date", { year: this.year - 1, month: undefined });
|
||||||
|
},
|
||||||
|
emitNextYear() {
|
||||||
|
this.$emit("update-date", { year: this.year + 1, month: undefined });
|
||||||
|
},
|
||||||
|
emitMonth(month) {
|
||||||
|
this.$emit("update-date", { year: this.year, month });
|
||||||
|
},
|
||||||
|
emitMonthYear(monthYear) {
|
||||||
|
const [year, month] = monthYear.split("-");
|
||||||
|
this.$emit("update-date", { year: parseInt(year), month: parseInt(month) });
|
||||||
|
},
|
||||||
|
emitYear(year) {
|
||||||
|
this.$emit("update-date", { year, month: undefined });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.btn {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
41
assets/js/components/Sessions/DateNavigatorButton.vue
Normal file
41
assets/js/components/Sessions/DateNavigatorButton.vue
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
<template>
|
||||||
|
<button class="btn btn-sm border-0" :disabled="disabled" @click="onClick">
|
||||||
|
<component :is="icon" size="s" :class="iconClass"></component>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import "@h2d2/shopicons/es/regular/angledoubleleftsmall";
|
||||||
|
import "@h2d2/shopicons/es/regular/angledoublerightsmall";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "DateNavigatorButton",
|
||||||
|
props: {
|
||||||
|
disabled: Boolean,
|
||||||
|
prev: Boolean,
|
||||||
|
next: Boolean,
|
||||||
|
onClick: Function,
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
icon() {
|
||||||
|
if (this.prev) {
|
||||||
|
return "shopicon-regular-angledoubleleftsmall";
|
||||||
|
} else if (this.next) {
|
||||||
|
return "shopicon-regular-angledoublerightsmall";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
iconClass() {
|
||||||
|
return this.prev ? "me-1" : this.next ? "ms-1" : "";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.btn,
|
||||||
|
.btn:active,
|
||||||
|
.btn:focus {
|
||||||
|
color: inherit !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
123
assets/js/components/Sessions/EnergyGroupedChart.vue
Normal file
123
assets/js/components/Sessions/EnergyGroupedChart.vue
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
<template>
|
||||||
|
<div class="row" v-if="chartData.labels.length > 1">
|
||||||
|
<div class="col-12 col-md-6 mb-3">
|
||||||
|
<Doughnut :data="chartData" :options="options" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6 d-flex align-items-center">
|
||||||
|
<LegendList :legends="legends" grid />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { Doughnut } from "vue-chartjs";
|
||||||
|
import { DoughnutController, ArcElement, LinearScale, Legend, Tooltip } from "chart.js";
|
||||||
|
import LegendList from "./LegendList.vue";
|
||||||
|
import { registerChartComponents, commonOptions, tooltipLabelColor } from "./chartConfig";
|
||||||
|
import formatter, { POWER_UNIT } from "../../mixins/formatter";
|
||||||
|
import colors from "../../colors";
|
||||||
|
import { GROUPS } from "./types";
|
||||||
|
|
||||||
|
registerChartComponents([DoughnutController, ArcElement, LinearScale, Legend, Tooltip]);
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "EnergyGroupedChart",
|
||||||
|
components: { Doughnut, LegendList },
|
||||||
|
props: {
|
||||||
|
sessions: { type: Array, default: () => [] },
|
||||||
|
groupBy: { type: String, default: GROUPS.NONE },
|
||||||
|
colorMappings: { type: Object, default: () => ({ loadpoint: {}, vehicle: {}, solar: {} }) },
|
||||||
|
},
|
||||||
|
mixins: [formatter],
|
||||||
|
computed: {
|
||||||
|
chartData() {
|
||||||
|
console.log("update energy aggregate data");
|
||||||
|
const aggregatedData = {};
|
||||||
|
|
||||||
|
if (this.groupBy === GROUPS.NONE) {
|
||||||
|
const total = this.sessions.reduce((acc, s) => acc + s.chargedEnergy, 0);
|
||||||
|
const self = this.sessions.reduce(
|
||||||
|
(acc, s) => acc + (s.chargedEnergy / 100) * s.solarPercentage,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
aggregatedData.self = self;
|
||||||
|
aggregatedData.grid = total - self;
|
||||||
|
} else {
|
||||||
|
this.sessions.forEach((session) => {
|
||||||
|
const groupKey = session[this.groupBy];
|
||||||
|
if (!aggregatedData[groupKey]) {
|
||||||
|
aggregatedData[groupKey] = 0;
|
||||||
|
}
|
||||||
|
aggregatedData[groupKey] += session.chargedEnergy;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort the data by energy in descending order
|
||||||
|
const sortedEntries = Object.entries(aggregatedData); //.sort((a, b) => b[1] - a[1]);
|
||||||
|
|
||||||
|
const labels = sortedEntries.map(([label]) =>
|
||||||
|
this.groupBy === GROUPS.NONE ? this.$t(`sessions.group.${label}`) : label
|
||||||
|
);
|
||||||
|
const data = sortedEntries.map(([, value]) => value);
|
||||||
|
const colorGroup = this.groupBy === GROUPS.NONE ? "solar" : this.groupBy;
|
||||||
|
const backgroundColor = sortedEntries.map(
|
||||||
|
([label]) => this.colorMappings[colorGroup][label]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [{ data, backgroundColor }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
legends() {
|
||||||
|
const total = this.chartData.datasets[0].data.reduce((acc, curr) => acc + curr, 0);
|
||||||
|
const maxEnergy = Math.max(...this.chartData.datasets[0].data);
|
||||||
|
// sync energy units for label grid view
|
||||||
|
const unit =
|
||||||
|
maxEnergy < 1 ? POWER_UNIT.W : maxEnergy > 1e4 ? POWER_UNIT.MW : POWER_UNIT.KW;
|
||||||
|
const fmtShare = (value) => this.fmtPercentage((100 / total) * value, 1);
|
||||||
|
const fmtValue = (value) => this.fmtWh(value * 1e3, unit);
|
||||||
|
return this.chartData.labels.map((label, index) => ({
|
||||||
|
label: label,
|
||||||
|
color: this.chartData.datasets[0].backgroundColor[index],
|
||||||
|
value: [
|
||||||
|
fmtValue(this.chartData.datasets[0].data[index]),
|
||||||
|
fmtShare(this.chartData.datasets[0].data[index]),
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
options() {
|
||||||
|
return {
|
||||||
|
...commonOptions,
|
||||||
|
locale: this.$i18n?.locale,
|
||||||
|
aspectRatio: 1,
|
||||||
|
borderRadius: 10,
|
||||||
|
color: colors.text,
|
||||||
|
borderWidth: 3,
|
||||||
|
borderColor: colors.background,
|
||||||
|
cutout: "70%",
|
||||||
|
radius: "95%",
|
||||||
|
animation: { duration: 250 },
|
||||||
|
plugins: {
|
||||||
|
...commonOptions.plugins,
|
||||||
|
tooltip: {
|
||||||
|
...commonOptions.plugins.tooltip,
|
||||||
|
mode: "index",
|
||||||
|
position: "center",
|
||||||
|
intersect: false,
|
||||||
|
callbacks: {
|
||||||
|
label: (tooltipItem) => this.formatValue(tooltipItem.raw || 0),
|
||||||
|
labelColor: tooltipLabelColor(false),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
formatValue(value) {
|
||||||
|
return this.fmtWh(value * 1e3, POWER_UNIT.AUTO);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
237
assets/js/components/Sessions/EnergyHistoryChart.vue
Normal file
237
assets/js/components/Sessions/EnergyHistoryChart.vue
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div style="position: relative; height: 300px" class="my-3">
|
||||||
|
<Bar :data="chartData" :options="options" />
|
||||||
|
</div>
|
||||||
|
<LegendList :legends="legends" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { Bar } from "vue-chartjs";
|
||||||
|
import { BarController, BarElement, CategoryScale, LinearScale, Legend, Tooltip } from "chart.js";
|
||||||
|
import { registerChartComponents, commonOptions, tooltipLabelColor } from "./chartConfig";
|
||||||
|
import LegendList from "./LegendList.vue";
|
||||||
|
import formatter, { POWER_UNIT } from "../../mixins/formatter";
|
||||||
|
import colors from "../../colors";
|
||||||
|
import { GROUPS, PERIODS } from "./types";
|
||||||
|
|
||||||
|
registerChartComponents([BarController, BarElement, CategoryScale, LinearScale, Legend, Tooltip]);
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "EnergyHistoryChart",
|
||||||
|
components: { Bar, LegendList },
|
||||||
|
props: {
|
||||||
|
sessions: { type: Array, default: () => [] },
|
||||||
|
groupBy: { type: String, default: GROUPS.NONE },
|
||||||
|
period: { type: String, default: PERIODS.TOTAL },
|
||||||
|
colorMappings: { type: Object, default: () => ({ loadpoint: {}, vehicle: {} }) },
|
||||||
|
},
|
||||||
|
mixins: [formatter],
|
||||||
|
computed: {
|
||||||
|
firstDay() {
|
||||||
|
if (this.sessions.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new Date(this.sessions[0].created);
|
||||||
|
},
|
||||||
|
month() {
|
||||||
|
return this.firstDay?.getMonth() + 1;
|
||||||
|
},
|
||||||
|
year() {
|
||||||
|
return this.firstDay?.getFullYear();
|
||||||
|
},
|
||||||
|
lastDay() {
|
||||||
|
if (this.sessions.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new Date(this.sessions[this.sessions.length - 1].created);
|
||||||
|
},
|
||||||
|
chartData() {
|
||||||
|
console.log("update energy history data");
|
||||||
|
const result = {};
|
||||||
|
const groups = new Set();
|
||||||
|
|
||||||
|
if (this.sessions.length > 0) {
|
||||||
|
//const lastDay = new Date(this.year, this.month, 0);
|
||||||
|
//const daysInMonth = this.lastDay.getDate();
|
||||||
|
let xFrom, xTo;
|
||||||
|
if (this.period === PERIODS.TOTAL) {
|
||||||
|
xFrom = this.firstDay.getFullYear();
|
||||||
|
xTo = this.lastDay.getFullYear();
|
||||||
|
} else if (this.period === PERIODS.YEAR) {
|
||||||
|
xFrom = 1;
|
||||||
|
xTo = 12;
|
||||||
|
} else {
|
||||||
|
xFrom = 1;
|
||||||
|
xTo = new Date(
|
||||||
|
this.lastDay.getFullYear(),
|
||||||
|
this.lastDay.getMonth() + 1,
|
||||||
|
0
|
||||||
|
).getDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
// initialize result with empty arrays
|
||||||
|
for (let i = xFrom; i <= xTo; i++) {
|
||||||
|
result[i] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate with actual data
|
||||||
|
this.sessions.forEach((session) => {
|
||||||
|
let index;
|
||||||
|
const date = new Date(session.created);
|
||||||
|
if (this.period === PERIODS.MONTH) {
|
||||||
|
index = date.getDate();
|
||||||
|
} else if (this.period === PERIODS.YEAR) {
|
||||||
|
index = date.getMonth() + 1;
|
||||||
|
} else {
|
||||||
|
index = date.getFullYear();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.groupBy === GROUPS.NONE) {
|
||||||
|
groups.add("grid");
|
||||||
|
groups.add("self");
|
||||||
|
const charged = session.chargedEnergy;
|
||||||
|
const self = (charged / 100) * session.solarPercentage;
|
||||||
|
const grid = charged - self;
|
||||||
|
result[index].self = (result[index].self || 0) + self;
|
||||||
|
result[index].grid = (result[index].grid || 0) + grid;
|
||||||
|
} else {
|
||||||
|
const groupKey = session[this.groupBy];
|
||||||
|
groups.add(groupKey);
|
||||||
|
result[index][groupKey] =
|
||||||
|
(result[index][groupKey] || 0) + session.chargedEnergy;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const datasets = Array.from(groups).map((group) => {
|
||||||
|
const colorGroup = this.groupBy === GROUPS.NONE ? "solar" : this.groupBy;
|
||||||
|
const backgroundColor = this.colorMappings[colorGroup][group];
|
||||||
|
const label =
|
||||||
|
this.groupBy === GROUPS.NONE ? this.$t(`sessions.group.${group}`) : group;
|
||||||
|
|
||||||
|
return {
|
||||||
|
backgroundColor,
|
||||||
|
label,
|
||||||
|
data: Object.values(result).map((day) => day[group] || 0),
|
||||||
|
borderRadius: (context) => {
|
||||||
|
const threshold = 0.04; // 400 Wh
|
||||||
|
const { dataIndex, datasetIndex } = context;
|
||||||
|
const currentValue = context.dataset.data[dataIndex];
|
||||||
|
const previousValuesExist = context.chart.data.datasets
|
||||||
|
.slice(datasetIndex + 1)
|
||||||
|
.some((dataset) => (dataset?.data[dataIndex] || 0) > threshold);
|
||||||
|
return currentValue > threshold && !previousValuesExist
|
||||||
|
? { topLeft: 10, topRight: 10 }
|
||||||
|
: { topLeft: 0, topRight: 0 };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels: Object.keys(result),
|
||||||
|
datasets: datasets,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
legends() {
|
||||||
|
return this.chartData.datasets.map((dataset) => ({
|
||||||
|
label: dataset.label,
|
||||||
|
color: dataset.backgroundColor,
|
||||||
|
value: this.fmtWh(
|
||||||
|
dataset.data.reduce((acc, curr) => acc + curr, 0) * 1e3,
|
||||||
|
POWER_UNIT.AUTO
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
options() {
|
||||||
|
return {
|
||||||
|
...commonOptions,
|
||||||
|
locale: this.$i18n?.locale,
|
||||||
|
color: colors.text,
|
||||||
|
borderSkipped: false,
|
||||||
|
maxBarThickness: 40,
|
||||||
|
animation: false,
|
||||||
|
plugins: {
|
||||||
|
...commonOptions.plugins,
|
||||||
|
tooltip: {
|
||||||
|
...commonOptions.plugins.tooltip,
|
||||||
|
mode: "index",
|
||||||
|
intersect: false,
|
||||||
|
positioner: (context) => {
|
||||||
|
const { chart, tooltipPosition } = context;
|
||||||
|
const { tooltip } = chart;
|
||||||
|
const { width, height } = tooltip;
|
||||||
|
const { x, y } = tooltipPosition();
|
||||||
|
const { innerWidth, innerHeight } = window;
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: Math.min(x, innerWidth - width),
|
||||||
|
y: Math.min(y, innerHeight - height),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
callbacks: {
|
||||||
|
title: (tooltipItem) => {
|
||||||
|
const { label } = tooltipItem[0];
|
||||||
|
if (this.period === PERIODS.TOTAL) {
|
||||||
|
return label;
|
||||||
|
} else if (this.period === PERIODS.YEAR) {
|
||||||
|
const date = new Date(this.year, label - 1, 1);
|
||||||
|
return this.fmtMonth(date);
|
||||||
|
} else {
|
||||||
|
const date = new Date(this.year, this.month, label);
|
||||||
|
return this.fmtDayMonth(date);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label: (tooltipItem) => {
|
||||||
|
const datasetLabel = tooltipItem.dataset.label || "";
|
||||||
|
const value = tooltipItem.raw || 0;
|
||||||
|
return (
|
||||||
|
datasetLabel + ": " + this.fmtWh(value * 1e3, POWER_UNIT.AUTO)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
labelColor: tooltipLabelColor(false),
|
||||||
|
labelPointStyle: function () {
|
||||||
|
return {
|
||||||
|
pointStyle: "circle",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
labelTextColor: (item) => {
|
||||||
|
return !item.raw ? colors.muted : "#fff";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
itemSort: function (a, b) {
|
||||||
|
return b.datasetIndex - a.datasetIndex;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
stacked: true,
|
||||||
|
border: { display: false },
|
||||||
|
grid: { display: false },
|
||||||
|
ticks: { color: colors.muted },
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
stacked: true,
|
||||||
|
border: { display: false },
|
||||||
|
grid: { color: colors.border },
|
||||||
|
title: {
|
||||||
|
text: "kWh",
|
||||||
|
display: true,
|
||||||
|
color: colors.muted,
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
callback: (value) => this.fmtWh(value * 1e3, POWER_UNIT.KW, false, 0),
|
||||||
|
color: colors.muted,
|
||||||
|
maxTicksLimit: 6,
|
||||||
|
},
|
||||||
|
position: "right",
|
||||||
|
min: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
88
assets/js/components/Sessions/LegendList.vue
Normal file
88
assets/js/components/Sessions/LegendList.vue
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
<template>
|
||||||
|
<ul
|
||||||
|
class="root p-0 d-flex flex-wrap column-gap-4 row-gap-2 overflow-hidden"
|
||||||
|
:class="{
|
||||||
|
'root--small-equal-widths': smallEqualWidths,
|
||||||
|
'root--grid': grid,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
class="legend-item d-flex align-items-baseline gap-2 no-wrap overflow-hidden"
|
||||||
|
v-for="legend in legends"
|
||||||
|
:key="legend.label"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="legend.color"
|
||||||
|
class="legend-color align-self-center me-1"
|
||||||
|
:style="{ backgroundColor: legend.color }"
|
||||||
|
></div>
|
||||||
|
<div class="legend-label text-nowrap">{{ legend.label }}</div>
|
||||||
|
<div
|
||||||
|
v-for="value in valueList(legend.value)"
|
||||||
|
class="text-muted text-nowrap legend-value text-end"
|
||||||
|
:key="value"
|
||||||
|
>
|
||||||
|
{{ value }}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: "LegendList",
|
||||||
|
props: {
|
||||||
|
legends: Array,
|
||||||
|
grid: Boolean,
|
||||||
|
smallEqualWidths: Boolean,
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
valueList(value) {
|
||||||
|
if (!value) return [];
|
||||||
|
return Array.isArray(value) ? value : [value];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.root {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
.legend-color {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.legend-label {
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-grow: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root--grid .legend-label {
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-shrink: 1;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.root--grid .legend-item {
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-basis: 100%;
|
||||||
|
}
|
||||||
|
.root--grid .legend-value:last-child {
|
||||||
|
flex-basis: 3.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root--small-equal-widths {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-evenly;
|
||||||
|
}
|
||||||
|
.root--small-equal-widths .legend-item {
|
||||||
|
flex-basis: 8rem;
|
||||||
|
}
|
||||||
|
.root--small-equal-widths .legend-label {
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-shrink: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
37
assets/js/components/Sessions/PeriodSelector.vue
Normal file
37
assets/js/components/Sessions/PeriodSelector.vue
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
<template>
|
||||||
|
<SelectGroup
|
||||||
|
id="sessionsPeriodSmall"
|
||||||
|
class="w-100 d-flex d-lg-none"
|
||||||
|
:options="periodOptions"
|
||||||
|
:modelValue="period"
|
||||||
|
@update:modelValue="changePeriod"
|
||||||
|
/>
|
||||||
|
<SelectGroup
|
||||||
|
id="sessionsPeriod"
|
||||||
|
class="w-100 d-none d-lg-flex"
|
||||||
|
:options="periodOptions"
|
||||||
|
large
|
||||||
|
:modelValue="period"
|
||||||
|
@update:modelValue="changePeriod"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import SelectGroup from "../SelectGroup.vue";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "PeriodSelector",
|
||||||
|
components: {
|
||||||
|
SelectGroup,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
period: { type: String, required: true },
|
||||||
|
periodOptions: { type: Array, required: true },
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
changePeriod(newPeriod) {
|
||||||
|
this.$emit("update:period", newPeriod);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
@ -129,10 +129,10 @@
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer d-flex justify-content-right">
|
<div class="modal-footer d-flex justify-content-start">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-outline-danger"
|
class="btn btn-link text-danger"
|
||||||
data-bs-dismiss="modal"
|
data-bs-dismiss="modal"
|
||||||
@click="openRemoveConfirmationModal"
|
@click="openRemoveConfirmationModal"
|
||||||
>
|
>
|
||||||
|
|
@ -182,15 +182,14 @@
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import "@h2d2/shopicons/es/regular/checkmark";
|
import "@h2d2/shopicons/es/regular/checkmark";
|
||||||
import { distanceUnit, distanceValue } from "../units";
|
|
||||||
import formatter from "../mixins/formatter";
|
|
||||||
import Modal from "bootstrap/js/dist/modal";
|
import Modal from "bootstrap/js/dist/modal";
|
||||||
import api from "../api";
|
import formatter from "../../mixins/formatter";
|
||||||
|
import VehicleOptions from "../VehicleOptions.vue";
|
||||||
import VehicleOptions from "./VehicleOptions.vue";
|
import { distanceUnit, distanceValue } from "../../units";
|
||||||
|
import api from "../../api";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "ChargingSessionModal",
|
name: "SessionDetailsModal",
|
||||||
components: { VehicleOptions },
|
components: { VehicleOptions },
|
||||||
mixins: [formatter],
|
mixins: [formatter],
|
||||||
props: {
|
props: {
|
||||||
493
assets/js/components/Sessions/SessionTable.vue
Normal file
493
assets/js/components/Sessions/SessionTable.vue
Normal file
|
|
@ -0,0 +1,493 @@
|
||||||
|
<template>
|
||||||
|
<h3 class="fw-normal mb-4">Übersicht</h3>
|
||||||
|
|
||||||
|
<div v-if="sessions.length === 0" data-testid="sessions-nodata" class="mb-5">
|
||||||
|
<p>{{ $t("sessions.noData") }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="mb-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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import CustomSelect from "../CustomSelect.vue";
|
||||||
|
import formatter, { POWER_UNIT } from "../../mixins/formatter";
|
||||||
|
import breakpoint from "../../mixins/breakpoint";
|
||||||
|
import settings from "../../settings";
|
||||||
|
|
||||||
|
const COLUMNS_PER_BREAKPOINT = {
|
||||||
|
xs: 1,
|
||||||
|
sm: 2,
|
||||||
|
md: 3,
|
||||||
|
lg: 4,
|
||||||
|
xl: 5,
|
||||||
|
xxl: 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "SessionTable",
|
||||||
|
components: { CustomSelect },
|
||||||
|
mixins: [formatter, breakpoint],
|
||||||
|
props: {
|
||||||
|
sessions: { type: Array, default: () => [] },
|
||||||
|
loadpointFilter: { type: String, default: "" },
|
||||||
|
vehicleFilter: { type: String, default: "" },
|
||||||
|
currency: { type: String },
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
selectedColumns: settings.sessionColumns,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
emits: ["show-session"],
|
||||||
|
computed: {
|
||||||
|
filteredSessions() {
|
||||||
|
return this.sessions.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.sessions.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.sessions.map((s) => s.loadpoint))];
|
||||||
|
},
|
||||||
|
vehicles() {
|
||||||
|
return [...new Set(this.sessions.map((s) => s.vehicle))];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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.sessions
|
||||||
|
.filter(this.filterByLoadpoint)
|
||||||
|
.filter((s) => !vehicle || s.vehicle === vehicle).length;
|
||||||
|
},
|
||||||
|
filterCountForLoadpoint(loadpoint) {
|
||||||
|
return this.sessions
|
||||||
|
.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 } });
|
||||||
|
},
|
||||||
|
showDetails(sessionId) {
|
||||||
|
this.$emit("show-session", sessionId);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</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 {
|
||||||
|
top: 7rem;
|
||||||
|
}
|
||||||
|
@media (min-width: 992px) {
|
||||||
|
.sticky-top {
|
||||||
|
top: 4.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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>
|
||||||
124
assets/js/components/Sessions/SolarGroupedChart.vue
Normal file
124
assets/js/components/Sessions/SolarGroupedChart.vue
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
<template>
|
||||||
|
<div v-if="chartData.labels.length > 1" class="row">
|
||||||
|
<div class="col-12 col-md-6 mb-3">
|
||||||
|
<PolarArea :data="chartData" :options="options" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6 d-flex align-items-center py-0 py-md-3">
|
||||||
|
<LegendList :legends="legends" grid />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { PolarArea } from "vue-chartjs";
|
||||||
|
import { RadialLinearScale, ArcElement, Legend, Tooltip } from "chart.js";
|
||||||
|
import { registerChartComponents, commonOptions, tooltipLabelColor } from "./chartConfig";
|
||||||
|
import formatter from "../../mixins/formatter";
|
||||||
|
import colors, { dimColor } from "../../colors";
|
||||||
|
import LegendList from "./LegendList.vue";
|
||||||
|
import { GROUPS } from "./types";
|
||||||
|
|
||||||
|
registerChartComponents([RadialLinearScale, ArcElement, Legend, Tooltip]);
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "SolarGroupedChart",
|
||||||
|
components: { PolarArea, LegendList },
|
||||||
|
props: {
|
||||||
|
sessions: { type: Array, default: () => [] },
|
||||||
|
groupBy: { type: String, default: GROUPS.LOADPOINT },
|
||||||
|
colorMappings: { type: Object, default: () => ({ loadpoint: {}, vehicle: {} }) },
|
||||||
|
},
|
||||||
|
mixins: [formatter],
|
||||||
|
computed: {
|
||||||
|
chartData() {
|
||||||
|
console.log("update solar grouped data");
|
||||||
|
const aggregatedData = {};
|
||||||
|
|
||||||
|
this.sessions.forEach((session) => {
|
||||||
|
const groupKey = session[this.groupBy];
|
||||||
|
if (!aggregatedData[groupKey]) {
|
||||||
|
aggregatedData[groupKey] = { grid: 0, self: 0 };
|
||||||
|
}
|
||||||
|
const charged = session.chargedEnergy;
|
||||||
|
const self = (charged / 100) * session.solarPercentage;
|
||||||
|
const grid = charged - self;
|
||||||
|
aggregatedData[groupKey].self += self;
|
||||||
|
aggregatedData[groupKey].grid += grid;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort the data by total energy in descending order
|
||||||
|
const sortedEntries = Object.entries(aggregatedData).sort(
|
||||||
|
(a, b) => b[1].grid + b[1].self - (a[1].grid + a[1].self)
|
||||||
|
);
|
||||||
|
const labels = sortedEntries.map(([label]) => label);
|
||||||
|
const data = sortedEntries.map(([, value]) => {
|
||||||
|
const total = value.grid + value.self;
|
||||||
|
const selfPercentage = (value.self / total) * 100;
|
||||||
|
return selfPercentage;
|
||||||
|
});
|
||||||
|
|
||||||
|
const colorGroup = this.groupBy === GROUPS.NONE ? "solar" : this.groupBy;
|
||||||
|
const borderColors = labels.map((label) => this.colorMappings[colorGroup][label]);
|
||||||
|
const backgroundColors = borderColors.map((color) => dimColor(color));
|
||||||
|
return {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: data,
|
||||||
|
borderColor: borderColors,
|
||||||
|
backgroundColor: backgroundColors,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
legends() {
|
||||||
|
return this.chartData.labels.map((label, index) => ({
|
||||||
|
label: label,
|
||||||
|
color: this.chartData.datasets[0].borderColor[index],
|
||||||
|
value: this.fmtPercentage(this.chartData.datasets[0].data[index], 1),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
options() {
|
||||||
|
return {
|
||||||
|
...commonOptions,
|
||||||
|
locale: this.$i18n?.locale,
|
||||||
|
aspectRatio: 1,
|
||||||
|
borderRadius: 8,
|
||||||
|
borderWidth: 3,
|
||||||
|
color: colors.text,
|
||||||
|
spacing: 0,
|
||||||
|
radius: "100%",
|
||||||
|
plugins: {
|
||||||
|
...commonOptions.plugins,
|
||||||
|
tooltip: {
|
||||||
|
...commonOptions.plugins.tooltip,
|
||||||
|
intersect: false,
|
||||||
|
mode: "index",
|
||||||
|
position: "topBottomCenter",
|
||||||
|
callbacks: {
|
||||||
|
title: () => null,
|
||||||
|
label: (tooltipItem) => {
|
||||||
|
const { label, raw = 0 } = tooltipItem;
|
||||||
|
return label + ": " + this.fmtPercentage(raw, 1);
|
||||||
|
},
|
||||||
|
labelColor: tooltipLabelColor(true),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
r: {
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
ticks: {
|
||||||
|
stepSize: 25,
|
||||||
|
color: colors.muted,
|
||||||
|
backdropColor: colors.background,
|
||||||
|
},
|
||||||
|
grid: { color: colors.border },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
183
assets/js/components/Sessions/SolarYearChart.vue
Normal file
183
assets/js/components/Sessions/SolarYearChart.vue
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
<template>
|
||||||
|
<div v-if="chartData.labels.length > 1" class="row">
|
||||||
|
<div class="col-12 col-md-6 mb-3">
|
||||||
|
<Radar :data="chartData" :options="options" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6 d-flex align-items-center py-0 py-md-3">
|
||||||
|
<LegendList :legends="legends" small-equal-widths />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { Radar } from "vue-chartjs";
|
||||||
|
import { RadialLinearScale, PointElement, LineElement, Filler, Tooltip } from "chart.js";
|
||||||
|
import { registerChartComponents, commonOptions, tooltipLabelColor } from "./chartConfig";
|
||||||
|
import formatter from "../../mixins/formatter";
|
||||||
|
import colors, { dimColor } from "../../colors";
|
||||||
|
import LegendList from "./LegendList.vue";
|
||||||
|
|
||||||
|
registerChartComponents([RadialLinearScale, PointElement, LineElement, Filler, Tooltip]);
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "SolarYearChart",
|
||||||
|
components: { Radar, LegendList },
|
||||||
|
props: {
|
||||||
|
sessions: { type: Array, default: () => [] },
|
||||||
|
period: { type: String, default: "total" },
|
||||||
|
},
|
||||||
|
mixins: [formatter],
|
||||||
|
computed: {
|
||||||
|
firstDay() {
|
||||||
|
if (this.sessions.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new Date(this.sessions[0].created);
|
||||||
|
},
|
||||||
|
lastDay() {
|
||||||
|
if (this.sessions.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new Date(this.sessions[this.sessions.length - 1].created);
|
||||||
|
},
|
||||||
|
chartData() {
|
||||||
|
console.log("update solar month data");
|
||||||
|
|
||||||
|
if (!this.sessions.length > 0) return { labels: [], datasets: [] };
|
||||||
|
|
||||||
|
const firstYear = this.firstDay.getFullYear();
|
||||||
|
const lastYear = this.lastDay.getFullYear();
|
||||||
|
|
||||||
|
const result = {};
|
||||||
|
|
||||||
|
const years = [];
|
||||||
|
|
||||||
|
// initialize result for years and months
|
||||||
|
for (let year = lastYear; year >= firstYear; year--) {
|
||||||
|
const yearString = `${year}`;
|
||||||
|
years.push(yearString);
|
||||||
|
result[yearString] = {};
|
||||||
|
console.log("year", yearString);
|
||||||
|
|
||||||
|
for (let month = 1; month <= 12; month++) {
|
||||||
|
result[yearString][month] = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate with actual data
|
||||||
|
this.sessions.forEach((session) => {
|
||||||
|
const date = new Date(session.created);
|
||||||
|
const year = `${date.getFullYear()}`;
|
||||||
|
const month = `${date.getMonth() + 1}`;
|
||||||
|
|
||||||
|
const charged = session.chargedEnergy;
|
||||||
|
const self = (charged / 100) * session.solarPercentage;
|
||||||
|
const grid = charged - self;
|
||||||
|
result[year][month].self = (result[year][month].self || 0) + self;
|
||||||
|
result[year][month].grid = (result[year][month].grid || 0) + grid;
|
||||||
|
});
|
||||||
|
|
||||||
|
const datasets = years.map((year) => {
|
||||||
|
const borderColor = colors.selfPalette[years.indexOf(year)];
|
||||||
|
const backgroundColor = years.length === 1 ? dimColor(borderColor) : "transparent";
|
||||||
|
return {
|
||||||
|
backgroundColor,
|
||||||
|
borderColor,
|
||||||
|
label: year,
|
||||||
|
data: Object.values(result[year]).map(({ self = 0, grid = 0 }) => {
|
||||||
|
const total = self + grid;
|
||||||
|
return total === 0 ? null : (self / total) * 100;
|
||||||
|
}),
|
||||||
|
yearData: Object.values(result[year]).reduce(
|
||||||
|
(acc, { self = 0, grid = 0 }) => ({
|
||||||
|
self: acc.self + self,
|
||||||
|
grid: acc.grid + grid,
|
||||||
|
}),
|
||||||
|
{ self: 0, grid: 0 }
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const labels = Object.keys(result[firstYear]).map((month) =>
|
||||||
|
this.fmtMonth(new Date(firstYear, month - 1, 1), true)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels,
|
||||||
|
datasets,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
legends() {
|
||||||
|
if (this.period === "total") {
|
||||||
|
return this.chartData.datasets.map((dataset) => {
|
||||||
|
const label = dataset.label;
|
||||||
|
const { self, grid } = dataset.yearData;
|
||||||
|
const total = self + grid;
|
||||||
|
const value = total === 0 ? "- %" : this.fmtPercentage((self / total) * 100, 1);
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
color: dataset.borderColor,
|
||||||
|
value,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return this.chartData.labels.map((label, index) => {
|
||||||
|
const value = this.chartData.datasets[0].data[index];
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
value:
|
||||||
|
value === null
|
||||||
|
? "- %"
|
||||||
|
: this.fmtPercentage(this.chartData.datasets[0].data[index], 1),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
options() {
|
||||||
|
return {
|
||||||
|
...commonOptions,
|
||||||
|
locale: this.$i18n?.locale,
|
||||||
|
aspectRatio: 1,
|
||||||
|
borderWidth: 4,
|
||||||
|
color: colors.text,
|
||||||
|
spacing: 0,
|
||||||
|
radius: "100%",
|
||||||
|
elements: { line: { tension: 0.05 } },
|
||||||
|
plugins: {
|
||||||
|
...commonOptions.plugins,
|
||||||
|
tooltip: {
|
||||||
|
...commonOptions.plugins.tooltip,
|
||||||
|
intersect: false,
|
||||||
|
mode: "index",
|
||||||
|
position: "topBottomCenter",
|
||||||
|
callbacks: {
|
||||||
|
label: (tooltipItem) => {
|
||||||
|
const value = tooltipItem.raw || 0;
|
||||||
|
const datasetLabel = tooltipItem.dataset.label || "";
|
||||||
|
return datasetLabel + ": " + this.fmtPercentage(value, 1);
|
||||||
|
},
|
||||||
|
labelColor: tooltipLabelColor(true),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
r: {
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
beginAtZero: false,
|
||||||
|
ticks: {
|
||||||
|
stepSize: 20,
|
||||||
|
color: colors.muted,
|
||||||
|
backdropColor: colors.background,
|
||||||
|
font: { size: 10 },
|
||||||
|
callback: (value) => this.fmtPercentage(value, 0),
|
||||||
|
},
|
||||||
|
angleLines: { display: false },
|
||||||
|
grid: { color: colors.border },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
62
assets/js/components/Sessions/chartConfig.js
Normal file
62
assets/js/components/Sessions/chartConfig.js
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { Chart, Tooltip } from "chart.js";
|
||||||
|
import colors from "../../colors";
|
||||||
|
// Register common components
|
||||||
|
export function registerChartComponents(components) {
|
||||||
|
Chart.register(...components);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default configurations immediately
|
||||||
|
Chart.defaults.font.family = window
|
||||||
|
.getComputedStyle(document.documentElement)
|
||||||
|
.getPropertyValue("--bs-font-sans-serif");
|
||||||
|
Chart.defaults.font.size = 14;
|
||||||
|
Chart.defaults.layout.padding = 0;
|
||||||
|
|
||||||
|
// Custom tooltip positioners
|
||||||
|
Tooltip.positioners.center = function () {
|
||||||
|
const { chart } = this;
|
||||||
|
return {
|
||||||
|
x: chart.width / 2,
|
||||||
|
y: chart.height / 2,
|
||||||
|
xAlign: "center",
|
||||||
|
yAlign: "center",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
Tooltip.positioners.topBottomCenter = function (context, eventPosition) {
|
||||||
|
const { chart } = this;
|
||||||
|
const { height, width } = chart;
|
||||||
|
|
||||||
|
const isTop = eventPosition.y > height / 2;
|
||||||
|
const yPadding = height / 5;
|
||||||
|
const y = isTop ? yPadding : height - yPadding;
|
||||||
|
const x = width / 2;
|
||||||
|
|
||||||
|
return { x, y, xAlign: "center", yAlign: "center" };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const commonOptions = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: "#000000cc",
|
||||||
|
boxPadding: 5,
|
||||||
|
usePointStyle: false,
|
||||||
|
borderWidth: 0.00001,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function tooltipLabelColor(useBorder = false) {
|
||||||
|
return function (item) {
|
||||||
|
const { backgroundColor, borderColor } = item.element.options;
|
||||||
|
const color = useBorder ? borderColor : backgroundColor;
|
||||||
|
return {
|
||||||
|
borderColor: !item.raw ? colors.muted : "#fff",
|
||||||
|
borderWidth: 1,
|
||||||
|
backgroundColor: color,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
17
assets/js/components/Sessions/types.js
Normal file
17
assets/js/components/Sessions/types.js
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
export const TYPES = {
|
||||||
|
SOLAR: "solar",
|
||||||
|
PRICE: "price",
|
||||||
|
CO2: "co2",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GROUPS = {
|
||||||
|
NONE: "none",
|
||||||
|
LOADPOINT: "loadpoint",
|
||||||
|
VEHICLE: "vehicle",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PERIODS = {
|
||||||
|
MONTH: "month",
|
||||||
|
YEAR: "year",
|
||||||
|
TOTAL: "total",
|
||||||
|
};
|
||||||
|
|
@ -240,4 +240,8 @@ export default {
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
}
|
}
|
||||||
|
.dropdown-menu {
|
||||||
|
/* above sticky, below modal https://getbootstrap.com/docs/5.3/layout/z-index/ */
|
||||||
|
z-index: 1045 !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ export default {
|
||||||
let unit = format;
|
let unit = format;
|
||||||
let d = digits;
|
let d = digits;
|
||||||
if (POWER_UNIT.AUTO === unit) {
|
if (POWER_UNIT.AUTO === unit) {
|
||||||
if (watt >= 1_000_000) {
|
if (watt >= 10_000_000) {
|
||||||
unit = POWER_UNIT.MW;
|
unit = POWER_UNIT.MW;
|
||||||
} else if (watt >= 1000 || 0 === watt) {
|
} else if (watt >= 1000 || 0 === watt) {
|
||||||
unit = POWER_UNIT.KW;
|
unit = POWER_UNIT.KW;
|
||||||
|
|
@ -82,6 +82,20 @@ export default {
|
||||||
maximumFractionDigits: decimals,
|
maximumFractionDigits: decimals,
|
||||||
}).format(number);
|
}).format(number);
|
||||||
},
|
},
|
||||||
|
fmtGrams: function (gramms, withUnit = true) {
|
||||||
|
let unit = "gram";
|
||||||
|
let value = gramms;
|
||||||
|
if (gramms >= 1000) {
|
||||||
|
unit = "kilogram";
|
||||||
|
value = gramms / 1000;
|
||||||
|
}
|
||||||
|
return new Intl.NumberFormat(this.$i18n?.locale, {
|
||||||
|
style: withUnit ? "unit" : "decimal",
|
||||||
|
unit,
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(value);
|
||||||
|
},
|
||||||
fmtCo2Short: function (gramms) {
|
fmtCo2Short: function (gramms) {
|
||||||
return `${this.fmtNumber(gramms, 0)} g`;
|
return `${this.fmtNumber(gramms, 0)} g`;
|
||||||
},
|
},
|
||||||
|
|
@ -203,6 +217,11 @@ export default {
|
||||||
month: short ? "short" : "long",
|
month: short ? "short" : "long",
|
||||||
}).format(date);
|
}).format(date);
|
||||||
},
|
},
|
||||||
|
fmtYear: function (date) {
|
||||||
|
return new Intl.DateTimeFormat(this.$i18n?.locale, {
|
||||||
|
year: "numeric",
|
||||||
|
}).format(date);
|
||||||
|
},
|
||||||
fmtDayMonthYear: function (date) {
|
fmtDayMonthYear: function (date) {
|
||||||
return new Intl.DateTimeFormat(this.$i18n?.locale, {
|
return new Intl.DateTimeFormat(this.$i18n?.locale, {
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
|
|
@ -210,16 +229,23 @@ export default {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
}).format(date);
|
}).format(date);
|
||||||
},
|
},
|
||||||
fmtMoney: function (amout = 0, currency = "EUR", decimals = true) {
|
fmtDayMonth: function (date) {
|
||||||
return new Intl.NumberFormat(this.$i18n?.locale, {
|
return new Intl.DateTimeFormat(this.$i18n?.locale, {
|
||||||
|
weekday: "short",
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
}).format(date);
|
||||||
|
},
|
||||||
|
fmtMoney: function (amout = 0, currency = "EUR", decimals = true, withSymbol = false) {
|
||||||
|
const currencyDisplay = withSymbol ? "narrowSymbol" : "code";
|
||||||
|
const result = new Intl.NumberFormat(this.$i18n?.locale, {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency,
|
currency,
|
||||||
currencyDisplay: "code",
|
currencyDisplay,
|
||||||
maximumFractionDigits: decimals ? undefined : 0,
|
maximumFractionDigits: decimals ? undefined : 0,
|
||||||
})
|
}).format(amout);
|
||||||
.format(amout)
|
|
||||||
.replace(currency, "")
|
return withSymbol ? result : result.replace(currency, "").trim();
|
||||||
.trim();
|
|
||||||
},
|
},
|
||||||
fmtCurrencySymbol: function (currency = "EUR") {
|
fmtCurrencySymbol: function (currency = "EUR") {
|
||||||
const symbols = { EUR: "€", USD: "$" };
|
const symbols = { EUR: "€", USD: "$" };
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ const fmt = mount({
|
||||||
describe("fmtW", () => {
|
describe("fmtW", () => {
|
||||||
test("should format with units", () => {
|
test("should format with units", () => {
|
||||||
expect(fmt.fmtW(0, POWER_UNIT.AUTO)).eq("0,0 kW");
|
expect(fmt.fmtW(0, POWER_UNIT.AUTO)).eq("0,0 kW");
|
||||||
expect(fmt.fmtW(1200000, POWER_UNIT.AUTO)).eq("1,2 MW");
|
expect(fmt.fmtW(1200000, POWER_UNIT.AUTO)).eq("1.200,0 kW");
|
||||||
expect(fmt.fmtW(0, POWER_UNIT.MW)).eq("0,0 MW");
|
expect(fmt.fmtW(0, POWER_UNIT.MW)).eq("0,0 MW");
|
||||||
expect(fmt.fmtW(1200000, POWER_UNIT.MW)).eq("1,2 MW");
|
expect(fmt.fmtW(1200000, POWER_UNIT.MW)).eq("1,2 MW");
|
||||||
expect(fmt.fmtW(0, POWER_UNIT.KW)).eq("0,0 kW");
|
expect(fmt.fmtW(0, POWER_UNIT.KW)).eq("0,0 kW");
|
||||||
|
|
@ -23,7 +23,7 @@ describe("fmtW", () => {
|
||||||
});
|
});
|
||||||
test("should format without units", () => {
|
test("should format without units", () => {
|
||||||
expect(fmt.fmtW(0, POWER_UNIT.AUTO, false)).eq("0,0");
|
expect(fmt.fmtW(0, POWER_UNIT.AUTO, false)).eq("0,0");
|
||||||
expect(fmt.fmtW(1200000, POWER_UNIT.AUTO, false)).eq("1,2");
|
expect(fmt.fmtW(1200000, POWER_UNIT.AUTO, false)).eq("1.200,0");
|
||||||
expect(fmt.fmtW(0, POWER_UNIT.MW, false)).eq("0,0");
|
expect(fmt.fmtW(0, POWER_UNIT.MW, false)).eq("0,0");
|
||||||
expect(fmt.fmtW(1200000, POWER_UNIT.MW, false)).eq("1,2");
|
expect(fmt.fmtW(1200000, POWER_UNIT.MW, false)).eq("1,2");
|
||||||
expect(fmt.fmtW(0, POWER_UNIT.KW, false)).eq("0,0");
|
expect(fmt.fmtW(0, POWER_UNIT.KW, false)).eq("0,0");
|
||||||
|
|
@ -50,7 +50,7 @@ describe("fmtW", () => {
|
||||||
describe("fmtWh", () => {
|
describe("fmtWh", () => {
|
||||||
test("should format with units", () => {
|
test("should format with units", () => {
|
||||||
expect(fmt.fmtWh(0, POWER_UNIT.AUTO)).eq("0,0 kWh");
|
expect(fmt.fmtWh(0, POWER_UNIT.AUTO)).eq("0,0 kWh");
|
||||||
expect(fmt.fmtWh(1200000, POWER_UNIT.AUTO)).eq("1,2 MWh");
|
expect(fmt.fmtWh(1200000, POWER_UNIT.AUTO)).eq("1.200,0 kWh");
|
||||||
expect(fmt.fmtWh(0, POWER_UNIT.MW)).eq("0,0 MWh");
|
expect(fmt.fmtWh(0, POWER_UNIT.MW)).eq("0,0 MWh");
|
||||||
expect(fmt.fmtWh(1200000, POWER_UNIT.MW)).eq("1,2 MWh");
|
expect(fmt.fmtWh(1200000, POWER_UNIT.MW)).eq("1,2 MWh");
|
||||||
expect(fmt.fmtWh(0, POWER_UNIT.KW)).eq("0,0 kWh");
|
expect(fmt.fmtWh(0, POWER_UNIT.KW)).eq("0,0 kWh");
|
||||||
|
|
@ -60,7 +60,7 @@ describe("fmtWh", () => {
|
||||||
});
|
});
|
||||||
test("should format without units", () => {
|
test("should format without units", () => {
|
||||||
expect(fmt.fmtWh(0, POWER_UNIT.AUTO, false)).eq("0,0");
|
expect(fmt.fmtWh(0, POWER_UNIT.AUTO, false)).eq("0,0");
|
||||||
expect(fmt.fmtWh(1200000, POWER_UNIT.AUTO, false)).eq("1,2");
|
expect(fmt.fmtWh(1200000, POWER_UNIT.AUTO, false)).eq("1.200,0");
|
||||||
expect(fmt.fmtWh(0, POWER_UNIT.MW, false)).eq("0,0");
|
expect(fmt.fmtWh(0, POWER_UNIT.MW, false)).eq("0,0");
|
||||||
expect(fmt.fmtWh(1200000, POWER_UNIT.MW, false)).eq("1,2");
|
expect(fmt.fmtWh(1200000, POWER_UNIT.MW, false)).eq("1,2");
|
||||||
expect(fmt.fmtWh(0, POWER_UNIT.KW, false)).eq("0,0");
|
expect(fmt.fmtWh(0, POWER_UNIT.KW, false)).eq("0,0");
|
||||||
|
|
|
||||||
|
|
@ -41,12 +41,13 @@ export default function setupRouter(i18n) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/sessions",
|
path: "/sessions",
|
||||||
component: () => import("./views/ChargingSessions.vue"),
|
component: () => import("./views/Sessions.vue"),
|
||||||
props: (route) => {
|
props: (route) => {
|
||||||
const { month, year, loadpoint, vehicle } = route.query;
|
const { month, year, loadpoint, vehicle, period } = route.query;
|
||||||
return {
|
return {
|
||||||
month: month ? parseInt(month, 10) : undefined,
|
month: month ? parseInt(month, 10) : undefined,
|
||||||
year: year ? parseInt(year, 10) : undefined,
|
year: year ? parseInt(year, 10) : undefined,
|
||||||
|
period,
|
||||||
loadpointFilter: loadpoint,
|
loadpointFilter: loadpoint,
|
||||||
vehicleFilter: vehicle,
|
vehicleFilter: vehicle,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ const SESSION_INFO = "session_info";
|
||||||
const SESSION_COLUMNS = "session_columns";
|
const SESSION_COLUMNS = "session_columns";
|
||||||
const SAVINGS_PERIOD = "savings_period";
|
const SAVINGS_PERIOD = "savings_period";
|
||||||
const SAVINGS_REGION = "savings_region";
|
const SAVINGS_REGION = "savings_region";
|
||||||
|
const SESSIONS_GROUP = "sessions_group";
|
||||||
|
const SESSIONS_TYPE = "sessions_type";
|
||||||
|
|
||||||
function read(key) {
|
function read(key) {
|
||||||
return window.localStorage[key];
|
return window.localStorage[key];
|
||||||
|
|
@ -60,6 +62,8 @@ const settings = reactive({
|
||||||
sessionColumns: readArray(SESSION_COLUMNS),
|
sessionColumns: readArray(SESSION_COLUMNS),
|
||||||
savingsPeriod: read(SAVINGS_PERIOD),
|
savingsPeriod: read(SAVINGS_PERIOD),
|
||||||
savingsRegion: read(SAVINGS_REGION),
|
savingsRegion: read(SAVINGS_REGION),
|
||||||
|
sessionsGroup: read(SESSIONS_GROUP),
|
||||||
|
sessionsType: read(SESSIONS_TYPE),
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(() => settings.locale, save(SETTINGS_LOCALE));
|
watch(() => settings.locale, save(SETTINGS_LOCALE));
|
||||||
|
|
@ -71,5 +75,6 @@ watch(() => settings.sessionInfo, saveArray(SESSION_INFO));
|
||||||
watch(() => settings.sessionColumns, saveArray(SESSION_COLUMNS));
|
watch(() => settings.sessionColumns, saveArray(SESSION_COLUMNS));
|
||||||
watch(() => settings.savingsPeriod, save(SAVINGS_PERIOD));
|
watch(() => settings.savingsPeriod, save(SAVINGS_PERIOD));
|
||||||
watch(() => settings.savingsRegion, save(SAVINGS_REGION));
|
watch(() => settings.savingsRegion, save(SAVINGS_REGION));
|
||||||
|
watch(() => settings.sessionsGroup, save(SESSIONS_GROUP));
|
||||||
|
watch(() => settings.sessionsType, save(SESSIONS_TYPE));
|
||||||
export default settings;
|
export default settings;
|
||||||
|
|
|
||||||
2
assets/js/types/shopicons.d.ts
vendored
Normal file
2
assets/js/types/shopicons.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
declare module "@h2d2/shopicons/es/regular/*";
|
||||||
|
declare module "@h2d2/shopicons/es/filled/*";
|
||||||
|
|
@ -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>
|
||||||
40
i18n/de.toml
40
i18n/de.toml
|
|
@ -239,7 +239,6 @@ running = "prüfe…"
|
||||||
success = "erfolgreich"
|
success = "erfolgreich"
|
||||||
unknown = "unbekannt"
|
unknown = "unbekannt"
|
||||||
validate = "prüfen"
|
validate = "prüfen"
|
||||||
validateSave = "Prüfen & speichern"
|
|
||||||
|
|
||||||
[config.vehicle]
|
[config.vehicle]
|
||||||
cancel = "Abbrechen"
|
cancel = "Abbrechen"
|
||||||
|
|
@ -580,7 +579,6 @@ modalTitle = "Meldungen"
|
||||||
[offline]
|
[offline]
|
||||||
configurationError = "Fehler beim Starten. Überprüfe deine Konfiguration und starte neu."
|
configurationError = "Fehler beim Starten. Überprüfe deine Konfiguration und starte neu."
|
||||||
message = "Keine Verbindung zum Server."
|
message = "Keine Verbindung zum Server."
|
||||||
reload = "Erneut laden?"
|
|
||||||
restart = "Neustart"
|
restart = "Neustart"
|
||||||
restartNeeded = "Erforderlich, um Änderungen zu übernehmen"
|
restartNeeded = "Erforderlich, um Änderungen zu übernehmen"
|
||||||
restarting = "Server ist gleich wieder verfügbar."
|
restarting = "Server ist gleich wieder verfügbar."
|
||||||
|
|
@ -613,8 +611,12 @@ started = "Startzeit"
|
||||||
title = "Ladevorgang"
|
title = "Ladevorgang"
|
||||||
|
|
||||||
[sessions]
|
[sessions]
|
||||||
|
avgCo2TitleByGroup = "⌀ CO₂ {byGroup}"
|
||||||
avgPower = "⌀ Leistung"
|
avgPower = "⌀ Leistung"
|
||||||
avgPrice = "⌀ Preis"
|
avgPrice = "⌀ Preis"
|
||||||
|
avgPriceTitleByGroup = "⌀ Ladepreis {byGroup}"
|
||||||
|
byGroupLoadpoint = "je Ladepunkt"
|
||||||
|
byGroupVehicle = "je Fahrzeug"
|
||||||
chargeDuration = "Ladedauer"
|
chargeDuration = "Ladedauer"
|
||||||
co2 = "⌀ CO₂"
|
co2 = "⌀ CO₂"
|
||||||
csvMonth = "Download {month} CSV"
|
csvMonth = "Download {month} CSV"
|
||||||
|
|
@ -622,11 +624,24 @@ csvTotal = "Gesamte CSV herunterladen"
|
||||||
date = "Anfang"
|
date = "Anfang"
|
||||||
downloadCsv = "Als CSV herunterladen"
|
downloadCsv = "Als CSV herunterladen"
|
||||||
energy = "Geladen"
|
energy = "Geladen"
|
||||||
|
energyGroupedTitle = "Energiemenge {energy}"
|
||||||
|
energyGroupedTitleByGroup = "Energiemenge {byGroup}"
|
||||||
|
energySubTitle = "{energy} gesamt"
|
||||||
|
energyTitle = "{percent} Sonnenenergie"
|
||||||
|
groupedCo2TitleByGroup = "CO₂-Menge {byGroup}"
|
||||||
|
groupedPriceTitleByGroup = "Kosten {byGroup}"
|
||||||
|
historyCo2SubTitle = "{value} gesamt"
|
||||||
|
historyCo2Title = "⌀ {value} CO₂-Emission"
|
||||||
|
historyPriceSubTitle = "{value} gesamt"
|
||||||
|
historyPriceTitle = "⌀ {value} Ladepreis"
|
||||||
loadpoint = "Ladepunkt"
|
loadpoint = "Ladepunkt"
|
||||||
noData = "Noch keine Ladevorgänge in diesem Monat."
|
noData = "Noch keine Ladevorgänge in diesem Monat."
|
||||||
price = "Σ Preis"
|
price = "Σ Preis"
|
||||||
reallyDelete = "Möchtest du diesen Ladevorgang wirklich löschen?"
|
reallyDelete = "Möchtest du diesen Ladevorgang wirklich löschen?"
|
||||||
|
showIndividualEntries = "Einzelne Ladevorgänge anzeigen"
|
||||||
solar = "Sonne"
|
solar = "Sonne"
|
||||||
|
solarTitle = "Sonnenanteil über das Jahr"
|
||||||
|
solarTitleByGroup = "Sonnenanteil {byGroup}"
|
||||||
title = "Ladevorgänge"
|
title = "Ladevorgänge"
|
||||||
total = "Insgesamt"
|
total = "Insgesamt"
|
||||||
vehicle = "Fahrzeug"
|
vehicle = "Fahrzeug"
|
||||||
|
|
@ -652,6 +667,27 @@ allLoadpoints = "Alle Ladepunkte"
|
||||||
allVehicles = "Alle Fahrzeuge"
|
allVehicles = "Alle Fahrzeuge"
|
||||||
filter = "Filtern"
|
filter = "Filtern"
|
||||||
|
|
||||||
|
[sessions.group]
|
||||||
|
co2 = "Emissionen"
|
||||||
|
grid = "Netz"
|
||||||
|
price = "Preis"
|
||||||
|
self = "Sonne"
|
||||||
|
|
||||||
|
[sessions.groupBy]
|
||||||
|
loadpoint = "Ladepunkt"
|
||||||
|
none = "Gesamt"
|
||||||
|
vehicle = "Fahrzeug"
|
||||||
|
|
||||||
|
[sessions.period]
|
||||||
|
month = "Monat"
|
||||||
|
total = "Gesamt"
|
||||||
|
year = "Jahr"
|
||||||
|
|
||||||
|
[sessions.type]
|
||||||
|
co2 = "CO₂"
|
||||||
|
price = "Preis"
|
||||||
|
solar = "Sonne"
|
||||||
|
|
||||||
[settings]
|
[settings]
|
||||||
title = "Darstellung"
|
title = "Darstellung"
|
||||||
|
|
||||||
|
|
|
||||||
38
i18n/en.toml
38
i18n/en.toml
|
|
@ -610,8 +610,12 @@ started = "Started"
|
||||||
title = "Charging Session"
|
title = "Charging Session"
|
||||||
|
|
||||||
[sessions]
|
[sessions]
|
||||||
|
avgCo2TitleByGroup = "⌀ CO₂ {byGroup}"
|
||||||
avgPower = "⌀ Power"
|
avgPower = "⌀ Power"
|
||||||
avgPrice = "⌀ Price"
|
avgPrice = "⌀ Price"
|
||||||
|
avgPriceTitleByGroup = "⌀ Price {byGroup}"
|
||||||
|
byGroupLoadpoint = "by Charging Point"
|
||||||
|
byGroupVehicle = "by Vehicle"
|
||||||
chargeDuration = "Duration"
|
chargeDuration = "Duration"
|
||||||
co2 = "⌀ CO₂"
|
co2 = "⌀ CO₂"
|
||||||
csvMonth = "Download {month} CSV"
|
csvMonth = "Download {month} CSV"
|
||||||
|
|
@ -619,11 +623,24 @@ csvTotal = "Download total CSV"
|
||||||
date = "Start"
|
date = "Start"
|
||||||
downloadCsv = "Download as CSV"
|
downloadCsv = "Download as CSV"
|
||||||
energy = "Charged"
|
energy = "Charged"
|
||||||
|
energyGroupedTitle = "Charged Energy {energy}"
|
||||||
|
energyGroupedTitleByGroup = "Energy {byGroup}"
|
||||||
|
energySubTitle = "{energy} total"
|
||||||
|
energyTitle = "{percent} Solar Energy"
|
||||||
|
groupedCo2TitleByGroup = "CO₂-Amount {byGroup}"
|
||||||
|
groupedPriceTitleByGroup = "Total Cost {byGroup}"
|
||||||
|
historyCo2SubTitle = "{value} total"
|
||||||
|
historyCo2Title = "⌀ {value} CO₂-Emission"
|
||||||
|
historyPriceSubTitle = "{value} total"
|
||||||
|
historyPriceTitle = "⌀ {value} Charge Price"
|
||||||
loadpoint = "Charging point"
|
loadpoint = "Charging point"
|
||||||
noData = "No charging sessions this month."
|
noData = "No charging sessions this month."
|
||||||
price = "Σ Price"
|
price = "Σ Price"
|
||||||
reallyDelete = "Do you really want to delete this session?"
|
reallyDelete = "Do you really want to delete this session?"
|
||||||
|
showIndividualEntries = "Show individual sessions"
|
||||||
solar = "Solar"
|
solar = "Solar"
|
||||||
|
solarTitle = "Solar Share Over Year"
|
||||||
|
solarTitleByGroup = "Solar {byGroup}"
|
||||||
title = "Charging Sessions"
|
title = "Charging Sessions"
|
||||||
total = "Total"
|
total = "Total"
|
||||||
vehicle = "Vehicle"
|
vehicle = "Vehicle"
|
||||||
|
|
@ -649,6 +666,27 @@ allLoadpoints = "all charging points"
|
||||||
allVehicles = "all vehicles"
|
allVehicles = "all vehicles"
|
||||||
filter = "Filter"
|
filter = "Filter"
|
||||||
|
|
||||||
|
[sessions.group]
|
||||||
|
co2 = "Emissions"
|
||||||
|
grid = "Grid"
|
||||||
|
price = "Price"
|
||||||
|
self = "Solar"
|
||||||
|
|
||||||
|
[sessions.groupBy]
|
||||||
|
loadpoint = "Charging point"
|
||||||
|
none = "Total"
|
||||||
|
vehicle = "Vehicle"
|
||||||
|
|
||||||
|
[sessions.period]
|
||||||
|
month = "Month"
|
||||||
|
total = "Total"
|
||||||
|
year = "Year"
|
||||||
|
|
||||||
|
[sessions.type]
|
||||||
|
co2 = "CO₂"
|
||||||
|
price = "Price"
|
||||||
|
solar = "Solar"
|
||||||
|
|
||||||
[settings]
|
[settings]
|
||||||
title = "User Interface"
|
title = "User Interface"
|
||||||
|
|
||||||
|
|
|
||||||
30
package-lock.json
generated
30
package-lock.json
generated
|
|
@ -25,6 +25,7 @@
|
||||||
"body-parser": "^1.20.2",
|
"body-parser": "^1.20.2",
|
||||||
"bootstrap": "^5.2.2",
|
"bootstrap": "^5.2.2",
|
||||||
"canvas-confetti": "^1.4.0",
|
"canvas-confetti": "^1.4.0",
|
||||||
|
"chart.js": "^4.4.4",
|
||||||
"countup.js": "^2.3.2",
|
"countup.js": "^2.3.2",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^8.57.0",
|
||||||
"eslint-config-prettier": "^9.0.0",
|
"eslint-config-prettier": "^9.0.0",
|
||||||
|
|
@ -46,6 +47,7 @@
|
||||||
"vite-plugin-toml": "^0.7.0",
|
"vite-plugin-toml": "^0.7.0",
|
||||||
"vitest": "^2.0.5",
|
"vitest": "^2.0.5",
|
||||||
"vue": "^3.2.29",
|
"vue": "^3.2.29",
|
||||||
|
"vue-chartjs": "^5.3.1",
|
||||||
"vue-eslint-parser": "^9.1.0",
|
"vue-eslint-parser": "^9.1.0",
|
||||||
"vue-hot-reload-api": "^2.3.4",
|
"vue-hot-reload-api": "^2.3.4",
|
||||||
"vue-i18n": "^10.0.1",
|
"vue-i18n": "^10.0.1",
|
||||||
|
|
@ -2620,6 +2622,12 @@
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@kurkle/color": {
|
||||||
|
"version": "0.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz",
|
||||||
|
"integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@lezer/common": {
|
"node_modules/@lezer/common": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.1.tgz",
|
||||||
|
|
@ -4350,6 +4358,18 @@
|
||||||
"tslib": "^2.0.3"
|
"tslib": "^2.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/chart.js": {
|
||||||
|
"version": "4.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.4.tgz",
|
||||||
|
"integrity": "sha512-emICKGBABnxhMjUjlYRR12PmOXhJ2eJjEHL2/dZlWjxRAZT1D8xplLFq5M0tMQK8ja+wBS/tuVEJB5C6r7VxJA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@kurkle/color": "^0.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"pnpm": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/check-error": {
|
"node_modules/check-error": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz",
|
||||||
|
|
@ -9727,6 +9747,16 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vue-chartjs": {
|
||||||
|
"version": "5.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.1.tgz",
|
||||||
|
"integrity": "sha512-rZjqcHBxKiHrBl0CIvcOlVEBwRhpWAVf6rDU3vUfa7HuSRmGtCslc0Oc8m16oAVuk0erzc1FCtH1VCriHsrz+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"chart.js": "^4.1.1",
|
||||||
|
"vue": "^3.0.0-0 || ^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vue-component-type-helpers": {
|
"node_modules/vue-component-type-helpers": {
|
||||||
"version": "2.1.6",
|
"version": "2.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-2.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-2.1.6.tgz",
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@
|
||||||
"body-parser": "^1.20.2",
|
"body-parser": "^1.20.2",
|
||||||
"bootstrap": "^5.2.2",
|
"bootstrap": "^5.2.2",
|
||||||
"canvas-confetti": "^1.4.0",
|
"canvas-confetti": "^1.4.0",
|
||||||
|
"chart.js": "^4.4.4",
|
||||||
"countup.js": "^2.3.2",
|
"countup.js": "^2.3.2",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^8.57.0",
|
||||||
"eslint-config-prettier": "^9.0.0",
|
"eslint-config-prettier": "^9.0.0",
|
||||||
|
|
@ -53,6 +54,7 @@
|
||||||
"vite-plugin-toml": "^0.7.0",
|
"vite-plugin-toml": "^0.7.0",
|
||||||
"vitest": "^2.0.5",
|
"vitest": "^2.0.5",
|
||||||
"vue": "^3.2.29",
|
"vue": "^3.2.29",
|
||||||
|
"vue-chartjs": "^5.3.1",
|
||||||
"vue-eslint-parser": "^9.1.0",
|
"vue-eslint-parser": "^9.1.0",
|
||||||
"vue-hot-reload-api": "^2.3.4",
|
"vue-hot-reload-api": "^2.3.4",
|
||||||
"vue-i18n": "^10.0.1",
|
"vue-i18n": "^10.0.1",
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,8 @@ test.describe("basics", async () => {
|
||||||
});
|
});
|
||||||
test("month with data", async ({ page }) => {
|
test("month with data", async ({ page }) => {
|
||||||
await page.goto("/#/sessions?year=2023&month=5");
|
await page.goto("/#/sessions?year=2023&month=5");
|
||||||
await expect(page.getByRole("heading", { name: "MAY 2023" })).toBeVisible();
|
await expect(page.getByTestId("navigate-month")).toHaveText("May");
|
||||||
|
await expect(page.getByTestId("navigate-year")).toHaveText("2023");
|
||||||
await expect(page.getByTestId("sessions-nodata")).toHaveCount(0);
|
await expect(page.getByTestId("sessions-nodata")).toHaveCount(0);
|
||||||
await expect(page.getByRole("table")).toBeVisible();
|
await expect(page.getByRole("table")).toBeVisible();
|
||||||
await expect(page.getByTestId("sessions-head")).toHaveCount(1);
|
await expect(page.getByTestId("sessions-head")).toHaveCount(1);
|
||||||
|
|
@ -91,8 +92,8 @@ test.describe("mobile basics", async () => {
|
||||||
await page.goto("/#/sessions?year=2023&month=5");
|
await page.goto("/#/sessions?year=2023&month=5");
|
||||||
|
|
||||||
await page.getByTestId("sessions-head-energy").getByRole("combobox").selectOption("Solar");
|
await page.getByTestId("sessions-head-energy").getByRole("combobox").selectOption("Solar");
|
||||||
await page.getByRole("link", { name: "Apr" }).click();
|
await page.getByTestId("navigate-next-year-month").click();
|
||||||
await page.getByRole("link", { name: "May" }).click();
|
await page.getByTestId("navigate-prev-year-month").click();
|
||||||
await expect(page.getByTestId("sessions-head-solar")).toContainText("Solar%");
|
await expect(page.getByTestId("sessions-head-solar")).toContainText("Solar%");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -100,20 +101,22 @@ test.describe("mobile basics", async () => {
|
||||||
test.describe("paging", async () => {
|
test.describe("paging", async () => {
|
||||||
test("prev/next links", async ({ page }) => {
|
test("prev/next links", async ({ page }) => {
|
||||||
await page.goto("/#/sessions?year=2023&month=5");
|
await page.goto("/#/sessions?year=2023&month=5");
|
||||||
await expect(page.getByRole("link", { name: "April" })).toBeVisible();
|
await expect(page.getByTestId("navigate-next-month")).not.toBeDisabled();
|
||||||
await expect(page.getByRole("link", { name: "June" })).toBeVisible();
|
await expect(page.getByTestId("navigate-prev-month")).not.toBeDisabled();
|
||||||
});
|
});
|
||||||
test("next month", async ({ page }) => {
|
test("next month", async ({ page }) => {
|
||||||
await page.goto("/#/sessions?year=2023&month=5");
|
await page.goto("/#/sessions?year=2023&month=5");
|
||||||
await page.getByRole("link", { name: "June" }).click();
|
await page.getByTestId("navigate-next-month").click();
|
||||||
await expect(page.getByRole("heading", { name: "JUNE 2023" })).toBeVisible();
|
await expect(page.getByTestId("navigate-month")).toHaveText("June");
|
||||||
await expect(page.getByRole("link", { name: "May" })).toBeVisible();
|
await expect(page.getByTestId("navigate-year")).toHaveText("2023");
|
||||||
await expect(page.getByRole("link", { name: "July" })).toBeVisible();
|
await expect(page.getByTestId("navigate-next-month")).not.toBeDisabled();
|
||||||
|
await expect(page.getByTestId("navigate-prev-month")).not.toBeDisabled();
|
||||||
});
|
});
|
||||||
test("prev month", async ({ page }) => {
|
test("prev month", async ({ page }) => {
|
||||||
await page.goto("/#/sessions?year=2023&month=6");
|
await page.goto("/#/sessions?year=2023&month=6");
|
||||||
await page.getByRole("link", { name: "May" }).click();
|
await page.getByTestId("navigate-prev-month").click();
|
||||||
await expect(page.getByRole("heading", { name: "MAY 2023" })).toBeVisible();
|
await expect(page.getByTestId("navigate-month")).toHaveText("May");
|
||||||
|
await expect(page.getByTestId("navigate-year")).toHaveText("2023");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -224,8 +227,13 @@ for (const [name, viewport] of Object.entries({ desktop, mobile })) {
|
||||||
.locator("visible=true")
|
.locator("visible=true")
|
||||||
.selectOption("Carport (3)");
|
.selectOption("Carport (3)");
|
||||||
await expect(page.getByTestId("sessions-entry")).toHaveCount(3);
|
await expect(page.getByTestId("sessions-entry")).toHaveCount(3);
|
||||||
await page.getByRole("link", { name: "Jun" }).click();
|
if (name === "mobile") {
|
||||||
await page.getByRole("link", { name: "May" }).click();
|
await page.getByTestId("navigate-next-year-month").click();
|
||||||
|
await page.getByTestId("navigate-prev-year-month").click();
|
||||||
|
} else {
|
||||||
|
await page.getByTestId("navigate-next-month").click();
|
||||||
|
await page.getByTestId("navigate-prev-month").click();
|
||||||
|
}
|
||||||
await expect(page.getByTestId("sessions-entry")).toHaveCount(3);
|
await expect(page.getByTestId("sessions-entry")).toHaveCount(3);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue