mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-21 22:28:40 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
043f42617a | ||
|
|
b2130a0cdc | ||
|
|
9481f7c948 | ||
|
|
c70ffc9060 |
+171
-54
@@ -21,7 +21,6 @@ import {
|
||||
} from "../common/datetime/calc_date";
|
||||
import type { DateRange } from "../common/datetime/calc_date_range";
|
||||
import { calcDateRange } from "../common/datetime/calc_date_range";
|
||||
import { formatTime24h } from "../common/datetime/format_time";
|
||||
import { DEFAULT_ENTITY_NAME } from "../common/entity/compute_entity_name_display";
|
||||
import { formatNumber } from "../common/number/format_number";
|
||||
import { normalizeValueBySIPrefix } from "../common/number/normalize-by-si-prefix";
|
||||
@@ -799,8 +798,8 @@ const clearEnergyCollectionPreferences = (hass: HomeAssistant) => {
|
||||
};
|
||||
|
||||
const scheduleHourlyRefresh = (collection: EnergyCollection) => {
|
||||
if (collection._refreshTimeout) {
|
||||
clearTimeout(collection._refreshTimeout);
|
||||
if (collection._refreshTimeout !== undefined) {
|
||||
window.clearTimeout(collection._refreshTimeout);
|
||||
}
|
||||
|
||||
if (collection._active && (!collection.end || collection.end > new Date())) {
|
||||
@@ -859,18 +858,92 @@ export const getEnergyDefaultPeriodStorageKey = (
|
||||
return `energy-default-period-${key}`;
|
||||
};
|
||||
|
||||
// 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.
|
||||
export const getNextEnergyPeriodStart = (
|
||||
// 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.
|
||||
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),
|
||||
};
|
||||
};
|
||||
|
||||
// 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.
|
||||
export const getNextEnergyPeriodStart = (
|
||||
midnightRollover: boolean,
|
||||
now: Date,
|
||||
locale: HomeAssistant["locale"],
|
||||
config: HomeAssistant["config"],
|
||||
periodStart?: Date
|
||||
): Date => {
|
||||
const dayEnd = calcDate(now, endOfDay, locale, config);
|
||||
return midnightRollover ? addMilliseconds(dayEnd, 1) : addHours(dayEnd, 1);
|
||||
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);
|
||||
};
|
||||
|
||||
export const getEnergyDataCollection = (
|
||||
@@ -929,12 +1002,80 @@ export const getEnergyDataCollection = (
|
||||
}
|
||||
) as EnergyCollection;
|
||||
|
||||
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.
|
||||
let followLiveDay = false;
|
||||
|
||||
const applyLiveDayPeriod = (now: Date): boolean => {
|
||||
const live = getEnergyLiveDayPeriod(
|
||||
midnightRollover,
|
||||
now,
|
||||
hass.locale,
|
||||
hass.config,
|
||||
collection.start
|
||||
);
|
||||
const changed =
|
||||
collection.start.getTime() !== live.start.getTime() ||
|
||||
collection.end?.getTime() !== live.end.getTime();
|
||||
collection.start = live.start;
|
||||
collection.end = live.end;
|
||||
return changed;
|
||||
};
|
||||
|
||||
const clearUpdatePeriodTimeout = () => {
|
||||
if (collection._updatePeriodTimeout !== undefined) {
|
||||
window.clearTimeout(collection._updatePeriodTimeout);
|
||||
collection._updatePeriodTimeout = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleUpdatePeriod = () => {
|
||||
clearUpdatePeriodTimeout();
|
||||
const scheduledAt = new Date();
|
||||
collection._updatePeriodTimeout = window.setTimeout(
|
||||
() => {
|
||||
if (applyLiveDayPeriod(new Date())) {
|
||||
collection.refresh();
|
||||
}
|
||||
scheduleUpdatePeriod();
|
||||
},
|
||||
Math.max(
|
||||
0,
|
||||
getNextEnergyPeriodStart(
|
||||
midnightRollover,
|
||||
scheduledAt,
|
||||
hass.locale,
|
||||
hass.config,
|
||||
collection.start
|
||||
).getTime() - scheduledAt.getTime()
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const origSubscribe = collection.subscribe;
|
||||
|
||||
collection.subscribe = (subscriber: (data: EnergyData) => void) => {
|
||||
// Catch up before origSubscribe so the first fetch uses the live day.
|
||||
// Refresh only when state already exists: cold subscribe fetches via
|
||||
// origSubscribe; a re-subscribe inside the 5s unsub grace does not.
|
||||
const needsRefresh =
|
||||
followLiveDay &&
|
||||
applyLiveDayPeriod(new Date()) &&
|
||||
collection.state !== undefined;
|
||||
if (followLiveDay) {
|
||||
scheduleUpdatePeriod();
|
||||
}
|
||||
|
||||
const unsub = origSubscribe(subscriber);
|
||||
collection._active++;
|
||||
|
||||
if (needsRefresh) {
|
||||
collection.refresh();
|
||||
}
|
||||
|
||||
if (collection._refreshTimeout === undefined) {
|
||||
scheduleHourlyRefresh(collection);
|
||||
}
|
||||
@@ -942,79 +1083,55 @@ export const getEnergyDataCollection = (
|
||||
return () => {
|
||||
collection._active--;
|
||||
if (collection._active < 1) {
|
||||
clearTimeout(collection._refreshTimeout);
|
||||
collection._refreshTimeout = undefined;
|
||||
if (collection._refreshTimeout !== undefined) {
|
||||
window.clearTimeout(collection._refreshTimeout);
|
||||
collection._refreshTimeout = undefined;
|
||||
}
|
||||
clearUpdatePeriodTimeout();
|
||||
}
|
||||
unsub();
|
||||
};
|
||||
};
|
||||
|
||||
collection._active = 0;
|
||||
collection.prefs = options.prefs;
|
||||
|
||||
const now = new Date();
|
||||
const hour = formatTime24h(now, hass.locale, hass.config).split(":")[0];
|
||||
// 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" && hour === "0" && !midnightRollover
|
||||
preferredPeriod === "today" &&
|
||||
shouldFallbackEnergyPeriodToYesterday(
|
||||
midnightRollover,
|
||||
now,
|
||||
hass.locale,
|
||||
hass.config
|
||||
)
|
||||
? "yesterday"
|
||||
: preferredPeriod;
|
||||
|
||||
const [start, end] = calcDateRange(hass.locale, hass.config, period);
|
||||
collection.start = calcDate(start, startOfDay, hass.locale, hass.config);
|
||||
collection.end = calcDate(end, endOfDay, hass.locale, hass.config);
|
||||
|
||||
const scheduleUpdatePeriod = () => {
|
||||
collection._updatePeriodTimeout = window.setTimeout(
|
||||
() => {
|
||||
collection.start = calcDate(
|
||||
new Date(),
|
||||
startOfDay,
|
||||
hass.locale,
|
||||
hass.config
|
||||
);
|
||||
collection.end = calcDate(
|
||||
new Date(),
|
||||
endOfDay,
|
||||
hass.locale,
|
||||
hass.config
|
||||
);
|
||||
collection.refresh();
|
||||
scheduleUpdatePeriod();
|
||||
},
|
||||
getNextEnergyPeriodStart(
|
||||
midnightRollover,
|
||||
new Date(),
|
||||
hass.locale,
|
||||
hass.config
|
||||
).getTime() - Date.now()
|
||||
);
|
||||
};
|
||||
scheduleUpdatePeriod();
|
||||
followLiveDay = preferredPeriod === "today";
|
||||
|
||||
collection.isActive = () => !!collection._active;
|
||||
collection.clearPrefs = () => {
|
||||
collection.prefs = undefined;
|
||||
};
|
||||
collection.setPeriod = (newStart: Date, newEnd?: Date) => {
|
||||
if (collection._updatePeriodTimeout) {
|
||||
clearTimeout(collection._updatePeriodTimeout);
|
||||
collection._updatePeriodTimeout = undefined;
|
||||
}
|
||||
clearUpdatePeriodTimeout();
|
||||
collection.start = newStart;
|
||||
collection.end = newEnd;
|
||||
if (
|
||||
const periodNow = new Date();
|
||||
followLiveDay =
|
||||
collection.start.getTime() ===
|
||||
calcDate(new Date(), startOfDay, hass.locale, hass.config).getTime() &&
|
||||
calcDate(periodNow, startOfDay, hass.locale, hass.config).getTime() &&
|
||||
collection.end?.getTime() ===
|
||||
calcDate(new Date(), endOfDay, hass.locale, hass.config).getTime()
|
||||
) {
|
||||
calcDate(periodNow, endOfDay, hass.locale, hass.config).getTime();
|
||||
if (followLiveDay) {
|
||||
scheduleUpdatePeriod();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
addDays,
|
||||
addHours,
|
||||
addMilliseconds,
|
||||
endOfDay,
|
||||
startOfDay,
|
||||
} from "date-fns";
|
||||
import type { HassConfig } from "home-assistant-js-websocket";
|
||||
import { afterAll, assert, beforeAll, describe, it } from "vitest";
|
||||
|
||||
import { calcDate } from "../../src/common/datetime/calc_date";
|
||||
import {
|
||||
type FrontendLocaleData,
|
||||
NumberFormat,
|
||||
TimeFormat,
|
||||
FirstWeekday,
|
||||
DateFormat,
|
||||
TimeZone,
|
||||
} from "../../src/data/translation";
|
||||
import {
|
||||
getEnergyFirstStatisticAt,
|
||||
getEnergyLiveDayPeriod,
|
||||
getNextEnergyPeriodStart,
|
||||
} from "../../src/data/energy";
|
||||
|
||||
const locale: FrontendLocaleData = {
|
||||
language: "en",
|
||||
number_format: NumberFormat.language,
|
||||
time_format: TimeFormat.language,
|
||||
date_format: DateFormat.language,
|
||||
time_zone: TimeZone.server,
|
||||
first_weekday: FirstWeekday.language,
|
||||
};
|
||||
const tokyoConfig = { time_zone: "Asia/Tokyo" } as HassConfig;
|
||||
|
||||
// Dedicated file so Europe/Berlin can be pinned without leaking into other
|
||||
// suites. Vitest's default TZ is Etc/UTC, where browser-local addDays and
|
||||
// server-zone math often agree — so UTC CI would miss this regression.
|
||||
describe("energy period DST (Europe/Berlin local TZ)", () => {
|
||||
const originalTz = process.env.TZ;
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.TZ = "Europe/Berlin";
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env.TZ = originalTz;
|
||||
});
|
||||
|
||||
it("uses a DST-fallback browser zone for this file", () => {
|
||||
// 24 Oct 2026 is still CEST. If TZ pinning failed, offset is 0 (UTC).
|
||||
assert.equal(
|
||||
new Date("2026-10-24T14:30:00.000Z").getTimezoneOffset(),
|
||||
-120
|
||||
);
|
||||
});
|
||||
|
||||
it("schedules tomorrow 01:00 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.
|
||||
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),
|
||||
1
|
||||
);
|
||||
const browserLocalAddDays = getEnergyFirstStatisticAt(
|
||||
addDays(now, 1),
|
||||
locale,
|
||||
tokyoConfig
|
||||
);
|
||||
const actual = getNextEnergyPeriodStart(false, 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()
|
||||
);
|
||||
assert.equal(
|
||||
browserLocalAddDays.getTime(),
|
||||
new Date("2026-10-25T16: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.
|
||||
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(
|
||||
addDays(now, -1),
|
||||
startOfDay,
|
||||
locale,
|
||||
tokyoConfig
|
||||
);
|
||||
const live = getEnergyLiveDayPeriod(
|
||||
false,
|
||||
now,
|
||||
locale,
|
||||
tokyoConfig,
|
||||
new Date(0)
|
||||
);
|
||||
|
||||
assert.equal(live.start.getTime(), tzInternal.getTime());
|
||||
assert.notEqual(live.start.getTime(), browserLocalAddDays.getTime());
|
||||
});
|
||||
});
|
||||
+408
-19
@@ -1,6 +1,6 @@
|
||||
import { startOfDay } from "date-fns";
|
||||
import { addDays, endOfDay, startOfDay } from "date-fns";
|
||||
import type { HassConfig } from "home-assistant-js-websocket";
|
||||
import { assert, describe, it } from "vitest";
|
||||
import { afterEach, assert, describe, it, vi } from "vitest";
|
||||
|
||||
import { calcDate } from "../../src/common/datetime/calc_date";
|
||||
import {
|
||||
@@ -20,6 +20,11 @@ import {
|
||||
formatPowerShort,
|
||||
getNextEnergyPeriodStart,
|
||||
getEnergyDefaultPeriodStorageKey,
|
||||
getEnergyFirstStatisticAt,
|
||||
getEnergyLiveDayPeriod,
|
||||
shouldFallbackEnergyPeriodToYesterday,
|
||||
getEnergyDataCollection,
|
||||
EMPTY_PREFERENCES,
|
||||
} from "../../src/data/energy";
|
||||
import type { DeviceRegistryEntry } from "../../src/data/device/device_registry";
|
||||
import type { EntityRegistryDisplayEntry } from "../../src/data/entity/entity_registry";
|
||||
@@ -866,27 +871,55 @@ describe("Self-consumed solar gauge tests", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNextEnergyPeriodStart", () => {
|
||||
const locale: FrontendLocaleData = {
|
||||
language: "en",
|
||||
number_format: NumberFormat.language,
|
||||
time_format: TimeFormat.language,
|
||||
date_format: DateFormat.language,
|
||||
time_zone: TimeZone.server,
|
||||
first_weekday: FirstWeekday.language,
|
||||
// Pin the time zone (via TimeZone.server) so energy period tests do not
|
||||
// depend on the machine's local zone.
|
||||
const energyPeriodLocale: FrontendLocaleData = {
|
||||
language: "en",
|
||||
number_format: NumberFormat.language,
|
||||
time_format: TimeFormat.language,
|
||||
date_format: DateFormat.language,
|
||||
time_zone: TimeZone.server,
|
||||
first_weekday: FirstWeekday.language,
|
||||
};
|
||||
const energyPeriodConfig = { time_zone: "America/New_York" } as HassConfig;
|
||||
const energyPeriodDay = (now: Date, offset = 0) => {
|
||||
const day = calcDate(
|
||||
now,
|
||||
addDays,
|
||||
energyPeriodLocale,
|
||||
energyPeriodConfig,
|
||||
offset
|
||||
);
|
||||
return {
|
||||
start: calcDate(day, startOfDay, energyPeriodLocale, energyPeriodConfig),
|
||||
end: calcDate(day, endOfDay, energyPeriodLocale, energyPeriodConfig),
|
||||
};
|
||||
// Pin the time zone (via TimeZone.server) so the test does not depend on the
|
||||
// machine's local zone.
|
||||
const config = { time_zone: "America/New_York" } as HassConfig;
|
||||
};
|
||||
|
||||
describe("getNextEnergyPeriodStart", () => {
|
||||
const isMidnight = (date: Date) =>
|
||||
calcDate(date, startOfDay, locale, config).getTime() === date.getTime();
|
||||
calcDate(
|
||||
date,
|
||||
startOfDay,
|
||||
energyPeriodLocale,
|
||||
energyPeriodConfig
|
||||
).getTime() === date.getTime();
|
||||
|
||||
it("rolls the real-time view over at midnight, statistics an hour later", () => {
|
||||
const now = new Date("2026-06-19T15:30:00-04:00");
|
||||
|
||||
const realTime = getNextEnergyPeriodStart(true, now, locale, config);
|
||||
const statistics = getNextEnergyPeriodStart(false, now, locale, config);
|
||||
const realTime = getNextEnergyPeriodStart(
|
||||
true,
|
||||
now,
|
||||
energyPeriodLocale,
|
||||
energyPeriodConfig
|
||||
);
|
||||
const statistics = getNextEnergyPeriodStart(
|
||||
false,
|
||||
now,
|
||||
energyPeriodLocale,
|
||||
energyPeriodConfig
|
||||
);
|
||||
|
||||
// Real-time rolls over exactly at the next midnight.
|
||||
assert.isTrue(isMidnight(realTime));
|
||||
@@ -896,9 +929,14 @@ describe("getNextEnergyPeriodStart", () => {
|
||||
);
|
||||
|
||||
// Statistics roll over an hour after midnight, on the same day boundary.
|
||||
assert.equal(statistics.getTime() - realTime.getTime(), 60 * 60 * 1000 - 1);
|
||||
assert.equal(statistics.getTime() - realTime.getTime(), 60 * 60 * 1000);
|
||||
assert.equal(
|
||||
calcDate(statistics, startOfDay, locale, config).getTime(),
|
||||
calcDate(
|
||||
statistics,
|
||||
startOfDay,
|
||||
energyPeriodLocale,
|
||||
energyPeriodConfig
|
||||
).getTime(),
|
||||
realTime.getTime()
|
||||
);
|
||||
});
|
||||
@@ -906,7 +944,12 @@ describe("getNextEnergyPeriodStart", () => {
|
||||
it("advances the real-time view to the next midnight when called after midnight", () => {
|
||||
const now = new Date("2026-06-20T00:30:00-04:00");
|
||||
|
||||
const realTime = getNextEnergyPeriodStart(true, now, locale, config);
|
||||
const realTime = getNextEnergyPeriodStart(
|
||||
true,
|
||||
now,
|
||||
energyPeriodLocale,
|
||||
energyPeriodConfig
|
||||
);
|
||||
|
||||
assert.isTrue(isMidnight(realTime));
|
||||
// Next midnight is June 21, not the already-passed June 20 midnight.
|
||||
@@ -915,6 +958,352 @@ describe("getNextEnergyPeriodStart", () => {
|
||||
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", () => {
|
||||
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
|
||||
);
|
||||
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", () => {
|
||||
const now = new Date("2026-06-20T10:00:00-04:00");
|
||||
const live = getEnergyLiveDayPeriod(
|
||||
false,
|
||||
now,
|
||||
energyPeriodLocale,
|
||||
energyPeriodConfig,
|
||||
energyPeriodDay(now, -2).start
|
||||
);
|
||||
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", () => {
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const createCollection = (
|
||||
key: string,
|
||||
preset?: string,
|
||||
midnightRollover = false
|
||||
) => {
|
||||
const hass = createMockHass();
|
||||
hass.locale = energyPeriodLocale;
|
||||
hass.config = { ...hass.config, time_zone: "America/New_York" };
|
||||
const callWS = vi.fn(async (msg: { type: string }) => {
|
||||
if (msg.type === "energy/info") {
|
||||
return { cost_sensors: {}, solar_forecast_domains: [] };
|
||||
}
|
||||
throw new Error(`unexpected ${msg.type}`);
|
||||
});
|
||||
Object.assign(hass, {
|
||||
connection: {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
connected: true,
|
||||
},
|
||||
callWS,
|
||||
});
|
||||
if (preset) {
|
||||
localStorage.setItem(getEnergyDefaultPeriodStorageKey(hass, key), preset);
|
||||
}
|
||||
return {
|
||||
collection: getEnergyDataCollection(hass, {
|
||||
key,
|
||||
prefs: EMPTY_PREFERENCES,
|
||||
midnightRollover,
|
||||
}),
|
||||
callWS,
|
||||
};
|
||||
};
|
||||
|
||||
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 () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-06-20T00:30:00-04:00"));
|
||||
|
||||
const { collection, callWS } = createCollection("energy_timer");
|
||||
const refresh = vi.spyOn(collection, "refresh");
|
||||
const unsub = collection.subscribe(() => undefined);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
refresh.mockClear();
|
||||
callWS.mockClear();
|
||||
|
||||
assert.equal(
|
||||
collection.start.getTime(),
|
||||
energyPeriodDay(new Date(), -1).start.getTime()
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30 * 60 * 1000);
|
||||
|
||||
assert.equal(
|
||||
collection.start.getTime(),
|
||||
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.
|
||||
assert.equal(refresh.mock.calls.length, 1);
|
||||
assert.equal(energyInfoFetches(callWS).length, 1);
|
||||
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("catches up a stale live day on resubscribe", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-06-18T15:00:00-04:00"));
|
||||
|
||||
const { collection, callWS } = createCollection("energy_catchup");
|
||||
const refresh = vi.spyOn(collection, "refresh");
|
||||
let unsub = collection.subscribe(() => undefined);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
refresh.mockClear();
|
||||
callWS.mockClear();
|
||||
|
||||
unsub();
|
||||
vi.setSystemTime(new Date("2026-06-20T10:00:00-04:00"));
|
||||
unsub = collection.subscribe(() => undefined);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
assert.equal(
|
||||
collection.start.getTime(),
|
||||
energyPeriodDay(new Date()).start.getTime()
|
||||
);
|
||||
assert.equal(refresh.mock.calls.length, 1);
|
||||
assert.equal(energyInfoFetches(callWS).length, 1);
|
||||
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("does not double-refresh on a cold subscribe after the unsub grace", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-06-18T15:00:00-04:00"));
|
||||
|
||||
const { collection, callWS } = createCollection("energy_cold_refresh");
|
||||
const refresh = vi.spyOn(collection, "refresh");
|
||||
let unsub = collection.subscribe(() => undefined);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
unsub();
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
refresh.mockClear();
|
||||
callWS.mockClear();
|
||||
|
||||
vi.setSystemTime(new Date("2026-06-20T10:00:00-04:00"));
|
||||
unsub = collection.subscribe(() => undefined);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
assert.equal(
|
||||
collection.start.getTime(),
|
||||
energyPeriodDay(new Date()).start.getTime()
|
||||
);
|
||||
// The library's first fetch is not collection.refresh(); this spy only
|
||||
// sees the extra refresh used during the unsub-grace re-subscribe.
|
||||
assert.equal(refresh.mock.calls.length, 0);
|
||||
assert.equal(energyInfoFetches(callWS).length, 1);
|
||||
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("keeps a custom setPeriod range overnight", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-06-18T15:00:00-04:00"));
|
||||
|
||||
const { collection } = createCollection("energy_custom_period");
|
||||
let unsub = collection.subscribe(() => undefined);
|
||||
const custom = energyPeriodDay(new Date(), -5);
|
||||
collection.setPeriod(custom.start, custom.end);
|
||||
unsub();
|
||||
|
||||
vi.setSystemTime(new Date("2026-06-20T10:00:00-04:00"));
|
||||
unsub = collection.subscribe(() => undefined);
|
||||
|
||||
assert.equal(collection.start.getTime(), custom.start.getTime());
|
||||
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("does not advance the period after the last subscriber leaves", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-06-20T00:30:00-04:00"));
|
||||
|
||||
const { collection } = createCollection("energy_unsub_timer");
|
||||
const unsub = collection.subscribe(() => undefined);
|
||||
const start = collection.start.getTime();
|
||||
unsub();
|
||||
|
||||
vi.advanceTimersByTime(48 * 60 * 60 * 1000);
|
||||
|
||||
assert.equal(collection.start.getTime(), start);
|
||||
});
|
||||
|
||||
it("does not roll a remembered week preset over to today", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-06-18T15:00:00-04:00"));
|
||||
|
||||
const { collection } = createCollection("energy_week_stored", "this_week");
|
||||
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();
|
||||
});
|
||||
|
||||
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", () => {
|
||||
|
||||
Reference in New Issue
Block a user