Skip to content

Commit bbdb4db

Browse files
cryptomilkclaude
andcommitted
feat(graph): add start_time to anchor the graph to a fixed time today
The graph window could only be a rolling hours_to_show range relative to the latest data point, with no way to pin it to a fixed time of day (e.g. show only today from midnight). Add a start_time config key ("HH:MM") resolved via the new _resolve_start_cutoff() helper (today's date, per datetime.now(), combined with the parsed time), threaded through _extract_entity_points() as a start_cutoff override that replaces the rolling hours_to_show window when set. Addresses #88 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6b1686a commit bbdb4db

4 files changed

Lines changed: 274 additions & 18 deletions

File tree

custom_components/eink_dashboard/frontend/src/eink-dashboard-editor.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1372,6 +1372,10 @@ export const SCHEMAS: Record<
13721372
},
13731373
],
13741374
},
1375+
{
1376+
name: "start_time",
1377+
selector: { time: {} },
1378+
},
13751379
{
13761380
name: "aggregate_func",
13771381
default: "avg",
@@ -1619,6 +1623,7 @@ export const LABELS: Record<string, string> = {
16191623
attribute_timestamp_key: "Timestamp key",
16201624
attribute_value_key: "Value key",
16211625
hours_to_show: "Hours to show",
1626+
start_time: "Start time",
16221627
detail: "Detail",
16231628
limits_min: "Y-axis minimum",
16241629
limits_max: "Y-axis maximum",

custom_components/eink_dashboard/frontend/src/types/ha.d.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1005,8 +1005,19 @@ export interface GraphWidget extends WidgetBase {
10051005
* Required when {@link data_source} is ``"attribute"``.
10061006
*/
10071007
attribute_value_key?: string;
1008-
/** History window in hours. Default: 24. */
1008+
/**
1009+
* History window in hours. Default: 24. Ignored when
1010+
* ``start_time`` is set.
1011+
*/
10091012
hours_to_show?: number;
1013+
/**
1014+
* Time-of-day string in ``"HH:MM"`` format (e.g. ``"00:00"``) that
1015+
* anchors the graph window to a fixed time today instead of a
1016+
* rolling ``hours_to_show`` window. Empty/unparsable values are
1017+
* ignored. If the time has not occurred yet today, the graph
1018+
* shows no data until it does.
1019+
*/
1020+
start_time?: string;
10101021
/**
10111022
* Number of data points per hour retained after bucketing.
10121023
* Each hour is split into ``1 / points_per_hour`` fixed-width

custom_components/eink_dashboard/widgets/graph.py

Lines changed: 89 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -697,6 +697,52 @@ def _fix_header_layout(
697697
return x_off + lpad, svg_w - r_inset - rpad
698698

699699

700+
def _resolve_start_cutoff(
701+
start_time_str: str, now: datetime.datetime
702+
) -> float | None:
703+
"""Resolve a ``start_time`` config string to today's Unix timestamp.
704+
705+
``now`` is treated as local time: HA sets the system timezone to
706+
match its configured timezone, so local time is the correct frame
707+
of reference for server-side rendering (see the same assumption
708+
in ``conditions.py``'s time-condition evaluation).
709+
710+
Args:
711+
start_time_str: Time-of-day string parseable by
712+
``datetime.time.fromisoformat`` (e.g. ``"00:00"``), or
713+
empty to disable the fixed start time.
714+
now: Current local datetime; only its date is used, so tests
715+
can pass a fixed value for deterministic results.
716+
717+
Returns:
718+
Unix timestamp for ``now``'s date at the given time, or
719+
``None`` when ``start_time_str`` is empty or unparsable. If
720+
the resolved timestamp is still in the future relative to
721+
``now``, it is returned as-is (the graph will show no data
722+
until that time is reached later today) and a warning is
723+
logged.
724+
"""
725+
if not start_time_str:
726+
return None
727+
try:
728+
parsed = datetime.time.fromisoformat(start_time_str)
729+
except ValueError:
730+
_LOGGER.warning(
731+
"Graph widget 'start_time' %r is not a valid HH:MM time; "
732+
"ignoring it",
733+
start_time_str,
734+
)
735+
return None
736+
cutoff = datetime.datetime.combine(now.date(), parsed)
737+
if cutoff > now:
738+
_LOGGER.warning(
739+
"Graph widget 'start_time' %s has not occurred yet "
740+
"today; the graph will show no data until then",
741+
start_time_str,
742+
)
743+
return cutoff.timestamp()
744+
745+
700746
def _combined_state_text(
701747
entity_descs: list[dict[str, object]],
702748
states: dict[str, Any],
@@ -1207,19 +1253,26 @@ def _extract_entity_points(
12071253
hours_to_show: int,
12081254
points_per_hour: float,
12091255
aggregate_func: str,
1256+
start_cutoff: float | None = None,
12101257
) -> list[tuple[float, float]]:
12111258
"""Extract and aggregate history data for one entity descriptor.
12121259
12131260
Reads the entity's raw history from ``states_dict``, filters to
1214-
the ``hours_to_show`` time window, strips non-numeric entries, and
1215-
buckets the result via ``_aggregate_history``.
1261+
the ``hours_to_show`` time window (or ``start_cutoff`` when
1262+
given), strips non-numeric entries, and buckets the result via
1263+
``_aggregate_history``.
12161264
12171265
Args:
12181266
desc: Entity descriptor dict from ``_normalize_entities()``.
12191267
states_dict: States dict from the display config.
1220-
hours_to_show: History window in hours.
1268+
hours_to_show: History window in hours. Ignored when
1269+
``start_cutoff`` is not ``None``.
12211270
points_per_hour: Target data density for bucketing.
12221271
aggregate_func: Bucket reduction function name.
1272+
start_cutoff: Optional fixed Unix timestamp cutoff (from the
1273+
widget's ``start_time`` setting). When given, only
1274+
history entries at or after this timestamp are kept,
1275+
replacing the rolling ``hours_to_show`` window entirely.
12231276
12241277
Returns:
12251278
Sorted oldest-to-newest list of ``(timestamp, value)`` pairs,
@@ -1232,24 +1285,32 @@ def _extract_entity_points(
12321285
list(state.get("history", [])) if isinstance(state, dict) else []
12331286
)
12341287
if raw_hist:
1235-
t_latest = max(
1236-
(
1237-
float(str(e.get("lu", 0)))
1238-
for e in raw_hist
1239-
if math.isfinite(float(str(e.get("lu", 0))))
1240-
),
1241-
default=None,
1242-
)
1243-
if t_latest is None:
1244-
raw_hist = []
1245-
else:
1246-
cutoff = t_latest - hours_to_show * 3600
1288+
if start_cutoff is not None:
12471289
raw_hist = [
12481290
e
12491291
for e in raw_hist
12501292
if math.isfinite(float(str(e.get("lu", 0))))
1251-
and float(str(e.get("lu", 0))) > cutoff
1293+
and float(str(e.get("lu", 0))) >= start_cutoff
12521294
]
1295+
else:
1296+
t_latest = max(
1297+
(
1298+
float(str(e.get("lu", 0)))
1299+
for e in raw_hist
1300+
if math.isfinite(float(str(e.get("lu", 0))))
1301+
),
1302+
default=None,
1303+
)
1304+
if t_latest is None:
1305+
raw_hist = []
1306+
else:
1307+
cutoff = t_latest - hours_to_show * 3600
1308+
raw_hist = [
1309+
e
1310+
for e in raw_hist
1311+
if math.isfinite(float(str(e.get("lu", 0))))
1312+
and float(str(e.get("lu", 0))) > cutoff
1313+
]
12531314
numeric: list[tuple[float, float]] = []
12541315
for entry in raw_hist:
12551316
s = entry.get("s", "")
@@ -1487,7 +1548,14 @@ def _build_graph_context(
14871548
``name`` (display name override),
14881549
``icon`` (MDI icon name, e.g. ``"mdi:thermometer"``),
14891550
``unit`` (unit string override),
1490-
``hours_to_show`` (history window in hours; default 24),
1551+
``hours_to_show`` (history window in hours; default 24;
1552+
ignored when ``start_time`` is set),
1553+
``start_time`` (time-of-day string, e.g. ``"00:00"``,
1554+
parseable by ``datetime.time.fromisoformat``; when set,
1555+
the graph shows data from this time today onward instead
1556+
of a rolling ``hours_to_show`` window; empty/unparsable
1557+
values are ignored; if the time has not occurred yet
1558+
today, the graph shows no data until it does),
14911559
``points_per_hour`` (data points per hour; default 0.5,
14921560
giving one point per 2-hour bucket),
14931561
``aggregate_func`` (``"avg"``, ``"min"``, ``"max"``,
@@ -1558,6 +1626,9 @@ def _build_graph_context(
15581626
svg_w = _widget_dim(widget, "w", config["width"] - x)
15591627

15601628
hours_to_show: int = int(float(widget.get("hours_to_show", 24)))
1629+
start_cutoff = _resolve_start_cutoff(
1630+
str(widget.get("start_time", "")), datetime.datetime.now()
1631+
)
15611632
points_per_hour: float = float(widget.get("points_per_hour", 0.5))
15621633
aggregate_func: str = str(widget.get("aggregate_func", "avg"))
15631634
group_by: str = str(widget.get("group_by", "interval"))
@@ -1696,6 +1767,7 @@ def _build_graph_context(
16961767
hours_to_show,
16971768
points_per_hour,
16981769
aggregate_func,
1770+
start_cutoff,
16991771
)
17001772
)
17011773

tests/test_render_graph.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,59 @@ def test_graph_hours_to_show_affects_data(self) -> None:
510510
)
511511
assert svg_full != svg_short
512512

513+
def test_graph_start_time_filters_out_earlier_points(self) -> None:
514+
# start_time anchors the window to a fixed time today,
515+
# overriding hours_to_show entirely: points before the fixed
516+
# start time are excluded, raising the auto-computed Y-axis
517+
# minimum from 10.0 (all points) to 20.0 (only points at/after
518+
# the start time). History timestamps are built relative to
519+
# datetime.now() so the test is deterministic regardless of
520+
# when it runs.
521+
import datetime as dt
522+
523+
now = dt.datetime.now()
524+
midday = now.replace(hour=12, minute=0, second=0, microsecond=0)
525+
history = [
526+
{
527+
"s": "10.0",
528+
"lu": (midday - dt.timedelta(hours=2)).timestamp(),
529+
},
530+
{
531+
"s": "15.0",
532+
"lu": (midday - dt.timedelta(hours=1)).timestamp(),
533+
},
534+
{
535+
"s": "20.0",
536+
"lu": (midday + dt.timedelta(hours=1)).timestamp(),
537+
},
538+
{
539+
"s": "25.0",
540+
"lu": (midday + dt.timedelta(hours=2)).timestamp(),
541+
},
542+
]
543+
states: dict[str, dict[str, object]] = {
544+
"sensor.temperature": {
545+
**MOCK_GRAPH_STATES["sensor.temperature"],
546+
"history": history,
547+
}
548+
}
549+
common = {
550+
"hours_to_show": 48,
551+
"points_per_hour": 2,
552+
"smoothing": False,
553+
"show_labels": True,
554+
}
555+
svg_no_start = render_widget_svg(
556+
self._base_widget(**common), self._config(states=states)
557+
)
558+
svg_with_start = render_widget_svg(
559+
self._base_widget(**common, start_time="12:00"),
560+
self._config(states=states),
561+
)
562+
assert "10.0" in svg_no_start
563+
assert "10.0" not in svg_with_start
564+
assert "20.0" in svg_with_start
565+
513566
def test_graph_aggregate_avg_accepted(self) -> None:
514567
# aggregate_func="avg" is accepted without error and produces a
515568
# graph element.
@@ -2876,3 +2929,118 @@ def test_graph_thresholds_fill_gradient_uses_lighter_colors(
28762929
fill_val = int(fill_hex[1:3], 16)
28772930
# Fill is lighter (higher value) than stroke.
28782931
assert fill_val >= stroke_val
2932+
2933+
# ── _resolve_start_cutoff unit tests ───────────────────────────────
2934+
2935+
def test_resolve_start_cutoff_empty_string_is_none(self) -> None:
2936+
# An empty start_time string disables the fixed start time.
2937+
import datetime as dt
2938+
2939+
from custom_components.eink_dashboard.widgets.graph import (
2940+
_resolve_start_cutoff,
2941+
)
2942+
2943+
now = dt.datetime(2026, 6, 11, 15, 0)
2944+
assert _resolve_start_cutoff("", now) is None
2945+
2946+
def test_resolve_start_cutoff_invalid_string_is_none(self) -> None:
2947+
# An unparsable start_time string is ignored rather than
2948+
# raising, degrading gracefully to "no fixed start time".
2949+
import datetime as dt
2950+
2951+
from custom_components.eink_dashboard.widgets.graph import (
2952+
_resolve_start_cutoff,
2953+
)
2954+
2955+
result = _resolve_start_cutoff(
2956+
"not-a-time", dt.datetime(2026, 6, 11, 15, 0)
2957+
)
2958+
assert result is None
2959+
2960+
def test_resolve_start_cutoff_combines_today_with_time(self) -> None:
2961+
# A valid "HH:MM" string is combined with now's date to
2962+
# produce today's timestamp at that time.
2963+
import datetime as dt
2964+
2965+
from custom_components.eink_dashboard.widgets.graph import (
2966+
_resolve_start_cutoff,
2967+
)
2968+
2969+
now = dt.datetime(2026, 6, 11, 15, 0)
2970+
result = _resolve_start_cutoff("06:30", now)
2971+
expected = dt.datetime(2026, 6, 11, 6, 30).timestamp()
2972+
assert result == expected
2973+
2974+
def test_resolve_start_cutoff_invalid_string_logs_warning(
2975+
self, caplog: pytest.LogCaptureFixture
2976+
) -> None:
2977+
# An unparsable start_time string logs a warning so a typo in
2978+
# the widget config isn't silently invisible.
2979+
import datetime as dt
2980+
import logging
2981+
2982+
from custom_components.eink_dashboard.widgets.graph import (
2983+
_resolve_start_cutoff,
2984+
)
2985+
2986+
with caplog.at_level(logging.WARNING):
2987+
_resolve_start_cutoff(
2988+
"not-a-time", dt.datetime(2026, 6, 11, 15, 0)
2989+
)
2990+
assert "not a valid" in caplog.text
2991+
2992+
def test_resolve_start_cutoff_future_time_logs_warning(
2993+
self, caplog: pytest.LogCaptureFixture
2994+
) -> None:
2995+
# When start_time has not occurred yet today, the timestamp
2996+
# is still returned as-is (the graph anchors to today's date
2997+
# only; it does not roll back to yesterday), but a warning is
2998+
# logged since the graph will show no data until then.
2999+
import datetime as dt
3000+
import logging
3001+
3002+
from custom_components.eink_dashboard.widgets.graph import (
3003+
_resolve_start_cutoff,
3004+
)
3005+
3006+
now = dt.datetime(2026, 6, 11, 8, 0)
3007+
with caplog.at_level(logging.WARNING):
3008+
result = _resolve_start_cutoff("18:00", now)
3009+
expected = dt.datetime(2026, 6, 11, 18, 0).timestamp()
3010+
assert result == expected
3011+
assert "has not occurred yet today" in caplog.text
3012+
3013+
def test_extract_entity_points_start_cutoff_is_inclusive(self) -> None:
3014+
# A history entry exactly at start_cutoff is kept, matching
3015+
# the widget docstring's "from this time today onward"
3016+
# (inclusive) wording. Points are spaced an hour apart with
3017+
# points_per_hour=1 so each lands in its own bucket and the
3018+
# aggregated value is unchanged, making it safe to assert on
3019+
# the returned values directly.
3020+
from custom_components.eink_dashboard.widgets.graph import (
3021+
_extract_entity_points,
3022+
)
3023+
3024+
cutoff = 0.0
3025+
desc = {"entity": "sensor.temperature"}
3026+
states_dict = {
3027+
"sensor.temperature": {
3028+
"history": [
3029+
{"s": "1.0", "lu": cutoff - 3600},
3030+
{"s": "2.0", "lu": cutoff},
3031+
{"s": "3.0", "lu": cutoff + 3600},
3032+
]
3033+
}
3034+
}
3035+
points = _extract_entity_points(
3036+
desc,
3037+
states_dict,
3038+
hours_to_show=24,
3039+
points_per_hour=1,
3040+
aggregate_func="avg",
3041+
start_cutoff=cutoff,
3042+
)
3043+
values = [v for _, v in points]
3044+
assert 1.0 not in values
3045+
assert 2.0 in values
3046+
assert 3.0 in values

0 commit comments

Comments
 (0)