diff --git a/demo/src/stubs/history.ts b/demo/src/stubs/history.ts index 342b6cc4f0..adb456a584 100644 --- a/demo/src/stubs/history.ts +++ b/demo/src/stubs/history.ts @@ -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 { StatisticValue } from "../../../src/data/history"; import { MockHomeAssistant } from "../../../src/fake_data/provide_hass"; @@ -222,6 +222,7 @@ const statisticsFunctions: Record< "sensor.energy_production_tarif_2": (id, start, end) => { const productionStart = new Date(start.getTime() + 9 * 60 * 60 * 1000); const productionEnd = new Date(start.getTime() + 21 * 60 * 60 * 1000); + const dayEnd = new Date(endOfDay(productionEnd)); const production = generateCurvedStatistics( id, productionStart, @@ -237,15 +238,17 @@ const statisticsFunctions: Record< const evening = generateSumStatistics( id, productionEnd, - end, + dayEnd, productionFinalVal, 0 ); - return [...morning, ...production, ...evening]; + const rest = generateSumStatistics(id, dayEnd, end, productionFinalVal, 1); + return [...morning, ...production, ...evening, ...rest]; }, "sensor.solar_production": (id, start, end) => { const productionStart = new Date(start.getTime() + 7 * 60 * 60 * 1000); const productionEnd = new Date(start.getTime() + 23 * 60 * 60 * 1000); + const dayEnd = new Date(endOfDay(productionEnd)); const production = generateCurvedStatistics( id, productionStart, @@ -261,11 +264,12 @@ const statisticsFunctions: Record< const evening = generateSumStatistics( id, productionEnd, - end, + dayEnd, productionFinalVal, 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) => generateMeanStatistics(id, start, end, 35, 1.3), diff --git a/package.json b/package.json index 1b7cb54886..8ca730506d 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "lint": "yarn run lint:eslint && yarn run lint:prettier && yarn run lint:types", "format": "yarn run format:eslint && yarn run format:prettier", "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 (http://paulusschoutsen.nl)", "license": "Apache-2.0", diff --git a/src/common/datetime/format_date.ts b/src/common/datetime/format_date.ts index e9b70430a0..f359973789 100644 --- a/src/common/datetime/format_date.ts +++ b/src/common/datetime/format_date.ts @@ -3,33 +3,11 @@ import memoizeOne from "memoize-one"; import { FrontendLocaleData } from "../../data/translation"; import { toLocaleDateStringSupportsOptions } from "./check_options_support"; -const formatDateMem = memoizeOne( - (locale: FrontendLocaleData) => - new Intl.DateTimeFormat(locale.language, { - year: "numeric", - month: "long", - day: "numeric", - }) -); - -export const formatDate = toLocaleDateStringSupportsOptions +// Tuesday, August 10 +export const formatDateWeekday = toLocaleDateStringSupportsOptions ? (dateObj: Date, locale: FrontendLocaleData) => - formatDateMem(locale).format(dateObj) - : (dateObj: Date) => format(dateObj, "longDate"); - -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"); - + formatDateWeekdayMem(locale).format(dateObj) + : (dateObj: Date) => format(dateObj, "dddd, MMMM D"); const formatDateWeekdayMem = memoizeOne( (locale: FrontendLocaleData) => 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) => - formatDateWeekdayMem(locale).format(dateObj) - : (dateObj: Date) => format(dateObj, "dddd, MMM D"); + formatDateMem(locale).format(dateObj) + : (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", + }) +); diff --git a/src/common/datetime/format_date_time.ts b/src/common/datetime/format_date_time.ts index 850e82b185..0b91a40b81 100644 --- a/src/common/datetime/format_date_time.ts +++ b/src/common/datetime/format_date_time.ts @@ -4,6 +4,12 @@ import { FrontendLocaleData } from "../../data/translation"; import { toLocaleStringSupportsOptions } from "./check_options_support"; 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( (locale: FrontendLocaleData) => 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) => - formatDateTimeMem(locale).format(dateObj) + formatDateTimeWithSecondsMem(locale).format(dateObj) : (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( (locale: FrontendLocaleData) => 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) => - formatDateTimeWithSecondsMem(locale).format(dateObj) + formatDateTimeNumericMem(locale).format(dateObj) : (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), + }) +); diff --git a/src/common/datetime/format_time.ts b/src/common/datetime/format_time.ts index 14bbdf8e53..a20e1feef5 100644 --- a/src/common/datetime/format_time.ts +++ b/src/common/datetime/format_time.ts @@ -4,6 +4,12 @@ import { FrontendLocaleData } from "../../data/translation"; import { toLocaleTimeStringSupportsOptions } from "./check_options_support"; 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( (locale: FrontendLocaleData) => 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) => - formatTimeMem(locale).format(dateObj) + formatTimeWithSecondsMem(locale).format(dateObj) : (dateObj: Date, locale: FrontendLocaleData) => - format(dateObj, "shortTime" + useAmPm(locale) ? " A" : ""); - + format(dateObj, "mediumTime" + useAmPm(locale) ? " A" : ""); const formatTimeWithSecondsMem = memoizeOne( (locale: FrontendLocaleData) => 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) => - formatTimeWithSecondsMem(locale).format(dateObj) + formatTimeWeekdayMem(locale).format(dateObj) : (dateObj: Date, locale: FrontendLocaleData) => - format(dateObj, "mediumTime" + useAmPm(locale) ? " A" : ""); - + format(dateObj, "dddd, HH:mm" + useAmPm(locale) ? " A" : ""); const formatTimeWeekdayMem = memoizeOne( (locale: FrontendLocaleData) => new Intl.DateTimeFormat(locale.language, { @@ -44,9 +50,3 @@ const formatTimeWeekdayMem = memoizeOne( 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" : ""); diff --git a/src/common/datetime/use_am_pm.ts b/src/common/datetime/use_am_pm.ts index cf5b2c124e..97bf2911b8 100644 --- a/src/common/datetime/use_am_pm.ts +++ b/src/common/datetime/use_am_pm.ts @@ -1,6 +1,7 @@ +import memoizeOne from "memoize-one"; import { FrontendLocaleData, TimeFormat } from "../../data/translation"; -export const useAmPm = (locale: FrontendLocaleData): boolean => { +export const useAmPm = memoizeOne((locale: FrontendLocaleData): boolean => { if ( locale.time_format === TimeFormat.language || locale.time_format === TimeFormat.system @@ -12,4 +13,4 @@ export const useAmPm = (locale: FrontendLocaleData): boolean => { } return locale.time_format === TimeFormat.am_pm; -}; +}); diff --git a/src/components/chart/chart-date-adapter.ts b/src/components/chart/chart-date-adapter.ts index a25163b662..97c78c3da3 100644 --- a/src/components/chart/chart-date-adapter.ts +++ b/src/components/chart/chart-date-adapter.ts @@ -35,7 +35,14 @@ import { endOfQuarter, endOfYear, } from "date-fns"; -import { formatDate, formatDateShort } from "../../common/datetime/format_date"; +import { + formatDate, + formatDateMonth, + formatDateMonthYear, + formatDateShort, + formatDateWeekday, + formatDateYear, +} from "../../common/datetime/format_date"; import { formatDateTime, formatDateTimeWithSeconds, @@ -53,8 +60,11 @@ const FORMATS = { minute: "minute", hour: "hour", day: "day", + date: "date", + weekday: "weekday", week: "week", month: "month", + monthyear: "monthyear", quarter: "quarter", year: "year", }; @@ -81,16 +91,22 @@ _adapters._date.override({ return formatTime(new Date(time), this.options.locale); case "hour": 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": return formatDateShort(new Date(time), this.options.locale); case "week": return formatDate(new Date(time), this.options.locale); 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": return formatDate(new Date(time), this.options.locale); case "year": - return formatDate(new Date(time), this.options.locale); + return formatDateYear(new Date(time), this.options.locale); default: return ""; } diff --git a/src/components/ha-button-toggle-group.ts b/src/components/ha-button-toggle-group.ts index 374be87341..3e24636d8e 100644 --- a/src/components/ha-button-toggle-group.ts +++ b/src/components/ha-button-toggle-group.ts @@ -15,6 +15,8 @@ export class HaButtonToggleGroup extends LitElement { @property({ type: Boolean }) public fullWidth = false; + @property({ type: Boolean }) public dense = false; + protected render(): TemplateResult { return html`
@@ -34,6 +36,8 @@ export class HaButtonToggleGroup extends LitElement { ? `${100 / this.buttons.length}%` : "initial", })} + outlined + .dense=${this.dense} .value=${button.value} ?active=${this.active === button.value} @click=${this._handleClick} @@ -56,10 +60,16 @@ export class HaButtonToggleGroup extends LitElement { --mdc-icon-button-size: var(--button-toggle-size, 36px); --mdc-icon-size: var(--button-toggle-icon-size, 20px); } - mwc-icon-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-right-width: 0px; + } + mwc-icon-button, + mwc-button { position: relative; cursor: pointer; } @@ -82,16 +92,19 @@ export class HaButtonToggleGroup extends LitElement { } mwc-icon-button:first-child, mwc-button:first-child { + --mdc-shape-small: 4px 0 0 4px; border-radius: 4px 0 0 4px; } mwc-icon-button:last-child, mwc-button:last-child { border-radius: 0 4px 4px 0; border-right-width: 1px; + --mdc-shape-small: 0 4px 4px 0; + --mdc-button-outline-width: 1px; } mwc-icon-button:only-child, mwc-button:only-child { - border-radius: 4px; + --mdc-shape-small: 4px; border-right-width: 1px; } `; diff --git a/src/components/ha-selector/ha-selector-time.ts b/src/components/ha-selector/ha-selector-time.ts index 84ab97c8da..9ac9ea8e51 100644 --- a/src/components/ha-selector/ha-selector-time.ts +++ b/src/components/ha-selector/ha-selector-time.ts @@ -1,10 +1,8 @@ import { html, LitElement } from "lit"; import { customElement, property } from "lit/decorators"; -import memoizeOne from "memoize-one"; import { useAmPm } from "../../common/datetime/use_am_pm"; import { fireEvent } from "../../common/dom/fire_event"; import { TimeSelector } from "../../data/selector"; -import { FrontendLocaleData } from "../../data/translation"; import { HomeAssistant } from "../../types"; import "../paper-time-input"; @@ -20,12 +18,8 @@ export class HaTimeSelector extends LitElement { @property({ type: Boolean }) public disabled = false; - private _useAmPmMem = memoizeOne((locale: FrontendLocaleData): boolean => - useAmPm(locale) - ); - protected render() { - const useAMPM = this._useAmPmMem(this.hass.locale); + const useAMPM = useAmPm(this.hass.locale); const parts = this.value?.split(":") || []; const hours = parts[0]; @@ -50,7 +44,7 @@ export class HaTimeSelector extends LitElement { private _timeChanged(ev) { 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); if (value && useAMPM) { if (ev.target.amPm === "PM") { diff --git a/src/data/history.ts b/src/data/history.ts index b1e1b9f82a..685524f289 100644 --- a/src/data/history.ts +++ b/src/data/history.ts @@ -1,3 +1,4 @@ +import { addDays, addMonths, startOfDay, startOfMonth } from "date-fns"; import { HassEntity } from "home-assistant-js-websocket"; import { computeStateDisplay } from "../common/entity/compute_state_display"; import { computeStateDomain } from "../common/entity/compute_state_domain"; @@ -406,3 +407,90 @@ export const calculateStatisticsSumGrowthWithPercentage = ( 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; +}; diff --git a/src/panels/lovelace/cards/energy/hui-energy-solar-graph-card.ts b/src/panels/lovelace/cards/energy/hui-energy-solar-graph-card.ts index f8e970aa5b..8b0c3be7c0 100644 --- a/src/panels/lovelace/cards/energy/hui-energy-solar-graph-card.ts +++ b/src/panels/lovelace/cards/energy/hui-energy-solar-graph-card.ts @@ -10,7 +10,13 @@ import { ChartOptions, ScatterDataPoint, } from "chart.js"; -import { endOfToday, isToday, startOfToday } from "date-fns"; +import { + addHours, + differenceInDays, + endOfToday, + isToday, + startOfToday, +} from "date-fns"; import { HomeAssistant } from "../../../../types"; import { LovelaceCard } from "../../types"; import { EnergySolarGraphCardConfig } from "../types"; @@ -39,6 +45,10 @@ import { } from "../../../../common/string/format_number"; import { SubscribeMixin } from "../../../../mixins/subscribe-mixin"; import { FrontendLocaleData } from "../../../../data/translation"; +import { + reduceSumStatisticsByMonth, + reduceSumStatisticsByDay, +} from "../../../../data/history"; @customElement("hui-energy-solar-graph-card") export class HuiEnergySolarGraphCard @@ -110,84 +120,108 @@ export class HuiEnergySolarGraphCard } private _createOptions = memoizeOne( - (start: Date, end: Date, locale: FrontendLocaleData): ChartOptions => ({ - parsing: false, - animation: false, - scales: { - x: { - type: "time", - suggestedMin: start.getTime(), - suggestedMax: end.getTime(), - adapters: { - date: { - locale: locale, + (start: Date, end: Date, locale: FrontendLocaleData): ChartOptions => { + const dayDifference = differenceInDays(end, start); + return { + parsing: false, + animation: false, + scales: { + x: { + type: "time", + suggestedMin: (dayDifference > 2 + ? addHours(start, -11) + : 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, - sampleSize: 5, - autoSkipPadding: 20, - major: { - enabled: true, + }, + plugins: { + tooltip: { + mode: "nearest", + callbacks: { + label: (context) => + `${context.dataset.label}: ${formatNumber( + context.parsed.y, + locale + )} kWh`, }, - font: (context) => - context.tick && context.tick.major - ? ({ weight: "bold" } as any) - : {}, }, - time: { - tooltipFormat: "datetime", + filler: { + propagate: false, }, - offset: true, - }, - y: { - type: "linear", - title: { - display: true, - text: "kWh", - }, - ticks: { - beginAtZero: true, + legend: { + display: false, + labels: { + usePointStyle: true, + }, }, }, - }, - plugins: { - tooltip: { + hover: { mode: "nearest", - callbacks: { - label: (context) => - `${context.dataset.label}: ${formatNumber( - context.parsed.y, - locale - )} kWh`, + }, + elements: { + line: { + tension: 0.3, + borderWidth: 1.5, + }, + bar: { borderWidth: 1.5, borderRadius: 4 }, + point: { + hitRadius: 5, }, }, - filler: { - propagate: false, - }, - 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), - }) + // @ts-expect-error + locale: numberFormatToLocale(locale), + }; + } ); private async _getStatistics(energyData: EnergyData): Promise { @@ -229,6 +263,11 @@ export class HuiEnergySolarGraphCard .getPropertyValue("--energy-solar-color") .trim(); + const dayDifference = differenceInDays( + energyData.end || new Date(), + energyData.start + ); + solarSources.forEach((source, idx) => { const data: ChartDataset<"bar" | "line">[] = []; const entity = this.hass.states[source.stat_energy_from]; @@ -244,9 +283,20 @@ export class HuiEnergySolarGraphCard const solarProductionData: ScatterDataPoint[] = []; // Process solar production data. - if (energyData.stats[source.stat_energy_from]) { - for (const point of energyData.stats[source.stat_energy_from]) { - if (!point.sum) { + if (source.stat_energy_from in energyData.stats) { + const stats = + 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; } if (prevValue === null) { @@ -294,7 +344,14 @@ export class HuiEnergySolarGraphCard ) { 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(); if (time in forecastsData) { forecastsData[time] += value; diff --git a/src/panels/lovelace/cards/energy/hui-energy-usage-graph-card.ts b/src/panels/lovelace/cards/energy/hui-energy-usage-graph-card.ts index 01abe63467..131efb96a6 100644 --- a/src/panels/lovelace/cards/energy/hui-energy-usage-graph-card.ts +++ b/src/panels/lovelace/cards/energy/hui-energy-usage-graph-card.ts @@ -1,5 +1,11 @@ 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 { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators"; @@ -21,6 +27,10 @@ import { import "../../../../components/chart/ha-chart-base"; import "../../../../components/ha-card"; import { EnergyData, getEnergyDataCollection } from "../../../../data/energy"; +import { + reduceSumStatisticsByDay, + reduceSumStatisticsByMonth, +} from "../../../../data/history"; import { FrontendLocaleData } from "../../../../data/translation"; import { SubscribeMixin } from "../../../../mixins/subscribe-mixin"; import { HomeAssistant } from "../../../../types"; @@ -97,106 +107,136 @@ export class HuiEnergyUsageGraphCard } private _createOptions = memoizeOne( - (start: Date, end: Date, locale: FrontendLocaleData): ChartOptions => ({ - parsing: false, - animation: false, - scales: { - x: { - type: "time", - suggestedMin: start.getTime(), - suggestedMax: end.getTime(), - adapters: { - date: { - locale: locale, + (start: Date, end: Date, locale: FrontendLocaleData): ChartOptions => { + const dayDifference = differenceInDays(end, start); + return { + parsing: false, + animation: false, + scales: { + x: { + type: "time", + suggestedMin: (dayDifference > 2 + ? addHours(start, -11) + : 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: { - stacked: true, - type: "linear", - title: { - display: true, - text: "kWh", - }, - ticks: { - beginAtZero: true, - callback: (value) => formatNumber(Math.abs(value), locale), - }, - }, - }, - plugins: { - tooltip: { - mode: "x", - intersect: true, - position: "nearest", - filter: (val) => val.formattedValue !== "0", - callbacks: { - label: (context) => - `${context.dataset.label}: ${formatNumber( - 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); + plugins: { + tooltip: { + mode: "x", + intersect: true, + position: "nearest", + filter: (val) => val.formattedValue !== "0", + callbacks: { + label: (context) => + `${context.dataset.label}: ${formatNumber( + 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 [ - totalConsumed - ? `Total consumed: ${formatNumber(totalConsumed, locale)} kWh` - : "", - totalReturned - ? `Total returned: ${formatNumber(totalReturned, locale)} kWh` - : "", - ].filter(Boolean); + return [ + totalConsumed + ? `Total consumed: ${formatNumber( + totalConsumed, + locale + )} kWh` + : "", + totalReturned + ? `Total returned: ${formatNumber( + totalReturned, + locale + )} kWh` + : "", + ].filter(Boolean); + }, + }, + }, + filler: { + propagate: false, + }, + legend: { + display: false, + labels: { + usePointStyle: true, }, }, }, - filler: { - propagate: false, + hover: { + mode: "nearest", }, - legend: { - display: false, - labels: { - usePointStyle: true, + elements: { + bar: { borderWidth: 1.5, borderRadius: 4 }, + point: { + hitRadius: 5, }, }, - }, - hover: { - mode: "nearest", - }, - elements: { - bar: { borderWidth: 1.5, borderRadius: 4 }, - point: { - hitRadius: 5, - }, - }, - // @ts-expect-error - locale: numberFormatToLocale(locale), - }) + // @ts-expect-error + locale: numberFormatToLocale(locale), + }; + } ); private async _getStatistics(energyData: EnergyData): Promise { @@ -233,7 +273,13 @@ export class HuiEnergyUsageGraphCard } } + const dayDifference = differenceInDays( + energyData.end || new Date(), + energyData.start + ); + const statisticsData = Object.values(energyData.stats); + const datasets: ChartDataset<"bar">[] = []; let endTime: Date; @@ -287,14 +333,20 @@ export class HuiEnergyUsageGraphCard const totalStats: { [start: string]: number } = {}; const sets: { [statId: string]: { [start: string]: number } } = {}; 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) { return; } + const set = {}; let prevValue: number; stats.forEach((stat) => { - if (!stat.sum) { + if (stat.sum === null) { return; } if (!prevValue) { diff --git a/src/panels/lovelace/components/hui-energy-period-selector.ts b/src/panels/lovelace/components/hui-energy-period-selector.ts index fa82e1d3ca..75bc850352 100644 --- a/src/panels/lovelace/components/hui-energy-period-selector.ts +++ b/src/panels/lovelace/components/hui-energy-period-selector.ts @@ -1,15 +1,45 @@ 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 { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; 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 { SubscribeMixin } from "../../../mixins/subscribe-mixin"; -import { HomeAssistant } from "../../../types"; +import { HomeAssistant, ToggleButton } from "../../../types"; import "@material/mwc-icon-button/mwc-icon-button"; import "../../../components/ha-svg-icon"; 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") export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) { @@ -21,6 +51,8 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) { @state() _endDate?: Date; + @state() private _period?: "day" | "week" | "month" | "year"; + public hassSubscribe(): UnsubscribeFunc[] { return [ getEnergyDataCollection(this.hass, { @@ -37,41 +69,110 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) { return html`
- ${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 + )}`}
- + - + - + Today + + Today + @value-changed=${this._handleView} + >
`; } + 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() { - 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() { - this._setDate(addDays(this._startDate!, -1)); + private _pickPrevious() { + 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() { - this._setDate(addDays(this._startDate!, +1)); + private _pickNext() { + 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) { - 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, { key: this.collectionKey, }); @@ -82,6 +183,17 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) { private _updateDates(energyData: EnergyData): void { this._startDate = energyData.start; 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 { @@ -96,16 +208,20 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) { text-align: center; 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 { --mdc-icon-button-size: 28px; } - mwc-button { + mwc-button, + ha-button-toggle-group { 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); } `; } diff --git a/src/types.ts b/src/types.ts index effe231479..5d25b7bc8e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -130,7 +130,7 @@ export type FullCalendarView = export interface ToggleButton { label: string; - iconPath: string; + iconPath?: string; value: string; }