Compare commits

...
4 Commits
Author SHA1 Message Date
Petar Petrov 043f42617a Don't follow the live day from midnightRollover alone.
A stored non-today preset would otherwise be discarded on the first subscribe. Drop tautological UTC DST tests that cannot fail.
2026-08-20 16:37:54 +03:00
Petar Petrov b2130a0cdc Keep energy day math in the server timezone so DST cannot skip a live day.
Browser-local addDays can jump a calendar day on a 23-hour DST fallback.
Assert against tz-internal endOfDay/addDays under Europe/Berlin so UTC CI
catches a regression, and prove the 01:00 timer and catch-up refresh fetch
the live day rather than only updating collection.start.
2026-08-20 14:24:25 +03:00
Petar Petrov 9481f7c948 Catch up the energy live day before subscribe fetches. 2026-08-20 13:42:45 +03:00
Petar Petrov c70ffc9060 Fix energy dashboard staying on yesterday after midnight. 2026-08-20 13:04:02 +03:00
3 changed files with 695 additions and 73 deletions
+171 -54
View File
@@ -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();
}
};
+116
View File
@@ -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
View File
@@ -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", () => {