From 036ae921e7e9fd8a7d35e5be58d1044fabed30bd Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Mon, 9 Feb 2026 10:58:42 +0200 Subject: [PATCH] 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. --- src/data/history.ts | 3 +- test/data/history.test.ts | 110 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 test/data/history.test.ts diff --git a/src/data/history.ts b/src/data/history.ts index fdbd9be1e0..b688650603 100644 --- a/src/data/history.ts +++ b/src/data/history.ts @@ -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); } } diff --git a/test/data/history.test.ts b/test/data/history.test.ts new file mode 100644 index 0000000000..76397a28de --- /dev/null +++ b/test/data/history.test.ts @@ -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); + }); +});