Compare commits

...
Author SHA1 Message Date
Petar Petrov 7044f38bf8 Refresh energy data after the first short-term bucket.
Midnight rollover fetched before Core had short-term stats and then waited
until :20, leaving an open dashboard empty; schedule :05:15 then resume hourly.
2026-09-10 13:04:54 +03:00
Petar Petrov 5104db47f4 Stop switching to 5-minute stats in hour 0 for energy charts.
Core now stitches unfinished current-hour long-term stats from short-term
data, so the frontend can keep period=hour after midnight rollover.
2026-09-10 12:42:47 +03:00
Petar Petrov 30e1e2f1f8 Fix YoY compare using hourly stats during hour 0.
Choose the compare period from startCompare/endCompare independently, and only use 5-minute stats for the live incomplete hour so year-old short ranges keep hourly long-term statistics.
2026-09-10 11:24:50 +03:00
Petar Petrov 45f85a47fe Roll energy collections over at midnight using 5-minute stats in hour 0.
Hourly long-term statistics do not exist until the first hour ends, so the
overview previously showed yesterday until 01:00. Prefer short-term 5-minute
stats for hour-scale ranges while elapsed time is under an hour instead.
2026-09-10 09:47:59 +03:00
5 changed files with 86 additions and 348 deletions

No files matched your search

