Compare commits

...

1 Commits

Author SHA1 Message Date
Petar Petrov
036ae921e7 Fix history-graph card rendering stale data point on left edge
When HistoryStream.processMessage() prunes expired history and preserves
the last expired state as a boundary marker, it updates lu (last_updated)
but not lc (last_changed). Chart components use lc preferentially, so
when lc is present the boundary point gets plotted at the original stale
timestamp far to the left of the visible window. Delete lc from the
boundary state so the chart uses the corrected lu timestamp.
2026-02-09 10:58:42 +02:00
2 changed files with 112 additions and 1 deletions

View File

@@ -142,7 +142,7 @@ export const subscribeHistory = (
);
};
class HistoryStream {
export class HistoryStream {
hass: HomeAssistant;
hoursToShow?: number;
@@ -221,6 +221,7 @@ class HistoryStream {
// only expire the rest of the history as it ages.
const lastExpiredState = expiredStates[expiredStates.length - 1];
lastExpiredState.lu = purgeBeforePythonTime;
delete lastExpiredState.lc;
newHistory[entityId].unshift(lastExpiredState);
}
}

110
test/data/history.test.ts Normal file
View File

@@ -0,0 +1,110 @@
import { describe, it, assert, vi } from "vitest";
import { HistoryStream } from "../../src/data/history";
import type { HomeAssistant } from "../../src/types";
const mockHass = {} as HomeAssistant;
describe("HistoryStream.processMessage", () => {
it("should delete lc from boundary state when pruning expired history", () => {
const now = Date.now();
const hoursToShow = 1;
const stream = new HistoryStream(mockHass, hoursToShow);
const purgeBeforePythonTime = (now - 60 * 60 * hoursToShow * 1000) / 1000;
// Seed combinedHistory with states where lc differs from lu
// (simulating a sensor reporting the same value multiple times)
const oldLc = purgeBeforePythonTime - 3600; // lc is 1 hour before purge time
const oldLu = purgeBeforePythonTime - 10; // lu is 10 seconds before purge time
stream.combinedHistory = {
"sensor.power": [
{ s: "500", a: {}, lc: oldLc, lu: oldLu },
{ s: "500", a: {}, lu: purgeBeforePythonTime + 100 },
],
};
vi.useFakeTimers();
vi.setSystemTime(now);
const result = stream.processMessage({
states: {
"sensor.power": [{ s: "510", a: {}, lu: purgeBeforePythonTime + 200 }],
},
});
vi.useRealTimers();
const boundaryState = result["sensor.power"][0];
// lc should be deleted so chart uses lu instead of stale lc
assert.equal(boundaryState.lc, undefined);
// lu should be set to approximately purgeBeforePythonTime
assert.closeTo(boundaryState.lu, purgeBeforePythonTime, 1);
// value should be preserved from the expired state
assert.equal(boundaryState.s, "500");
});
it("should handle boundary state without lc correctly", () => {
const now = Date.now();
const hoursToShow = 1;
const stream = new HistoryStream(mockHass, hoursToShow);
const purgeBeforePythonTime = (now - 60 * 60 * hoursToShow * 1000) / 1000;
// State without lc (lc equals lu, so lc is omitted)
stream.combinedHistory = {
"sensor.power": [
{ s: "500", a: {}, lu: purgeBeforePythonTime - 10 },
{ s: "510", a: {}, lu: purgeBeforePythonTime + 100 },
],
};
vi.useFakeTimers();
vi.setSystemTime(now);
const result = stream.processMessage({
states: {
"sensor.power": [{ s: "520", a: {}, lu: purgeBeforePythonTime + 200 }],
},
});
vi.useRealTimers();
const boundaryState = result["sensor.power"][0];
assert.equal(boundaryState.lc, undefined);
assert.closeTo(boundaryState.lu, purgeBeforePythonTime, 1);
assert.equal(boundaryState.s, "500");
});
it("should not modify states when none are expired", () => {
const now = Date.now();
const hoursToShow = 1;
const stream = new HistoryStream(mockHass, hoursToShow);
const purgeBeforePythonTime = (now - 60 * 60 * hoursToShow * 1000) / 1000;
// All states are within the time window
stream.combinedHistory = {
"sensor.power": [
{
s: "500",
a: {},
lc: purgeBeforePythonTime + 50,
lu: purgeBeforePythonTime + 100,
},
],
};
vi.useFakeTimers();
vi.setSystemTime(now);
const result = stream.processMessage({
states: {
"sensor.power": [{ s: "510", a: {}, lu: purgeBeforePythonTime + 200 }],
},
});
vi.useRealTimers();
// First state should retain its original lc since it wasn't expired
const firstState = result["sensor.power"][0];
assert.equal(firstState.lc, purgeBeforePythonTime + 50);
assert.equal(firstState.lu, purgeBeforePythonTime + 100);
});
});