Add period selection to energy dashboard (#9756)

This commit is contained in:
Bram Kragten 2021-08-11 01:22:27 +02:00 committed by GitHub
parent 3897e3d452
commit dc50e54afc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 672 additions and 262 deletions

View File

@ -1,4 +1,4 @@
import { addHours, differenceInHours } from "date-fns"; import { addHours, differenceInHours, endOfDay } from "date-fns";
import { HassEntity } from "home-assistant-js-websocket"; import { HassEntity } from "home-assistant-js-websocket";
import { StatisticValue } from "../../../src/data/history"; import { StatisticValue } from "../../../src/data/history";
import { MockHomeAssistant } from "../../../src/fake_data/provide_hass"; import { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
@ -222,6 +222,7 @@ const statisticsFunctions: Record<
"sensor.energy_production_tarif_2": (id, start, end) => { "sensor.energy_production_tarif_2": (id, start, end) => {
const productionStart = new Date(start.getTime() + 9 * 60 * 60 * 1000); const productionStart = new Date(start.getTime() + 9 * 60 * 60 * 1000);
const productionEnd = new Date(start.getTime() + 21 * 60 * 60 * 1000); const productionEnd = new Date(start.getTime() + 21 * 60 * 60 * 1000);
const dayEnd = new Date(endOfDay(productionEnd));
const production = generateCurvedStatistics( const production = generateCurvedStatistics(
id, id,
productionStart, productionStart,
@ -237,15 +238,17 @@ const statisticsFunctions: Record<
const evening = generateSumStatistics( const evening = generateSumStatistics(
id, id,
productionEnd, productionEnd,
end, dayEnd,
productionFinalVal, productionFinalVal,
0 0
); );
return [...morning, ...production, ...evening]; const rest = generateSumStatistics(id, dayEnd, end, productionFinalVal, 1);
return [...morning, ...production, ...evening, ...rest];
}, },
"sensor.solar_production": (id, start, end) => { "sensor.solar_production": (id, start, end) => {
const productionStart = new Date(start.getTime() + 7 * 60 * 60 * 1000); const productionStart = new Date(start.getTime() + 7 * 60 * 60 * 1000);
const productionEnd = new Date(start.getTime() + 23 * 60 * 60 * 1000); const productionEnd = new Date(start.getTime() + 23 * 60 * 60 * 1000);
const dayEnd = new Date(endOfDay(productionEnd));
const production = generateCurvedStatistics( const production = generateCurvedStatistics(
id, id,
productionStart, productionStart,
@ -261,11 +264,12 @@ const statisticsFunctions: Record<
const evening = generateSumStatistics( const evening = generateSumStatistics(
id, id,
productionEnd, productionEnd,
end, dayEnd,
productionFinalVal, productionFinalVal,
0 0
); );
return [...morning, ...production, ...evening]; const rest = generateSumStatistics(id, dayEnd, end, productionFinalVal, 2);
return [...morning, ...production, ...evening, ...rest];
}, },
"sensor.grid_fossil_fuel_percentage": (id, start, end) => "sensor.grid_fossil_fuel_percentage": (id, start, end) =>
generateMeanStatistics(id, start, end, 35, 1.3), generateMeanStatistics(id, start, end, 35, 1.3),

View File

@ -17,7 +17,7 @@
"lint": "yarn run lint:eslint && yarn run lint:prettier && yarn run lint:types", "lint": "yarn run lint:eslint && yarn run lint:prettier && yarn run lint:types",
"format": "yarn run format:eslint && yarn run format:prettier", "format": "yarn run format:eslint && yarn run format:prettier",
"mocha": "ts-mocha -p test-mocha/tsconfig.test.json \"test-mocha/**/*.ts\"", "mocha": "ts-mocha -p test-mocha/tsconfig.test.json \"test-mocha/**/*.ts\"",
"test": "yarn run lint && yarn run mocha" "test": "yarn run mocha"
}, },
"author": "Paulus Schoutsen <Paulus@PaulusSchoutsen.nl> (http://paulusschoutsen.nl)", "author": "Paulus Schoutsen <Paulus@PaulusSchoutsen.nl> (http://paulusschoutsen.nl)",
"license": "Apache-2.0", "license": "Apache-2.0",

View File

@ -3,33 +3,11 @@ import memoizeOne from "memoize-one";
import { FrontendLocaleData } from "../../data/translation"; import { FrontendLocaleData } from "../../data/translation";
import { toLocaleDateStringSupportsOptions } from "./check_options_support"; import { toLocaleDateStringSupportsOptions } from "./check_options_support";
const formatDateMem = memoizeOne( // Tuesday, August 10
(locale: FrontendLocaleData) => export const formatDateWeekday = toLocaleDateStringSupportsOptions
new Intl.DateTimeFormat(locale.language, {
year: "numeric",
month: "long",
day: "numeric",
})
);
export const formatDate = toLocaleDateStringSupportsOptions
? (dateObj: Date, locale: FrontendLocaleData) => ? (dateObj: Date, locale: FrontendLocaleData) =>
formatDateMem(locale).format(dateObj) formatDateWeekdayMem(locale).format(dateObj)
: (dateObj: Date) => format(dateObj, "longDate"); : (dateObj: Date) => format(dateObj, "dddd, MMMM D");
const formatDateShortMem = memoizeOne(
(locale: FrontendLocaleData) =>
new Intl.DateTimeFormat(locale.language, {
day: "numeric",
month: "short",
})
);
export const formatDateShort = toLocaleDateStringSupportsOptions
? (dateObj: Date, locale: FrontendLocaleData) =>
formatDateShortMem(locale).format(dateObj)
: (dateObj: Date) => format(dateObj, "shortDate");
const formatDateWeekdayMem = memoizeOne( const formatDateWeekdayMem = memoizeOne(
(locale: FrontendLocaleData) => (locale: FrontendLocaleData) =>
new Intl.DateTimeFormat(locale.language, { new Intl.DateTimeFormat(locale.language, {
@ -39,7 +17,80 @@ const formatDateWeekdayMem = memoizeOne(
}) })
); );
export const formatDateWeekday = toLocaleDateStringSupportsOptions // August 10, 2021
export const formatDate = toLocaleDateStringSupportsOptions
? (dateObj: Date, locale: FrontendLocaleData) => ? (dateObj: Date, locale: FrontendLocaleData) =>
formatDateWeekdayMem(locale).format(dateObj) formatDateMem(locale).format(dateObj)
: (dateObj: Date) => format(dateObj, "dddd, MMM D"); : (dateObj: Date) => format(dateObj, "MMMM D, YYYY");
const formatDateMem = memoizeOne(
(locale: FrontendLocaleData) =>
new Intl.DateTimeFormat(locale.language, {
year: "numeric",
month: "long",
day: "numeric",
})
);
// 10/08/2021
export const formatDateNumeric = toLocaleDateStringSupportsOptions
? (dateObj: Date, locale: FrontendLocaleData) =>
formatDateNumericMem(locale).format(dateObj)
: (dateObj: Date) => format(dateObj, "M/D/YYYY");
const formatDateNumericMem = memoizeOne(
(locale: FrontendLocaleData) =>
new Intl.DateTimeFormat(locale.language, {
year: "numeric",
month: "numeric",
day: "numeric",
})
);
// Aug 10
export const formatDateShort = toLocaleDateStringSupportsOptions
? (dateObj: Date, locale: FrontendLocaleData) =>
formatDateShortMem(locale).format(dateObj)
: (dateObj: Date) => format(dateObj, "MMM D");
const formatDateShortMem = memoizeOne(
(locale: FrontendLocaleData) =>
new Intl.DateTimeFormat(locale.language, {
day: "numeric",
month: "short",
})
);
// August 2021
export const formatDateMonthYear = toLocaleDateStringSupportsOptions
? (dateObj: Date, locale: FrontendLocaleData) =>
formatDateMonthYearMem(locale).format(dateObj)
: (dateObj: Date) => format(dateObj, "MMMM YYYY");
const formatDateMonthYearMem = memoizeOne(
(locale: FrontendLocaleData) =>
new Intl.DateTimeFormat(locale.language, {
month: "long",
year: "numeric",
})
);
// August
export const formatDateMonth = toLocaleDateStringSupportsOptions
? (dateObj: Date, locale: FrontendLocaleData) =>
formatDateMonthMem(locale).format(dateObj)
: (dateObj: Date) => format(dateObj, "MMMM");
const formatDateMonthMem = memoizeOne(
(locale: FrontendLocaleData) =>
new Intl.DateTimeFormat(locale.language, {
month: "long",
})
);
// 2021
export const formatDateYear = toLocaleDateStringSupportsOptions
? (dateObj: Date, locale: FrontendLocaleData) =>
formatDateYearMem(locale).format(dateObj)
: (dateObj: Date) => format(dateObj, "YYYY");
const formatDateYearMem = memoizeOne(
(locale: FrontendLocaleData) =>
new Intl.DateTimeFormat(locale.language, {
year: "numeric",
})
);

View File

@ -4,6 +4,12 @@ import { FrontendLocaleData } from "../../data/translation";
import { toLocaleStringSupportsOptions } from "./check_options_support"; import { toLocaleStringSupportsOptions } from "./check_options_support";
import { useAmPm } from "./use_am_pm"; import { useAmPm } from "./use_am_pm";
// August 9, 2021, 8:23 AM
export const formatDateTime = toLocaleStringSupportsOptions
? (dateObj: Date, locale: FrontendLocaleData) =>
formatDateTimeMem(locale).format(dateObj)
: (dateObj: Date, locale: FrontendLocaleData) =>
format(dateObj, "MMMM D, YYYY, HH:mm" + useAmPm(locale) ? " A" : "");
const formatDateTimeMem = memoizeOne( const formatDateTimeMem = memoizeOne(
(locale: FrontendLocaleData) => (locale: FrontendLocaleData) =>
new Intl.DateTimeFormat(locale.language, { new Intl.DateTimeFormat(locale.language, {
@ -16,12 +22,12 @@ const formatDateTimeMem = memoizeOne(
}) })
); );
export const formatDateTime = toLocaleStringSupportsOptions // August 9, 2021, 8:23:15 AM
export const formatDateTimeWithSeconds = toLocaleStringSupportsOptions
? (dateObj: Date, locale: FrontendLocaleData) => ? (dateObj: Date, locale: FrontendLocaleData) =>
formatDateTimeMem(locale).format(dateObj) formatDateTimeWithSecondsMem(locale).format(dateObj)
: (dateObj: Date, locale: FrontendLocaleData) => : (dateObj: Date, locale: FrontendLocaleData) =>
format(dateObj, "MMMM D, YYYY, HH:mm" + useAmPm(locale) ? " A" : ""); format(dateObj, "MMMM D, YYYY, HH:mm:ss" + useAmPm(locale) ? " A" : "");
const formatDateTimeWithSecondsMem = memoizeOne( const formatDateTimeWithSecondsMem = memoizeOne(
(locale: FrontendLocaleData) => (locale: FrontendLocaleData) =>
new Intl.DateTimeFormat(locale.language, { new Intl.DateTimeFormat(locale.language, {
@ -35,8 +41,20 @@ const formatDateTimeWithSecondsMem = memoizeOne(
}) })
); );
export const formatDateTimeWithSeconds = toLocaleStringSupportsOptions // 9/8/2021, 8:23 AM
export const formatDateTimeNumeric = toLocaleStringSupportsOptions
? (dateObj: Date, locale: FrontendLocaleData) => ? (dateObj: Date, locale: FrontendLocaleData) =>
formatDateTimeWithSecondsMem(locale).format(dateObj) formatDateTimeNumericMem(locale).format(dateObj)
: (dateObj: Date, locale: FrontendLocaleData) => : (dateObj: Date, locale: FrontendLocaleData) =>
format(dateObj, "MMMM D, YYYY, HH:mm:ss" + useAmPm(locale) ? " A" : ""); format(dateObj, "M/D/YYYY, HH:mm" + useAmPm(locale) ? " A" : "");
const formatDateTimeNumericMem = memoizeOne(
(locale: FrontendLocaleData) =>
new Intl.DateTimeFormat(locale.language, {
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "2-digit",
hour12: useAmPm(locale),
})
);

View File

@ -4,6 +4,12 @@ import { FrontendLocaleData } from "../../data/translation";
import { toLocaleTimeStringSupportsOptions } from "./check_options_support"; import { toLocaleTimeStringSupportsOptions } from "./check_options_support";
import { useAmPm } from "./use_am_pm"; import { useAmPm } from "./use_am_pm";
// 9:15 PM || 21:15
export const formatTime = toLocaleTimeStringSupportsOptions
? (dateObj: Date, locale: FrontendLocaleData) =>
formatTimeMem(locale).format(dateObj)
: (dateObj: Date, locale: FrontendLocaleData) =>
format(dateObj, "shortTime" + useAmPm(locale) ? " A" : "");
const formatTimeMem = memoizeOne( const formatTimeMem = memoizeOne(
(locale: FrontendLocaleData) => (locale: FrontendLocaleData) =>
new Intl.DateTimeFormat(locale.language, { new Intl.DateTimeFormat(locale.language, {
@ -13,12 +19,12 @@ const formatTimeMem = memoizeOne(
}) })
); );
export const formatTime = toLocaleTimeStringSupportsOptions // 9:15:24 PM || 21:15:24
export const formatTimeWithSeconds = toLocaleTimeStringSupportsOptions
? (dateObj: Date, locale: FrontendLocaleData) => ? (dateObj: Date, locale: FrontendLocaleData) =>
formatTimeMem(locale).format(dateObj) formatTimeWithSecondsMem(locale).format(dateObj)
: (dateObj: Date, locale: FrontendLocaleData) => : (dateObj: Date, locale: FrontendLocaleData) =>
format(dateObj, "shortTime" + useAmPm(locale) ? " A" : ""); format(dateObj, "mediumTime" + useAmPm(locale) ? " A" : "");
const formatTimeWithSecondsMem = memoizeOne( const formatTimeWithSecondsMem = memoizeOne(
(locale: FrontendLocaleData) => (locale: FrontendLocaleData) =>
new Intl.DateTimeFormat(locale.language, { new Intl.DateTimeFormat(locale.language, {
@ -29,12 +35,12 @@ const formatTimeWithSecondsMem = memoizeOne(
}) })
); );
export const formatTimeWithSeconds = toLocaleTimeStringSupportsOptions // Tuesday 7:00 PM || Tuesday 19:00
export const formatTimeWeekday = toLocaleTimeStringSupportsOptions
? (dateObj: Date, locale: FrontendLocaleData) => ? (dateObj: Date, locale: FrontendLocaleData) =>
formatTimeWithSecondsMem(locale).format(dateObj) formatTimeWeekdayMem(locale).format(dateObj)
: (dateObj: Date, locale: FrontendLocaleData) => : (dateObj: Date, locale: FrontendLocaleData) =>
format(dateObj, "mediumTime" + useAmPm(locale) ? " A" : ""); format(dateObj, "dddd, HH:mm" + useAmPm(locale) ? " A" : "");
const formatTimeWeekdayMem = memoizeOne( const formatTimeWeekdayMem = memoizeOne(
(locale: FrontendLocaleData) => (locale: FrontendLocaleData) =>
new Intl.DateTimeFormat(locale.language, { new Intl.DateTimeFormat(locale.language, {
@ -44,9 +50,3 @@ const formatTimeWeekdayMem = memoizeOne(
hour12: useAmPm(locale), hour12: useAmPm(locale),
}) })
); );
export const formatTimeWeekday = toLocaleTimeStringSupportsOptions
? (dateObj: Date, locale: FrontendLocaleData) =>
formatTimeWeekdayMem(locale).format(dateObj)
: (dateObj: Date, locale: FrontendLocaleData) =>
format(dateObj, "dddd, HH:mm" + useAmPm(locale) ? " A" : "");

View File

@ -1,6 +1,7 @@
import memoizeOne from "memoize-one";
import { FrontendLocaleData, TimeFormat } from "../../data/translation"; import { FrontendLocaleData, TimeFormat } from "../../data/translation";
export const useAmPm = (locale: FrontendLocaleData): boolean => { export const useAmPm = memoizeOne((locale: FrontendLocaleData): boolean => {
if ( if (
locale.time_format === TimeFormat.language || locale.time_format === TimeFormat.language ||
locale.time_format === TimeFormat.system locale.time_format === TimeFormat.system
@ -12,4 +13,4 @@ export const useAmPm = (locale: FrontendLocaleData): boolean => {
} }
return locale.time_format === TimeFormat.am_pm; return locale.time_format === TimeFormat.am_pm;
}; });

View File

@ -35,7 +35,14 @@ import {
endOfQuarter, endOfQuarter,
endOfYear, endOfYear,
} from "date-fns"; } from "date-fns";
import { formatDate, formatDateShort } from "../../common/datetime/format_date"; import {
formatDate,
formatDateMonth,
formatDateMonthYear,
formatDateShort,
formatDateWeekday,
formatDateYear,
} from "../../common/datetime/format_date";
import { import {
formatDateTime, formatDateTime,
formatDateTimeWithSeconds, formatDateTimeWithSeconds,
@ -53,8 +60,11 @@ const FORMATS = {
minute: "minute", minute: "minute",
hour: "hour", hour: "hour",
day: "day", day: "day",
date: "date",
weekday: "weekday",
week: "week", week: "week",
month: "month", month: "month",
monthyear: "monthyear",
quarter: "quarter", quarter: "quarter",
year: "year", year: "year",
}; };
@ -81,16 +91,22 @@ _adapters._date.override({
return formatTime(new Date(time), this.options.locale); return formatTime(new Date(time), this.options.locale);
case "hour": case "hour":
return formatTime(new Date(time), this.options.locale); return formatTime(new Date(time), this.options.locale);
case "weekday":
return formatDateWeekday(new Date(time), this.options.locale);
case "date":
return formatDate(new Date(time), this.options.locale);
case "day": case "day":
return formatDateShort(new Date(time), this.options.locale); return formatDateShort(new Date(time), this.options.locale);
case "week": case "week":
return formatDate(new Date(time), this.options.locale); return formatDate(new Date(time), this.options.locale);
case "month": case "month":
return formatDate(new Date(time), this.options.locale); return formatDateMonth(new Date(time), this.options.locale);
case "monthyear":
return formatDateMonthYear(new Date(time), this.options.locale);
case "quarter": case "quarter":
return formatDate(new Date(time), this.options.locale); return formatDate(new Date(time), this.options.locale);
case "year": case "year":
return formatDate(new Date(time), this.options.locale); return formatDateYear(new Date(time), this.options.locale);
default: default:
return ""; return "";
} }

View File

@ -15,6 +15,8 @@ export class HaButtonToggleGroup extends LitElement {
@property({ type: Boolean }) public fullWidth = false; @property({ type: Boolean }) public fullWidth = false;
@property({ type: Boolean }) public dense = false;
protected render(): TemplateResult { protected render(): TemplateResult {
return html` return html`
<div> <div>
@ -34,6 +36,8 @@ export class HaButtonToggleGroup extends LitElement {
? `${100 / this.buttons.length}%` ? `${100 / this.buttons.length}%`
: "initial", : "initial",
})} })}
outlined
.dense=${this.dense}
.value=${button.value} .value=${button.value}
?active=${this.active === button.value} ?active=${this.active === button.value}
@click=${this._handleClick} @click=${this._handleClick}
@ -56,10 +60,16 @@ export class HaButtonToggleGroup extends LitElement {
--mdc-icon-button-size: var(--button-toggle-size, 36px); --mdc-icon-button-size: var(--button-toggle-size, 36px);
--mdc-icon-size: var(--button-toggle-icon-size, 20px); --mdc-icon-size: var(--button-toggle-icon-size, 20px);
} }
mwc-icon-button,
mwc-button { mwc-button {
--mdc-shape-small: 0;
--mdc-button-outline-width: 1px 0 1px 1px;
}
mwc-icon-button {
border: 1px solid var(--primary-color); border: 1px solid var(--primary-color);
border-right-width: 0px; border-right-width: 0px;
}
mwc-icon-button,
mwc-button {
position: relative; position: relative;
cursor: pointer; cursor: pointer;
} }
@ -82,16 +92,19 @@ export class HaButtonToggleGroup extends LitElement {
} }
mwc-icon-button:first-child, mwc-icon-button:first-child,
mwc-button:first-child { mwc-button:first-child {
--mdc-shape-small: 4px 0 0 4px;
border-radius: 4px 0 0 4px; border-radius: 4px 0 0 4px;
} }
mwc-icon-button:last-child, mwc-icon-button:last-child,
mwc-button:last-child { mwc-button:last-child {
border-radius: 0 4px 4px 0; border-radius: 0 4px 4px 0;
border-right-width: 1px; border-right-width: 1px;
--mdc-shape-small: 0 4px 4px 0;
--mdc-button-outline-width: 1px;
} }
mwc-icon-button:only-child, mwc-icon-button:only-child,
mwc-button:only-child { mwc-button:only-child {
border-radius: 4px; --mdc-shape-small: 4px;
border-right-width: 1px; border-right-width: 1px;
} }
`; `;

View File

@ -1,10 +1,8 @@
import { html, LitElement } from "lit"; import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { useAmPm } from "../../common/datetime/use_am_pm"; import { useAmPm } from "../../common/datetime/use_am_pm";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import { TimeSelector } from "../../data/selector"; import { TimeSelector } from "../../data/selector";
import { FrontendLocaleData } from "../../data/translation";
import { HomeAssistant } from "../../types"; import { HomeAssistant } from "../../types";
import "../paper-time-input"; import "../paper-time-input";
@ -20,12 +18,8 @@ export class HaTimeSelector extends LitElement {
@property({ type: Boolean }) public disabled = false; @property({ type: Boolean }) public disabled = false;
private _useAmPmMem = memoizeOne((locale: FrontendLocaleData): boolean =>
useAmPm(locale)
);
protected render() { protected render() {
const useAMPM = this._useAmPmMem(this.hass.locale); const useAMPM = useAmPm(this.hass.locale);
const parts = this.value?.split(":") || []; const parts = this.value?.split(":") || [];
const hours = parts[0]; const hours = parts[0];
@ -50,7 +44,7 @@ export class HaTimeSelector extends LitElement {
private _timeChanged(ev) { private _timeChanged(ev) {
let value = ev.target.value; let value = ev.target.value;
const useAMPM = this._useAmPmMem(this.hass.locale); const useAMPM = useAmPm(this.hass.locale);
let hours = Number(ev.target.hour || 0); let hours = Number(ev.target.hour || 0);
if (value && useAMPM) { if (value && useAMPM) {
if (ev.target.amPm === "PM") { if (ev.target.amPm === "PM") {

View File

@ -1,3 +1,4 @@
import { addDays, addMonths, startOfDay, startOfMonth } from "date-fns";
import { HassEntity } from "home-assistant-js-websocket"; import { HassEntity } from "home-assistant-js-websocket";
import { computeStateDisplay } from "../common/entity/compute_state_display"; import { computeStateDisplay } from "../common/entity/compute_state_display";
import { computeStateDomain } from "../common/entity/compute_state_domain"; import { computeStateDomain } from "../common/entity/compute_state_domain";
@ -406,3 +407,90 @@ export const calculateStatisticsSumGrowthWithPercentage = (
return sum; return sum;
}; };
export const reduceSumStatisticsByDay = (
values: StatisticValue[]
): StatisticValue[] => {
if (!values?.length) {
return [];
}
const result: StatisticValue[] = [];
if (
values.length > 1 &&
new Date(values[0].start).getDate() === new Date(values[1].start).getDate()
) {
// add init value if the first value isn't end of previous period
result.push({
...values[0]!,
start: startOfMonth(addDays(new Date(values[0].start), -1)).toISOString(),
});
}
let lastValue: StatisticValue;
let prevDate: number | undefined;
for (const value of values) {
const date = new Date(value.start).getDate();
if (prevDate === undefined) {
prevDate = date;
}
if (prevDate !== date) {
// Last value of the day
result.push({
...lastValue!,
start: startOfDay(new Date(lastValue!.start)).toISOString(),
});
prevDate = date;
}
lastValue = value;
}
// Add final value
result.push({
...lastValue!,
start: startOfDay(new Date(lastValue!.start)).toISOString(),
});
return result;
};
export const reduceSumStatisticsByMonth = (
values: StatisticValue[]
): StatisticValue[] => {
if (!values?.length) {
return [];
}
const result: StatisticValue[] = [];
if (
values.length > 1 &&
new Date(values[0].start).getMonth() ===
new Date(values[1].start).getMonth()
) {
// add init value if the first value isn't end of previous period
result.push({
...values[0]!,
start: startOfMonth(
addMonths(new Date(values[0].start), -1)
).toISOString(),
});
}
let lastValue: StatisticValue;
let prevMonth: number | undefined;
for (const value of values) {
const month = new Date(value.start).getMonth();
if (prevMonth === undefined) {
prevMonth = month;
}
if (prevMonth !== month) {
// Last value of the day
result.push({
...lastValue!,
start: startOfMonth(new Date(lastValue!.start)).toISOString(),
});
prevMonth = month;
}
lastValue = value;
}
// Add final value
result.push({
...lastValue!,
start: startOfMonth(new Date(lastValue!.start)).toISOString(),
});
return result;
};

View File

@ -10,7 +10,13 @@ import {
ChartOptions, ChartOptions,
ScatterDataPoint, ScatterDataPoint,
} from "chart.js"; } from "chart.js";
import { endOfToday, isToday, startOfToday } from "date-fns"; import {
addHours,
differenceInDays,
endOfToday,
isToday,
startOfToday,
} from "date-fns";
import { HomeAssistant } from "../../../../types"; import { HomeAssistant } from "../../../../types";
import { LovelaceCard } from "../../types"; import { LovelaceCard } from "../../types";
import { EnergySolarGraphCardConfig } from "../types"; import { EnergySolarGraphCardConfig } from "../types";
@ -39,6 +45,10 @@ import {
} from "../../../../common/string/format_number"; } from "../../../../common/string/format_number";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin"; import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import { FrontendLocaleData } from "../../../../data/translation"; import { FrontendLocaleData } from "../../../../data/translation";
import {
reduceSumStatisticsByMonth,
reduceSumStatisticsByDay,
} from "../../../../data/history";
@customElement("hui-energy-solar-graph-card") @customElement("hui-energy-solar-graph-card")
export class HuiEnergySolarGraphCard export class HuiEnergySolarGraphCard
@ -110,84 +120,108 @@ export class HuiEnergySolarGraphCard
} }
private _createOptions = memoizeOne( private _createOptions = memoizeOne(
(start: Date, end: Date, locale: FrontendLocaleData): ChartOptions => ({ (start: Date, end: Date, locale: FrontendLocaleData): ChartOptions => {
parsing: false, const dayDifference = differenceInDays(end, start);
animation: false, return {
scales: { parsing: false,
x: { animation: false,
type: "time", scales: {
suggestedMin: start.getTime(), x: {
suggestedMax: end.getTime(), type: "time",
adapters: { suggestedMin: (dayDifference > 2
date: { ? addHours(start, -11)
locale: locale, : start
).getTime(),
suggestedMax: (dayDifference > 2
? addHours(end, -11)
: end
).getTime(),
adapters: {
date: {
locale: locale,
},
},
ticks: {
maxRotation: 0,
sampleSize: 5,
autoSkipPadding: 20,
major: {
enabled: true,
},
font: (context) =>
context.tick && context.tick.major
? ({ weight: "bold" } as any)
: {},
},
time: {
tooltipFormat:
dayDifference > 35
? "monthyear"
: dayDifference > 7
? "date"
: dayDifference > 2
? "weekday"
: dayDifference > 0
? "datetime"
: "hour",
minUnit:
dayDifference > 35
? "month"
: dayDifference > 2
? "day"
: "hour",
},
offset: true,
},
y: {
type: "linear",
title: {
display: true,
text: "kWh",
},
ticks: {
beginAtZero: true,
}, },
}, },
ticks: { },
maxRotation: 0, plugins: {
sampleSize: 5, tooltip: {
autoSkipPadding: 20, mode: "nearest",
major: { callbacks: {
enabled: true, label: (context) =>
`${context.dataset.label}: ${formatNumber(
context.parsed.y,
locale
)} kWh`,
}, },
font: (context) =>
context.tick && context.tick.major
? ({ weight: "bold" } as any)
: {},
}, },
time: { filler: {
tooltipFormat: "datetime", propagate: false,
}, },
offset: true, legend: {
}, display: false,
y: { labels: {
type: "linear", usePointStyle: true,
title: { },
display: true,
text: "kWh",
},
ticks: {
beginAtZero: true,
}, },
}, },
}, hover: {
plugins: {
tooltip: {
mode: "nearest", mode: "nearest",
callbacks: { },
label: (context) => elements: {
`${context.dataset.label}: ${formatNumber( line: {
context.parsed.y, tension: 0.3,
locale borderWidth: 1.5,
)} kWh`, },
bar: { borderWidth: 1.5, borderRadius: 4 },
point: {
hitRadius: 5,
}, },
}, },
filler: { // @ts-expect-error
propagate: false, locale: numberFormatToLocale(locale),
}, };
legend: { }
display: false,
labels: {
usePointStyle: true,
},
},
},
hover: {
mode: "nearest",
},
elements: {
line: {
tension: 0.3,
borderWidth: 1.5,
},
bar: { borderWidth: 1.5, borderRadius: 4 },
point: {
hitRadius: 5,
},
},
// @ts-expect-error
locale: numberFormatToLocale(locale),
})
); );
private async _getStatistics(energyData: EnergyData): Promise<void> { private async _getStatistics(energyData: EnergyData): Promise<void> {
@ -229,6 +263,11 @@ export class HuiEnergySolarGraphCard
.getPropertyValue("--energy-solar-color") .getPropertyValue("--energy-solar-color")
.trim(); .trim();
const dayDifference = differenceInDays(
energyData.end || new Date(),
energyData.start
);
solarSources.forEach((source, idx) => { solarSources.forEach((source, idx) => {
const data: ChartDataset<"bar" | "line">[] = []; const data: ChartDataset<"bar" | "line">[] = [];
const entity = this.hass.states[source.stat_energy_from]; const entity = this.hass.states[source.stat_energy_from];
@ -244,9 +283,20 @@ export class HuiEnergySolarGraphCard
const solarProductionData: ScatterDataPoint[] = []; const solarProductionData: ScatterDataPoint[] = [];
// Process solar production data. // Process solar production data.
if (energyData.stats[source.stat_energy_from]) { if (source.stat_energy_from in energyData.stats) {
for (const point of energyData.stats[source.stat_energy_from]) { const stats =
if (!point.sum) { dayDifference > 35
? reduceSumStatisticsByMonth(
energyData.stats[source.stat_energy_from]
)
: dayDifference > 2
? reduceSumStatisticsByDay(
energyData.stats[source.stat_energy_from]
)
: energyData.stats[source.stat_energy_from];
for (const point of stats) {
if (point.sum === null) {
continue; continue;
} }
if (prevValue === null) { if (prevValue === null) {
@ -294,7 +344,14 @@ export class HuiEnergySolarGraphCard
) { ) {
return; return;
} }
dateObj.setMinutes(0, 0, 0); if (dayDifference > 35) {
dateObj.setDate(1);
}
if (dayDifference > 2) {
dateObj.setHours(0, 0, 0, 0);
} else {
dateObj.setMinutes(0, 0, 0);
}
const time = dateObj.getTime(); const time = dateObj.getTime();
if (time in forecastsData) { if (time in forecastsData) {
forecastsData[time] += value; forecastsData[time] += value;

View File

@ -1,5 +1,11 @@
import { ChartData, ChartDataset, ChartOptions } from "chart.js"; import { ChartData, ChartDataset, ChartOptions } from "chart.js";
import { startOfToday, endOfToday, isToday } from "date-fns"; import {
startOfToday,
endOfToday,
isToday,
differenceInDays,
addHours,
} from "date-fns";
import { UnsubscribeFunc } from "home-assistant-js-websocket"; import { UnsubscribeFunc } from "home-assistant-js-websocket";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
@ -21,6 +27,10 @@ import {
import "../../../../components/chart/ha-chart-base"; import "../../../../components/chart/ha-chart-base";
import "../../../../components/ha-card"; import "../../../../components/ha-card";
import { EnergyData, getEnergyDataCollection } from "../../../../data/energy"; import { EnergyData, getEnergyDataCollection } from "../../../../data/energy";
import {
reduceSumStatisticsByDay,
reduceSumStatisticsByMonth,
} from "../../../../data/history";
import { FrontendLocaleData } from "../../../../data/translation"; import { FrontendLocaleData } from "../../../../data/translation";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin"; import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import { HomeAssistant } from "../../../../types"; import { HomeAssistant } from "../../../../types";
@ -97,106 +107,136 @@ export class HuiEnergyUsageGraphCard
} }
private _createOptions = memoizeOne( private _createOptions = memoizeOne(
(start: Date, end: Date, locale: FrontendLocaleData): ChartOptions => ({ (start: Date, end: Date, locale: FrontendLocaleData): ChartOptions => {
parsing: false, const dayDifference = differenceInDays(end, start);
animation: false, return {
scales: { parsing: false,
x: { animation: false,
type: "time", scales: {
suggestedMin: start.getTime(), x: {
suggestedMax: end.getTime(), type: "time",
adapters: { suggestedMin: (dayDifference > 2
date: { ? addHours(start, -11)
locale: locale, : start
).getTime(),
suggestedMax: (dayDifference > 2
? addHours(end, -11)
: end
).getTime(),
adapters: {
date: {
locale: locale,
},
},
ticks: {
maxRotation: 0,
sampleSize: 5,
autoSkipPadding: 20,
major: {
enabled: true,
},
font: (context) =>
context.tick && context.tick.major
? ({ weight: "bold" } as any)
: {},
},
time: {
tooltipFormat:
dayDifference > 35
? "monthyear"
: dayDifference > 7
? "date"
: dayDifference > 2
? "weekday"
: dayDifference > 0
? "datetime"
: "hour",
minUnit:
dayDifference > 35
? "month"
: dayDifference > 2
? "day"
: "hour",
},
offset: true,
},
y: {
stacked: true,
type: "linear",
title: {
display: true,
text: "kWh",
},
ticks: {
beginAtZero: true,
callback: (value) => formatNumber(Math.abs(value), locale),
}, },
}, },
ticks: {
maxRotation: 0,
sampleSize: 5,
autoSkipPadding: 20,
major: {
enabled: true,
},
font: (context) =>
context.tick && context.tick.major
? ({ weight: "bold" } as any)
: {},
},
time: {
tooltipFormat: "datetime",
},
offset: true,
}, },
y: { plugins: {
stacked: true, tooltip: {
type: "linear", mode: "x",
title: { intersect: true,
display: true, position: "nearest",
text: "kWh", filter: (val) => val.formattedValue !== "0",
}, callbacks: {
ticks: { label: (context) =>
beginAtZero: true, `${context.dataset.label}: ${formatNumber(
callback: (value) => formatNumber(Math.abs(value), locale), Math.abs(context.parsed.y),
}, locale
}, )} kWh`,
}, footer: (contexts) => {
plugins: { let totalConsumed = 0;
tooltip: { let totalReturned = 0;
mode: "x", for (const context of contexts) {
intersect: true, const value = (context.dataset.data[context.dataIndex] as any)
position: "nearest", .y;
filter: (val) => val.formattedValue !== "0", if (value > 0) {
callbacks: { totalConsumed += value;
label: (context) => } else {
`${context.dataset.label}: ${formatNumber( totalReturned += Math.abs(value);
Math.abs(context.parsed.y), }
locale
)} kWh`,
footer: (contexts) => {
let totalConsumed = 0;
let totalReturned = 0;
for (const context of contexts) {
const value = (context.dataset.data[context.dataIndex] as any)
.y;
if (value > 0) {
totalConsumed += value;
} else {
totalReturned += Math.abs(value);
} }
} return [
return [ totalConsumed
totalConsumed ? `Total consumed: ${formatNumber(
? `Total consumed: ${formatNumber(totalConsumed, locale)} kWh` totalConsumed,
: "", locale
totalReturned )} kWh`
? `Total returned: ${formatNumber(totalReturned, locale)} kWh` : "",
: "", totalReturned
].filter(Boolean); ? `Total returned: ${formatNumber(
totalReturned,
locale
)} kWh`
: "",
].filter(Boolean);
},
},
},
filler: {
propagate: false,
},
legend: {
display: false,
labels: {
usePointStyle: true,
}, },
}, },
}, },
filler: { hover: {
propagate: false, mode: "nearest",
}, },
legend: { elements: {
display: false, bar: { borderWidth: 1.5, borderRadius: 4 },
labels: { point: {
usePointStyle: true, hitRadius: 5,
}, },
}, },
}, // @ts-expect-error
hover: { locale: numberFormatToLocale(locale),
mode: "nearest", };
}, }
elements: {
bar: { borderWidth: 1.5, borderRadius: 4 },
point: {
hitRadius: 5,
},
},
// @ts-expect-error
locale: numberFormatToLocale(locale),
})
); );
private async _getStatistics(energyData: EnergyData): Promise<void> { private async _getStatistics(energyData: EnergyData): Promise<void> {
@ -233,7 +273,13 @@ export class HuiEnergyUsageGraphCard
} }
} }
const dayDifference = differenceInDays(
energyData.end || new Date(),
energyData.start
);
const statisticsData = Object.values(energyData.stats); const statisticsData = Object.values(energyData.stats);
const datasets: ChartDataset<"bar">[] = []; const datasets: ChartDataset<"bar">[] = [];
let endTime: Date; let endTime: Date;
@ -287,14 +333,20 @@ export class HuiEnergyUsageGraphCard
const totalStats: { [start: string]: number } = {}; const totalStats: { [start: string]: number } = {};
const sets: { [statId: string]: { [start: string]: number } } = {}; const sets: { [statId: string]: { [start: string]: number } } = {};
statIds!.forEach((id) => { statIds!.forEach((id) => {
const stats = energyData.stats[id]; const stats =
dayDifference > 35
? reduceSumStatisticsByMonth(energyData.stats[id])
: dayDifference > 2
? reduceSumStatisticsByDay(energyData.stats[id])
: energyData.stats[id];
if (!stats) { if (!stats) {
return; return;
} }
const set = {}; const set = {};
let prevValue: number; let prevValue: number;
stats.forEach((stat) => { stats.forEach((stat) => {
if (!stat.sum) { if (stat.sum === null) {
return; return;
} }
if (!prevValue) { if (!prevValue) {

View File

@ -1,15 +1,45 @@
import { mdiChevronLeft, mdiChevronRight } from "@mdi/js"; import { mdiChevronLeft, mdiChevronRight } from "@mdi/js";
import { endOfToday, addDays, endOfDay, isToday, startOfToday } from "date-fns"; import {
endOfToday,
addDays,
endOfDay,
startOfToday,
endOfWeek,
endOfMonth,
startOfDay,
startOfWeek,
startOfMonth,
addMonths,
addWeeks,
startOfYear,
addYears,
endOfYear,
isWithinInterval,
differenceInDays,
} from "date-fns";
import { UnsubscribeFunc } from "home-assistant-js-websocket"; import { UnsubscribeFunc } from "home-assistant-js-websocket";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { formatDate } from "../../../common/datetime/format_date"; import {
formatDate,
formatDateMonthYear,
formatDateShort,
formatDateYear,
} from "../../../common/datetime/format_date";
import { EnergyData, getEnergyDataCollection } from "../../../data/energy"; import { EnergyData, getEnergyDataCollection } from "../../../data/energy";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { HomeAssistant } from "../../../types"; import { HomeAssistant, ToggleButton } from "../../../types";
import "@material/mwc-icon-button/mwc-icon-button"; import "@material/mwc-icon-button/mwc-icon-button";
import "../../../components/ha-svg-icon"; import "../../../components/ha-svg-icon";
import "@material/mwc-button/mwc-button"; import "@material/mwc-button/mwc-button";
import "../../../components/ha-button-toggle-group";
const viewButtons: ToggleButton[] = [
{ label: "Day", value: "day" },
{ label: "Week", value: "week" },
{ label: "Month", value: "month" },
{ label: "Year", value: "year" },
];
@customElement("hui-energy-period-selector") @customElement("hui-energy-period-selector")
export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) { export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) {
@ -21,6 +51,8 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) {
@state() _endDate?: Date; @state() _endDate?: Date;
@state() private _period?: "day" | "week" | "month" | "year";
public hassSubscribe(): UnsubscribeFunc[] { public hassSubscribe(): UnsubscribeFunc[] {
return [ return [
getEnergyDataCollection(this.hass, { getEnergyDataCollection(this.hass, {
@ -37,41 +69,110 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) {
return html` return html`
<div class="row"> <div class="row">
<div class="label"> <div class="label">
${formatDate(this._startDate, this.hass.locale)} ${this._period === "day"
? formatDate(this._startDate, this.hass.locale)
: this._period === "month"
? formatDateMonthYear(this._startDate, this.hass.locale)
: this._period === "year"
? formatDateYear(this._startDate, this.hass.locale)
: `${formatDateShort(
this._startDate,
this.hass.locale
)} - ${formatDateShort(
this._endDate || new Date(),
this.hass.locale
)}`}
</div> </div>
<mwc-icon-button label="Previous Day" @click=${this._pickPreviousDay}> <mwc-icon-button label="Previous" @click=${this._pickPrevious}>
<ha-svg-icon .path=${mdiChevronLeft}></ha-svg-icon> <ha-svg-icon .path=${mdiChevronLeft}></ha-svg-icon>
</mwc-icon-button> </mwc-icon-button>
<mwc-icon-button label="Next Day" @click=${this._pickNextDay}> <mwc-icon-button label="Next" @click=${this._pickNext}>
<ha-svg-icon .path=${mdiChevronRight}></ha-svg-icon> <ha-svg-icon .path=${mdiChevronRight}></ha-svg-icon>
</mwc-icon-button> </mwc-icon-button>
<mwc-button <mwc-button dense outlined @click=${this._pickToday}>
Today
</mwc-button>
<ha-button-toggle-group
.buttons=${viewButtons}
.active=${this._period}
dense dense
outlined @value-changed=${this._handleView}
.disabled=${isToday(this._startDate)} ></ha-button-toggle-group>
@click=${this._pickToday}
>Today</mwc-button
>
</div> </div>
`; `;
} }
private _handleView(ev: CustomEvent): void {
this._period = ev.detail.value;
const today = startOfToday();
const start =
!this._startDate ||
isWithinInterval(today, {
start: this._startDate,
end: this._endDate || endOfToday(),
})
? today
: this._startDate;
this._setDate(
this._period === "day"
? startOfDay(start)
: this._period === "week"
? startOfWeek(start, { weekStartsOn: 1 })
: this._period === "month"
? startOfMonth(start)
: startOfYear(start)
);
}
private _pickToday() { private _pickToday() {
this._setDate(startOfToday()); this._setDate(
this._period === "day"
? startOfToday()
: this._period === "week"
? startOfWeek(new Date(), { weekStartsOn: 1 })
: this._period === "month"
? startOfMonth(new Date())
: startOfYear(new Date())
);
} }
private _pickPreviousDay() { private _pickPrevious() {
this._setDate(addDays(this._startDate!, -1)); const newStart =
this._period === "day"
? addDays(this._startDate!, -1)
: this._period === "week"
? addWeeks(this._startDate!, -1)
: this._period === "month"
? addMonths(this._startDate!, -1)
: addYears(this._startDate!, -1);
this._setDate(newStart);
} }
private _pickNextDay() { private _pickNext() {
this._setDate(addDays(this._startDate!, +1)); const newStart =
this._period === "day"
? addDays(this._startDate!, 1)
: this._period === "week"
? addWeeks(this._startDate!, 1)
: this._period === "month"
? addMonths(this._startDate!, 1)
: addYears(this._startDate!, 1);
this._setDate(newStart);
} }
private _setDate(startDate: Date) { private _setDate(startDate: Date) {
const endDate = endOfDay(startDate); const endDate =
this._period === "day"
? endOfDay(startDate)
: this._period === "week"
? endOfWeek(startDate, { weekStartsOn: 1 })
: this._period === "month"
? endOfMonth(startDate)
: endOfYear(startDate);
const energyCollection = getEnergyDataCollection(this.hass, { const energyCollection = getEnergyDataCollection(this.hass, {
key: this.collectionKey, key: this.collectionKey,
}); });
@ -82,6 +183,17 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) {
private _updateDates(energyData: EnergyData): void { private _updateDates(energyData: EnergyData): void {
this._startDate = energyData.start; this._startDate = energyData.start;
this._endDate = energyData.end || endOfToday(); this._endDate = energyData.end || endOfToday();
const dayDifference = differenceInDays(this._endDate, this._startDate);
this._period =
dayDifference < 1
? "day"
: dayDifference === 6
? "week"
: dayDifference > 26 && dayDifference < 31 // 28, 29, 30 or 31 days in a month
? "month"
: dayDifference === 364 || dayDifference === 365 // Leap year
? "year"
: undefined;
} }
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
@ -96,16 +208,20 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) {
text-align: center; text-align: center;
font-size: 20px; font-size: 20px;
} }
:host {
--mdc-button-outline-color: currentColor;
--primary-color: currentColor;
--mdc-theme-primary: currentColor;
--mdc-button-disabled-outline-color: var(--disabled-text-color);
--mdc-button-disabled-ink-color: var(--disabled-text-color);
--mdc-icon-button-ripple-opacity: 0.2;
}
mwc-icon-button { mwc-icon-button {
--mdc-icon-button-size: 28px; --mdc-icon-button-size: 28px;
} }
mwc-button { mwc-button,
ha-button-toggle-group {
padding-left: 8px; padding-left: 8px;
--mdc-theme-primary: currentColor;
--mdc-button-outline-color: currentColor;
--mdc-button-disabled-outline-color: var(--disabled-text-color);
--mdc-button-disabled-ink-color: var(--disabled-text-color);
} }
`; `;
} }

View File

@ -130,7 +130,7 @@ export type FullCalendarView =
export interface ToggleButton { export interface ToggleButton {
label: string; label: string;
iconPath: string; iconPath?: string;
value: string; value: string;
} }