+32 -124
View File
@@ -1,6 +1,5 @@
import {
addDays,
addHours,
addMilliseconds,
addMonths,
addYears,
@@ -803,14 +802,21 @@ const scheduleHourlyRefresh = (collection: EnergyCollection) => {
}
if (collection._active && (!collection.end || collection.end > new Date())) {
// The stats are created every hour
// Schedule a refresh for 20 minutes past the hour
// If the end is larger than the current time.
// Long-term hourly stats land around :20. Before that, Core can already
// stitch the open hour from short-term rows once the first 5-minute
// bucket exists (recorder compiles at :05:10), so prefer an early
// refresh after midnight / early-hour fetches, then resume :20.
const nextFetch = new Date();
if (nextFetch.getMinutes() >= 20) {
nextFetch.setHours(nextFetch.getHours() + 1);
const firstShortTermRefresh = new Date(nextFetch);
firstShortTermRefresh.setMinutes(5, 15, 0);
if (nextFetch < firstShortTermRefresh) {
nextFetch.setTime(firstShortTermRefresh.getTime());
} else {
if (nextFetch.getMinutes() >= 20) {
nextFetch.setHours(nextFetch.getHours() + 1);
}
nextFetch.setMinutes(20, 0, 0);
}
nextFetch.setMinutes(20, 0, 0);
collection._refreshTimeout = window.setTimeout(
() => collection.refresh(),
@@ -858,104 +864,30 @@ export const getEnergyDefaultPeriodStorageKey = (
return `energy-default-period-${key}`;
};
// When today's first hourly statistic becomes available (01:00 in the
// configured timezone). Rolling the statistics view over at midnight would
// show an empty graph.
export const getEnergyFirstStatisticAt = (
now: Date,
locale: HomeAssistant["locale"],
config: HomeAssistant["config"]
): Date => addHours(calcDate(now, startOfDay, locale, config), 1);
// The statistics Energy view shows yesterday until 01:00 so the graph is not
// empty. The real-time "Now" view never does this — it has live data.
export const shouldFallbackEnergyPeriodToYesterday = (
midnightRollover: boolean,
now: Date,
locale: HomeAssistant["locale"],
config: HomeAssistant["config"]
): boolean =>
!midnightRollover &&
now.getTime() < getEnergyFirstStatisticAt(now, locale, config).getTime();
// Live day used while a rollover timer is scheduled (today, or the hour-0
// yesterday fallback). Custom dates do not use this. If the user already
// picked today during hour 0, keep today rather than snapping back.
// Live day used while a rollover timer is scheduled. Custom dates do not use
// this. Always today — Core fills the unfinished current hour from short-term
// stats in statistics_during_period, so hour/day charts are not empty after
// the first short-term bucket.
export const getEnergyLiveDayPeriod = (
midnightRollover: boolean,
now: Date,
locale: HomeAssistant["locale"],
config: HomeAssistant["config"],
currentStart: Date
): { start: Date; end: Date } => {
const todayStart = calcDate(now, startOfDay, locale, config);
if (
shouldFallbackEnergyPeriodToYesterday(
midnightRollover,
now,
locale,
config
) &&
currentStart.getTime() !== todayStart.getTime()
) {
const yesterday = calcDate(now, addDays, locale, config, -1);
return {
start: calcDate(yesterday, startOfDay, locale, config),
end: calcDate(yesterday, endOfDay, locale, config),
};
}
return {
start: todayStart,
end: calcDate(now, endOfDay, locale, config),
};
};
config: HomeAssistant["config"]
): { start: Date; end: Date } => ({
start: calcDate(now, startOfDay, locale, config),
end: calcDate(now, endOfDay, locale, config),
});
// When does the collection's day period need to roll over to the next day?
// With `midnightRollover` (the real-time "Now" view) it rolls over right at
// midnight. Otherwise it waits an hour, until the new day's first hourly
// statistic exists — rolling over at midnight would show an empty graph.
// Pass `periodStart` when the collection is on a specific day: hour-0
// yesterday (and any older stale live day) must wake at today 01:00, not
// tomorrow 01:00. Keep tomorrow 01:00 only when the user already picked today.
// Next midnight in the configured zone, not browser-local addDays, so a DST
// transition cannot skip a server-tz day.
export const getNextEnergyPeriodStart = (
midnightRollover: boolean,
now: Date,
locale: HomeAssistant["locale"],
config: HomeAssistant["config"],
periodStart?: Date
): Date => {
const todayStart = calcDate(now, startOfDay, locale, config);
if (
periodStart &&
shouldFallbackEnergyPeriodToYesterday(
midnightRollover,
now,
locale,
config
) &&
periodStart.getTime() !== todayStart.getTime()
) {
return getEnergyFirstStatisticAt(now, locale, config);
}
// Next midnight in the configured zone, not browser-local addDays, so a
// DST transition cannot skip a server-tz day.
const nextMidnight = addMilliseconds(
calcDate(now, endOfDay, locale, config),
1
);
return midnightRollover ? nextMidnight : addHours(nextMidnight, 1);
};
config: HomeAssistant["config"]
): Date => addMilliseconds(calcDate(now, endOfDay, locale, config), 1);
export const getEnergyDataCollection = (
hass: HomeAssistant,
options: {
prefs?: EnergyPreferences;
key?: string;
// The real-time "Now" view opts in to rolling its day period over at
// midnight rather than an hour later (it shows live data, so it always
// tracks today and never falls back to yesterday in the first hour).
midnightRollover?: boolean;
} = {}
options: { prefs?: EnergyPreferences; key?: string } = {}
): EnergyCollection => {
const [key, collectionKey] = convertCollectionKeyToConnection(
hass,
@@ -965,8 +897,6 @@ export const getEnergyDataCollection = (
return (hass.connection as any)[key];
}
const midnightRollover = options.midnightRollover ?? false;
energyCollectionKeys.add(collectionKey);
const collection = getCollection<EnergyData>(
@@ -1005,18 +935,12 @@ export const getEnergyDataCollection = (
collection._active = 0;
collection.prefs = options.prefs;
// True while the collection is tracking the rolling "today" (or hour-0
// yesterday) day. Cleared when the user picks a custom range.
// True while the collection is tracking the rolling "today" day. Cleared
// when the user picks a custom range.
let followLiveDay = false;
const applyLiveDayPeriod = (now: Date): boolean => {
const live = getEnergyLiveDayPeriod(
midnightRollover,
now,
hass.locale,
hass.config,
collection.start
);
const live = getEnergyLiveDayPeriod(now, hass.locale, hass.config);
const changed =
collection.start.getTime() !== live.start.getTime() ||
collection.end?.getTime() !== live.end.getTime();
@@ -1045,11 +969,9 @@ export const getEnergyDataCollection = (
Math.max(
0,
getNextEnergyPeriodStart(
midnightRollover,
scheduledAt,
hass.locale,
hass.config,
collection.start
hass.config
).getTime() - scheduledAt.getTime()
)
);
@@ -1093,26 +1015,12 @@ export const getEnergyDataCollection = (
};
};
// Set start to start of today if we have data for today, otherwise yesterday.
// The real-time "Now" view always tracks today; it shows live data even
// before today's first statistic exists, so it never falls back to yesterday.
const now = new Date();
const preferredPeriod =
(localStorage.getItem(
getEnergyDefaultPeriodStorageKey(hass, options.key)
) as DateRange) || "today";
const period =
preferredPeriod === "today" &&
shouldFallbackEnergyPeriodToYesterday(
midnightRollover,
now,
hass.locale,
hass.config
)
? "yesterday"
: preferredPeriod;
const [start, end] = calcDateRange(hass.locale, hass.config, period);
const [start, end] = calcDateRange(hass.locale, hass.config, preferredPeriod);
collection.start = calcDate(start, startOfDay, hass.locale, hass.config);
collection.end = calcDate(end, endOfDay, hass.locale, hass.config);
followLiveDay = preferredPeriod === "today";
@@ -158,9 +158,6 @@ async function fetchEnergyPrefs(
): Promise<EnergyPreferences> {
const collection = getEnergyDataCollection(hass, {
key: defaultCollection || DEFAULT_ENERGY_COLLECTION_KEY,
// When landing directly on the "Now" view this warms its real-time
// collection, so it must be created with midnight rollover too.
midnightRollover: defaultCollection === DEFAULT_POWER_COLLECTION_KEY,
});
return await new Promise<EnergyPreferences>((resolve) => {
@@ -34,8 +34,6 @@ export class PowerViewStrategy extends ReactiveElement {
const energyCollection = getEnergyDataCollection(hass, {
key: collectionKey,
// The "Now" view is real-time; roll its day period over at midnight.
midnightRollover: true,
});
if (!energyCollection.prefs) {
await energyCollection.refresh();
+16 -33
View File
@@ -1,10 +1,4 @@
import {
addDays,
addHours,
addMilliseconds,
endOfDay,
startOfDay,
} from "date-fns";
import { addDays, addMilliseconds, endOfDay, startOfDay } from "date-fns";
import type { HassConfig } from "home-assistant-js-websocket";
import { afterAll, assert, beforeAll, describe, it } from "vitest";
@@ -18,7 +12,6 @@ import {
TimeZone,
} from "../../src/data/translation";
import {
getEnergyFirstStatisticAt,
getEnergyLiveDayPeriod,
getNextEnergyPeriodStart,
} from "../../src/data/energy";
@@ -55,62 +48,52 @@ describe("energy period DST (Europe/Berlin local TZ)", () => {
);
});
it("schedules tomorrow 01:00 in the server zone, not via browser-local addDays", () => {
it("schedules tomorrow midnight in the server zone, not via browser-local addDays", () => {
// 23:30 JST on 24 Oct 2026. Europe/Berlin falls back on 25 Oct; local
// addDays(now, 1) then startOfDay in Tokyo skips to 26 Oct 01:00 JST.
// addDays(now, 1) then startOfDay in Tokyo skips to 26 Oct midnight JST.
const now = new Date("2026-10-24T14:30:00.000Z");
// Compare to the tz-internal formula rather than a hardcoded instant:
// the formula is the production invariant, and a pinned ISO string would
// not explain why UTC CI cannot catch a raw addDays(now, 1) regression.
const tzInternal = addHours(
addMilliseconds(calcDate(now, endOfDay, locale, tokyoConfig), 1),
const tzInternal = addMilliseconds(
calcDate(now, endOfDay, locale, tokyoConfig),
1
);
const browserLocalAddDays = getEnergyFirstStatisticAt(
const browserLocalAddDays = calcDate(
addDays(now, 1),
startOfDay,
locale,
tokyoConfig
);
const actual = getNextEnergyPeriodStart(false, now, locale, tokyoConfig);
const actual = getNextEnergyPeriodStart(now, locale, tokyoConfig);
assert.equal(actual.getTime(), tzInternal.getTime());
assert.notEqual(actual.getTime(), browserLocalAddDays.getTime());
assert.equal(
actual.getTime(),
new Date("2026-10-24T16:00:00.000Z").getTime()
new Date("2026-10-24T15:00:00.000Z").getTime()
);
assert.equal(
browserLocalAddDays.getTime(),
new Date("2026-10-25T16:00:00.000Z").getTime()
new Date("2026-10-25T15:00:00.000Z").getTime()
);
});
it("resolves yesterday in the server zone, not via browser-local addDays", () => {
// 00:30 JST on 26 Oct 2026. Browser-local addDays can land two days back.
it("resolves today in the server zone, not via browser-local addDays", () => {
// 00:30 JST on 26 Oct 2026. Browser-local yesterday can land two days back.
const now = new Date("2026-10-25T15:30:00.000Z");
const tzInternal = calcDate(
calcDate(now, addDays, locale, tokyoConfig, -1),
startOfDay,
locale,
tokyoConfig
);
const browserLocalAddDays = calcDate(
const tzInternal = calcDate(now, startOfDay, locale, tokyoConfig);
const browserLocalYesterday = calcDate(
addDays(now, -1),
startOfDay,
locale,
tokyoConfig
);
const live = getEnergyLiveDayPeriod(
false,
now,
locale,
tokyoConfig,
new Date(0)
);
const live = getEnergyLiveDayPeriod(now, locale, tokyoConfig);
assert.equal(live.start.getTime(), tzInternal.getTime());
assert.notEqual(live.start.getTime(), browserLocalAddDays.getTime());
assert.notEqual(live.start.getTime(), browserLocalYesterday.getTime());
});
});
+38 -186
View File
@@ -20,9 +20,7 @@ import {
formatPowerShort,
getNextEnergyPeriodStart,
getEnergyDefaultPeriodStorageKey,
getEnergyFirstStatisticAt,
getEnergyLiveDayPeriod,
shouldFallbackEnergyPeriodToYesterday,
getEnergyDataCollection,
EMPTY_PREFERENCES,
} from "../../src/data/energy";
@@ -905,207 +903,64 @@ describe("getNextEnergyPeriodStart", () => {
energyPeriodConfig
).getTime() === date.getTime();
it("rolls the real-time view over at midnight, statistics an hour later", () => {
it("schedules the next midnight in the configured zone", () => {
const now = new Date("2026-06-19T15:30:00-04:00");
const realTime = getNextEnergyPeriodStart(
true,
now,
energyPeriodLocale,
energyPeriodConfig
);
const statistics = getNextEnergyPeriodStart(
false,
const next = getNextEnergyPeriodStart(
now,
energyPeriodLocale,
energyPeriodConfig
);
// Real-time rolls over exactly at the next midnight.
assert.isTrue(isMidnight(realTime));
assert.isTrue(isMidnight(next));
assert.equal(
realTime.getTime(),
next.getTime(),
new Date("2026-06-20T00:00:00-04:00").getTime()
);
// Statistics roll over an hour after midnight, on the same day boundary.
assert.equal(statistics.getTime() - realTime.getTime(), 60 * 60 * 1000);
assert.equal(
calcDate(
statistics,
startOfDay,
energyPeriodLocale,
energyPeriodConfig
).getTime(),
realTime.getTime()
);
});
it("advances the real-time view to the next midnight when called after midnight", () => {
it("advances to the following midnight when called after midnight", () => {
const now = new Date("2026-06-20T00:30:00-04:00");
const realTime = getNextEnergyPeriodStart(
true,
const next = getNextEnergyPeriodStart(
now,
energyPeriodLocale,
energyPeriodConfig
);
assert.isTrue(isMidnight(realTime));
assert.isTrue(isMidnight(next));
// Next midnight is June 21, not the already-passed June 20 midnight.
assert.equal(
realTime.getTime(),
next.getTime(),
new Date("2026-06-21T00:00:00-04:00").getTime()
);
});
it("wakes a non-today live day at today 01:00 during hour 0", () => {
const now = new Date("2026-06-20T00:30:00-04:00");
const todayOne = new Date("2026-06-20T01:00:00-04:00").getTime();
for (const offset of [-1, -2]) {
assert.equal(
getNextEnergyPeriodStart(
false,
now,
energyPeriodLocale,
energyPeriodConfig,
energyPeriodDay(now, offset).start
).getTime(),
todayOne
);
}
});
it("keeps tomorrow 01:00 when statistics is already on today during hour 0", () => {
const now = new Date("2026-06-20T00:30:00-04:00");
assert.equal(
getNextEnergyPeriodStart(
false,
now,
energyPeriodLocale,
energyPeriodConfig,
energyPeriodDay(now).start
).getTime(),
new Date("2026-06-21T01:00:00-04:00").getTime()
);
});
});
describe("shouldFallbackEnergyPeriodToYesterday", () => {
it("is true for the statistics view before 01:00", () => {
const now = new Date("2026-06-20T00:30:00-04:00");
assert.isTrue(
shouldFallbackEnergyPeriodToYesterday(
false,
now,
energyPeriodLocale,
energyPeriodConfig
)
);
assert.equal(
getEnergyFirstStatisticAt(
now,
energyPeriodLocale,
energyPeriodConfig
).getTime(),
new Date("2026-06-20T01:00:00-04:00").getTime()
);
});
it("is false at 01:00 and for the real-time view", () => {
const atOne = new Date("2026-06-20T01:00:00-04:00");
const beforeOne = new Date("2026-06-20T00:30:00-04:00");
assert.isFalse(
shouldFallbackEnergyPeriodToYesterday(
false,
atOne,
energyPeriodLocale,
energyPeriodConfig
)
);
assert.isFalse(
shouldFallbackEnergyPeriodToYesterday(
true,
beforeOne,
energyPeriodLocale,
energyPeriodConfig
)
);
});
});
describe("getEnergyLiveDayPeriod", () => {
it("keeps yesterday during hour 0 when that is the current period", () => {
it("returns today during hour 0", () => {
const now = new Date("2026-06-20T00:30:00-04:00");
const { start, end } = energyPeriodDay(now, -1);
const live = getEnergyLiveDayPeriod(
false,
now,
energyPeriodLocale,
energyPeriodConfig,
start
);
assert.equal(live.start.getTime(), start.getTime());
assert.equal(live.end.getTime(), end.getTime());
});
it("keeps today during hour 0 when the user already picked today", () => {
const now = new Date("2026-06-20T00:30:00-04:00");
const { start, end } = energyPeriodDay(now);
const live = getEnergyLiveDayPeriod(
false,
now,
energyPeriodLocale,
energyPeriodConfig,
start
);
assert.equal(live.start.getTime(), start.getTime());
assert.equal(live.end.getTime(), end.getTime());
});
it("advances a stale yesterday to today after 01:00", () => {
const now = new Date("2026-06-20T10:00:00-04:00");
const live = getEnergyLiveDayPeriod(
false,
now,
energyPeriodLocale,
energyPeriodConfig,
energyPeriodDay(now, -1).start
energyPeriodConfig
);
const expected = energyPeriodDay(now);
assert.equal(live.start.getTime(), expected.start.getTime());
assert.equal(live.end.getTime(), expected.end.getTime());
});
it("advances a two-day-old live day to today", () => {
it("returns today after 01:00", () => {
const now = new Date("2026-06-20T10:00:00-04:00");
const live = getEnergyLiveDayPeriod(
false,
now,
energyPeriodLocale,
energyPeriodConfig,
energyPeriodDay(now, -2).start
energyPeriodConfig
);
const expected = energyPeriodDay(now);
assert.equal(live.start.getTime(), expected.start.getTime());
assert.equal(live.end.getTime(), expected.end.getTime());
});
it("falls back to yesterday for a stale live day during hour 0", () => {
const now = new Date("2026-06-20T00:30:00-04:00");
const live = getEnergyLiveDayPeriod(
false,
now,
energyPeriodLocale,
energyPeriodConfig,
energyPeriodDay(now, -2).start
);
const expected = energyPeriodDay(now, -1);
assert.equal(live.start.getTime(), expected.start.getTime());
assert.equal(live.end.getTime(), expected.end.getTime());
});
});
describe("getEnergyDataCollection live day", () => {
@@ -1114,11 +969,7 @@ describe("getEnergyDataCollection live day", () => {
vi.useRealTimers();
});
const createCollection = (
key: string,
preset?: string,
midnightRollover = false
) => {
const createCollection = (key: string, preset?: string) => {
const hass = createMockHass();
hass.locale = energyPeriodLocale;
hass.config = { ...hass.config, time_zone: "America/New_York" };
@@ -1143,7 +994,6 @@ describe("getEnergyDataCollection live day", () => {
collection: getEnergyDataCollection(hass, {
key,
prefs: EMPTY_PREFERENCES,
midnightRollover,
}),
callWS,
};
@@ -1152,10 +1002,18 @@ describe("getEnergyDataCollection live day", () => {
const energyInfoFetches = (callWS: ReturnType<typeof vi.fn>) =>
callWS.mock.calls.filter((call) => call[0].type === "energy/info");
it("advances hour-0 yesterday to today at 01:00 and fetches the new day", async () => {
it("starts today during hour 0 and rolls over at midnight", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-20T00:30:00-04:00"));
assert.equal(
createCollection("energy_hour0").collection.start.getTime(),
energyPeriodDay(new Date()).start.getTime()
);
// Create near end of day so a 30-minute advance hits midnight without
// also firing many hourly :20 refreshes.
vi.setSystemTime(new Date("2026-06-20T23:30:00-04:00"));
const { collection, callWS } = createCollection("energy_timer");
const refresh = vi.spyOn(collection, "refresh");
const unsub = collection.subscribe(() => undefined);
@@ -1165,7 +1023,7 @@ describe("getEnergyDataCollection live day", () => {
assert.equal(
collection.start.getTime(),
energyPeriodDay(new Date(), -1).start.getTime()
energyPeriodDay(new Date()).start.getTime()
);
await vi.advanceTimersByTimeAsync(30 * 60 * 1000);
@@ -1175,7 +1033,21 @@ describe("getEnergyDataCollection live day", () => {
energyPeriodDay(new Date()).start.getTime()
);
// Cards render EnergyData from the websocket store, not collection.start.
// The 01:00 callback must refresh() so getEnergyData runs for today.
// The midnight callback must refresh() so getEnergyData runs for the new day.
assert.equal(refresh.mock.calls.length, 1);
assert.equal(energyInfoFetches(callWS).length, 1);
// Midnight refresh is before the first short-term bucket. Schedule a
// follow-up after recorder's :05:10 compile, then resume :20 cadence.
refresh.mockClear();
callWS.mockClear();
await vi.advanceTimersByTimeAsync(5 * 60 * 1000 + 15 * 1000);
assert.equal(refresh.mock.calls.length, 1);
assert.equal(energyInfoFetches(callWS).length, 1);
refresh.mockClear();
callWS.mockClear();
await vi.advanceTimersByTimeAsync(14 * 60 * 1000 + 45 * 1000);
assert.equal(refresh.mock.calls.length, 1);
assert.equal(energyInfoFetches(callWS).length, 1);
@@ -1284,26 +1156,6 @@ describe("getEnergyDataCollection live day", () => {
unsub();
});
it("does not roll a remembered week preset over to today with midnightRollover", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-18T15:00:00-04:00"));
const { collection } = createCollection(
"energy_week_stored_now",
"this_week",
true
);
const weekStart = collection.start.getTime();
const unsub = collection.subscribe(() => undefined);
vi.setSystemTime(new Date("2026-06-20T10:00:00-04:00"));
vi.advanceTimersByTime(48 * 60 * 60 * 1000);
assert.equal(collection.start.getTime(), weekStart);
unsub();
});
});
describe("getEnergyDefaultPeriodStorageKey", () => {