Sessions UI: visualization, month/year charts (#15902)

This commit is contained in:
Michael Geers 2024-10-21 12:13:45 +02:00 • committed by GitHub
parent 1bd521fc24
commit cfa35f295b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 3271 additions and 715 deletions

View file

@ -48,6 +48,7 @@
:id="id"
class="w-50"
v-model="value"
equal-width
:options="[
{ value: false, name: $t('config.options.boolean.no') },
{ value: true, name: $t('config.options.boolean.yes') },

View file

@ -11,6 +11,7 @@
name: $t(`settings.theme.${value}`),
}))
"
equal-width
/>
</FormRow>
<FormRow id="settingsLanguage" :label="$t('settings.language.label')">
@ -36,6 +37,7 @@
name: $t(`settings.unit.${value}`),
}))
"
equal-width
/>
</FormRow>
<FormRow id="telemetryEnabled" :label="$t('settings.telemetry.label')">

View file

@ -120,6 +120,7 @@
{ name: '2-phases', value: '2' },
{ name: '3-phases', value: undefined },
]"
equal-width
/>
</FormRow>
<div class="row mb-3">

View 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>

View 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>

View 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>

View file

@ -1,12 +1,12 @@
<template>
<div class="mode-group border d-inline-flex" role="group">
<div class="mode-group border d-inline-flex" :class="{ large }" role="group">
<button
v-for="(option, i) in options"
:id="i === 0 ? id : null"
:key="option.value"
type="button"
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"
@click="$emit('update:modelValue', option.value)"
>
@ -22,6 +22,8 @@ export default {
id: String,
options: Array,
modelValue: [Number, String],
equalWidth: Boolean,
large: Boolean,
},
emits: ["update:modelValue"],
};
@ -32,11 +34,10 @@ export default {
border: 2px solid var(--evcc-default-text);
border-radius: 17px;
padding: 4px;
background: var(--evcc-background);
}
.btn {
/* equal width buttons */
flex-basis: 0;
white-space: nowrap;
border-radius: 12px;
padding: 0.1em 0.8em;
@ -45,6 +46,9 @@ export default {
overflow-x: hidden;
text-overflow: ellipsis;
}
.btn--equal {
flex-basis: 0;
}
.btn:hover {
color: var(--evcc-gray);
}
@ -52,7 +56,11 @@ export default {
color: var(--evcc-background);
background: var(--evcc-default-text);
}
.btn-group {
.modal-group.large {
height: 32px;
}
.large .btn {
height: 32px;
border-radius: 16px;
}
</style>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View file

@ -129,10 +129,10 @@
</tbody>
</table>
</div>
<div class="modal-footer d-flex justify-content-right">
<div class="modal-footer d-flex justify-content-start">
<button
type="button"
class="btn btn-outline-danger"
class="btn btn-link text-danger"
data-bs-dismiss="modal"
@click="openRemoveConfirmationModal"
>
@ -182,15 +182,14 @@
<script>
import "@h2d2/shopicons/es/regular/checkmark";
import { distanceUnit, distanceValue } from "../units";
import formatter from "../mixins/formatter";
import Modal from "bootstrap/js/dist/modal";
import api from "../api";
import VehicleOptions from "./VehicleOptions.vue";
import formatter from "../../mixins/formatter";
import VehicleOptions from "../VehicleOptions.vue";
import { distanceUnit, distanceValue } from "../../units";
import api from "../../api";
export default {
name: "ChargingSessionModal",
name: "SessionDetailsModal",
components: { VehicleOptions },
mixins: [formatter],
props: {

View 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>

View 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>

View 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>

View 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,
};
};
}

View 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",
};

View file

@ -240,4 +240,8 @@ export default {
width: 18px;
height: 20px;
}
.dropdown-menu {
/* above sticky, below modal https://getbootstrap.com/docs/5.3/layout/z-index/ */
z-index: 1045 !important;
}
</style>