Skip to content

Commit 7926689

Browse files
authored
feat: expose custom interval attrs on chore status sensors for all custom frequencies (#242)
* feat(sensor): expose custom interval attrs for all custom frequencies What changed: - Added CUSTOM_INTERVAL_FREQUENCIES frozenset in const.py covering custom, custom_from_complete, custom_from_complete_date_only - Both chore status sensors (per-user + shared/global) now expose custom_frequency_interval and custom_frequency_unit attributes for all custom frequencies, not just FREQUENCY_CUSTOM - Added targeted tests verifying attribute exposure on both sensor types Why: - GH-238: dashboards need the configured recurrence interval/unit to render human-readable schedules without external lookup tables * feat(dashboards): stack custom interval in admin v1 recurrence cell What changed: - admin-shared-v1 and admin-peruser-v1 now render a second line inside the recurrence detail cell showing 'Every X unit' when the chore uses a custom frequency with a configured interval - Keeps the mode label (custom vs custom_from_complete) as the primary line; interval is stacked below without adding a new grid item Why: - GH-238: surface configured interval without sacrificing the custom-mode distinction in the fragile admin detail grid
1 parent d5bc23f commit 7926689

5 files changed

Lines changed: 169 additions & 4 deletions

File tree

custom_components/choreops/const.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4000,6 +4000,16 @@ class EntityRequirement(StrEnum):
40004000
FREQUENCY_CUSTOM_FROM_COMPLETE_DATE_ONLY,
40014001
]
40024002

4003+
# Frequencies that carry a user-defined custom interval value + unit.
4004+
# FREQUENCY_CUSTOM_1_* shortcuts are NOT included (fixed-length, no stored value).
4005+
CUSTOM_INTERVAL_FREQUENCIES = frozenset(
4006+
{
4007+
FREQUENCY_CUSTOM,
4008+
FREQUENCY_CUSTOM_FROM_COMPLETE,
4009+
FREQUENCY_CUSTOM_FROM_COMPLETE_DATE_ONLY,
4010+
}
4011+
)
4012+
40034013
# Weekday Options
40044014
WEEKDAY_OPTIONS = {
40054015
"mon": "Monday",

custom_components/choreops/dashboards/templates/admin-peruser-v1.yaml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1510,6 +1510,9 @@ views:
15101510
{%- set points_label = (state_attr(points_sensor, 'unit_of_measurement') if points_sensor not in ['', None, 'None'] else None) or 'Points' -%}
15111511
{%- set recurrence_key = chore_attrs.get('recurring_frequency', 'none') -%}
15121512
{%- set recurrence_display = recurrence_map.get(recurrence_key, recurrence_key | title) -%}
1513+
{%- set custom_interval_value = chore_attrs.get('custom_frequency_interval') -%}
1514+
{%- set custom_interval_unit = chore_attrs.get('custom_frequency_unit') -%}
1515+
{%- set recurrence_interval_display = (ui.get('every', 'err-every') ~ ' ' ~ custom_interval_value ~ ' ' ~ custom_interval_unit) if custom_interval_value not in [None, 'None', ''] and custom_interval_unit not in [None, 'None', ''] else '' -%}
15131516
{%- set approval_reset_display = approval_reset_map.get(chore_attrs.get('approval_reset_type', 'at_midnight_once'), chore_attrs.get('approval_reset_type', ui.get('unknown', 'err-unknown'))) -%}
15141517
{%- set pending_claim_action_map = {
15151518
'hold_pending': ui.get('hold_pending', 'Hold pending'),
@@ -1640,7 +1643,7 @@ views:
16401643
{%- set detail_grid = detail_grid ~ "<div><div style='font-size:10px;text-transform:uppercase;letter-spacing:0.06em;color:var(--secondary-text-color);'>" ~ ui.get('completion_criteria', 'err-completion_criteria') ~ "</div><div style='margin-top:2px;font-size:13px;font-weight:600;line-height:1.3;'>" ~ completion_type_display ~ "</div></div>" -%}
16411644
{%- set detail_grid = detail_grid ~ "<div><div style='font-size:10px;text-transform:uppercase;letter-spacing:0.06em;color:var(--secondary-text-color);'>" ~ ui.get('value', 'err-value') ~ "</div><div style='margin-top:2px;font-size:13px;font-weight:600;line-height:1.3;'>" ~ chore_value ~ " " ~ points_label ~ "</div></div>" -%}
16421645
{%- set detail_grid = detail_grid ~ "<div><div style='font-size:10px;text-transform:uppercase;letter-spacing:0.06em;color:var(--secondary-text-color);'>" ~ ui.get('approval_reset_type', 'err-approval_reset_type') ~ "</div><div style='margin-top:2px;font-size:13px;font-weight:600;line-height:1.3;'>" ~ approval_reset_display ~ "</div></div>" -%}
1643-
{%- set detail_grid = detail_grid ~ "<div><div style='font-size:10px;text-transform:uppercase;letter-spacing:0.06em;color:var(--secondary-text-color);'>" ~ ui.get('recurrence', 'err-recurrence') ~ "</div><div style='margin-top:2px;font-size:13px;font-weight:600;line-height:1.3;'>" ~ recurrence_display ~ "</div></div>" -%}
1646+
{%- set detail_grid = detail_grid ~ ("<div><div style='font-size:10px;text-transform:uppercase;letter-spacing:0.06em;color:var(--secondary-text-color);'>" ~ ui.get('recurrence', 'err-recurrence') ~ "</div><div style='margin-top:2px;font-size:13px;font-weight:600;line-height:1.3;'>" ~ recurrence_display ~ "</div>" ~ ("<div style='margin-top:2px;font-size:11px;font-weight:500;line-height:1.3;color:var(--secondary-text-color);'>" ~ recurrence_interval_display ~ "</div>" if recurrence_interval_display != '' else '') ~ "</div>") -%}
16441647
{%- if is_rotation_type and assigned_users | count > 0 -%}
16451648
{%- set assignee_order_ns = namespace(items=[]) -%}
16461649
{%- if completion_type_key == 'rotation_primary_standby' -%}

custom_components/choreops/dashboards/templates/admin-shared-v1.yaml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1578,6 +1578,9 @@ views:
15781578
{%- set points_label = (state_attr(points_sensor, 'unit_of_measurement') if points_sensor not in ['', None, 'None'] else None) or 'Points' -%}
15791579
{%- set recurrence_key = chore_attrs.get('recurring_frequency', 'none') -%}
15801580
{%- set recurrence_display = recurrence_map.get(recurrence_key, recurrence_key | title) -%}
1581+
{%- set custom_interval_value = chore_attrs.get('custom_frequency_interval') -%}
1582+
{%- set custom_interval_unit = chore_attrs.get('custom_frequency_unit') -%}
1583+
{%- set recurrence_interval_display = (ui.get('every', 'err-every') ~ ' ' ~ custom_interval_value ~ ' ' ~ custom_interval_unit) if custom_interval_value not in [None, 'None', ''] and custom_interval_unit not in [None, 'None', ''] else '' -%}
15811584
{%- set approval_reset_display = approval_reset_map.get(chore_attrs.get('approval_reset_type', 'at_midnight_once'), chore_attrs.get('approval_reset_type', ui.get('unknown', 'err-unknown'))) -%}
15821585
{%- set pending_claim_action_map = {
15831586
'hold_pending': ui.get('hold_pending', 'Hold pending'),
@@ -1708,7 +1711,7 @@ views:
17081711
{%- set detail_grid = detail_grid ~ "<div><div style='font-size:10px;text-transform:uppercase;letter-spacing:0.06em;color:var(--secondary-text-color);'>" ~ ui.get('completion_criteria', 'err-completion_criteria') ~ "</div><div style='margin-top:2px;font-size:13px;font-weight:600;line-height:1.3;'>" ~ completion_type_display ~ "</div></div>" -%}
17091712
{%- set detail_grid = detail_grid ~ "<div><div style='font-size:10px;text-transform:uppercase;letter-spacing:0.06em;color:var(--secondary-text-color);'>" ~ ui.get('value', 'err-value') ~ "</div><div style='margin-top:2px;font-size:13px;font-weight:600;line-height:1.3;'>" ~ chore_value ~ " " ~ points_label ~ "</div></div>" -%}
17101713
{%- set detail_grid = detail_grid ~ "<div><div style='font-size:10px;text-transform:uppercase;letter-spacing:0.06em;color:var(--secondary-text-color);'>" ~ ui.get('approval_reset_type', 'err-approval_reset_type') ~ "</div><div style='margin-top:2px;font-size:13px;font-weight:600;line-height:1.3;'>" ~ approval_reset_display ~ "</div></div>" -%}
1711-
{%- set detail_grid = detail_grid ~ "<div><div style='font-size:10px;text-transform:uppercase;letter-spacing:0.06em;color:var(--secondary-text-color);'>" ~ ui.get('recurrence', 'err-recurrence') ~ "</div><div style='margin-top:2px;font-size:13px;font-weight:600;line-height:1.3;'>" ~ recurrence_display ~ "</div></div>" -%}
1714+
{%- set detail_grid = detail_grid ~ ("<div><div style='font-size:10px;text-transform:uppercase;letter-spacing:0.06em;color:var(--secondary-text-color);'>" ~ ui.get('recurrence', 'err-recurrence') ~ "</div><div style='margin-top:2px;font-size:13px;font-weight:600;line-height:1.3;'>" ~ recurrence_display ~ "</div>" ~ ("<div style='margin-top:2px;font-size:11px;font-weight:500;line-height:1.3;color:var(--secondary-text-color);'>" ~ recurrence_interval_display ~ "</div>" if recurrence_interval_display != '' else '') ~ "</div>") -%}
17121715
{%- if is_rotation_type and assigned_users | count > 0 -%}
17131716
{%- set assignee_order_ns = namespace(items=[]) -%}
17141717
{%- if completion_type_key == 'rotation_primary_standby' -%}

custom_components/choreops/sensor.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1316,7 +1316,7 @@ def extra_state_attributes(self) -> dict[str, Any]:
13161316

13171317
if (
13181318
chore_info.get(const.DATA_CHORE_RECURRING_FREQUENCY)
1319-
== const.FREQUENCY_CUSTOM
1319+
in const.CUSTOM_INTERVAL_FREQUENCIES
13201320
):
13211321
attributes[const.ATTR_CUSTOM_FREQUENCY_INTERVAL] = chore_info.get(
13221322
const.DATA_CHORE_CUSTOM_INTERVAL
@@ -2794,7 +2794,7 @@ def extra_state_attributes(self) -> dict:
27942794

27952795
if (
27962796
chore_info.get(const.DATA_CHORE_RECURRING_FREQUENCY)
2797-
== const.FREQUENCY_CUSTOM
2797+
in const.CUSTOM_INTERVAL_FREQUENCIES
27982798
):
27992799
attributes[const.ATTR_CUSTOM_FREQUENCY_INTERVAL] = chore_info.get(
28002800
const.DATA_CHORE_CUSTOM_INTERVAL

tests/test_frequency_enhanced.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from zoneinfo import ZoneInfo
2424

2525
from homeassistant.core import HomeAssistant
26+
from homeassistant.helpers import entity_registry as er
2627
from homeassistant.util import dt as dt_util
2728
import pytest
2829

@@ -1312,3 +1313,151 @@ async def test_f3_10_regression_months_still_works(
13121313
# Jan 14 + 1 month = Feb 14
13131314
assert result_dt.month == 2
13141315
assert result_dt.day == 14
1316+
1317+
1318+
# =============================================================================
1319+
# CUSTOM INTERVAL ATTRIBUTES (GH-238)
1320+
# =============================================================================
1321+
1322+
1323+
class TestCustomIntervalSensorAttributes:
1324+
"""Verify custom interval/unit attributes on chore status sensors.
1325+
1326+
The global (shared) and per-user chore status sensors must expose the
1327+
configured custom interval value and unit when the chore uses one of the
1328+
custom frequencies (FREQUENCY_CUSTOM, FREQUENCY_CUSTOM_FROM_COMPLETE,
1329+
FREQUENCY_CUSTOM_FROM_COMPLETE_DATE_ONLY).
1330+
"""
1331+
1332+
def _per_user_sensor_unique_id(
1333+
self,
1334+
entry_id: str,
1335+
assignee_id: str,
1336+
chore_id: str,
1337+
) -> str:
1338+
"""Return the unique ID for a per-user chore status sensor."""
1339+
return (
1340+
f"{entry_id}_{assignee_id}_{chore_id}"
1341+
f"{const.SENSOR_KC_UID_SUFFIX_CHORE_STATUS_SENSOR}"
1342+
)
1343+
1344+
def _shared_sensor_unique_id(self, entry_id: str, chore_id: str) -> str:
1345+
"""Return the unique ID for a shared chore global status sensor."""
1346+
return (
1347+
f"{entry_id}_{chore_id}"
1348+
f"{const.SENSOR_KC_UID_SUFFIX_SHARED_CHORE_GLOBAL_STATE_SENSOR}"
1349+
)
1350+
1351+
def _entity_id_from_unique_id(
1352+
self,
1353+
entity_registry: er.EntityRegistry,
1354+
unique_id: str,
1355+
) -> str | None:
1356+
"""Resolve an entity id from its registry unique id."""
1357+
return entity_registry.async_get_entity_id("sensor", const.DOMAIN, unique_id)
1358+
1359+
async def test_custom_from_complete_per_user_and_shared_attributes(
1360+
self,
1361+
hass: HomeAssistant,
1362+
entity_registry: er.EntityRegistry,
1363+
scenario_enhanced_frequencies: SetupResult,
1364+
) -> None:
1365+
"""Custom-from-complete chore exposes interval attrs on both sensors."""
1366+
coordinator = scenario_enhanced_frequencies.coordinator
1367+
config_entry = scenario_enhanced_frequencies.config_entry
1368+
chore_id = scenario_enhanced_frequencies.chore_ids[
1369+
"Custom From Complete SHARED"
1370+
]
1371+
assignee_id = scenario_enhanced_frequencies.assignee_ids["Zoë"]
1372+
1373+
chore_info = coordinator.chores_data.get(chore_id, {})
1374+
assert (
1375+
chore_info.get(DATA_CHORE_RECURRING_FREQUENCY)
1376+
== FREQUENCY_CUSTOM_FROM_COMPLETE
1377+
)
1378+
assert chore_info.get(DATA_CHORE_CUSTOM_INTERVAL) == 10
1379+
assert chore_info.get(DATA_CHORE_CUSTOM_INTERVAL_UNIT) == TIME_UNIT_DAYS
1380+
1381+
# Per-user sensor exposure
1382+
per_user_eid = self._per_user_sensor_unique_id(
1383+
config_entry.entry_id, assignee_id, chore_id
1384+
)
1385+
per_user_entity_id = self._entity_id_from_unique_id(
1386+
entity_registry, per_user_eid
1387+
)
1388+
assert per_user_entity_id is not None
1389+
per_user_state = hass.states.get(per_user_entity_id)
1390+
assert per_user_state is not None
1391+
assert per_user_state.attributes[const.ATTR_CUSTOM_FREQUENCY_INTERVAL] == 10
1392+
assert per_user_state.attributes[const.ATTR_CUSTOM_FREQUENCY_UNIT] == (
1393+
TIME_UNIT_DAYS
1394+
)
1395+
1396+
# Shared global sensor (chore is shared_all) exposure
1397+
shared_sensor_unique_id = self._shared_sensor_unique_id(
1398+
config_entry.entry_id, chore_id
1399+
)
1400+
shared_entity_id = self._entity_id_from_unique_id(
1401+
entity_registry, shared_sensor_unique_id
1402+
)
1403+
assert shared_entity_id is not None
1404+
shared_state = hass.states.get(shared_entity_id)
1405+
assert shared_state is not None
1406+
assert shared_state.attributes[const.ATTR_CUSTOM_FREQUENCY_INTERVAL] == 10
1407+
assert shared_state.attributes[const.ATTR_CUSTOM_FREQUENCY_UNIT] == (
1408+
TIME_UNIT_DAYS
1409+
)
1410+
1411+
@pytest.mark.parametrize(
1412+
("chore_name", "assignee_name", "frequency", "interval", "unit"),
1413+
[
1414+
pytest.param(
1415+
"Custom From Complete SHARED",
1416+
"Zoë",
1417+
FREQUENCY_CUSTOM_FROM_COMPLETE,
1418+
10,
1419+
TIME_UNIT_DAYS,
1420+
id="custom_from_complete",
1421+
),
1422+
pytest.param(
1423+
"Custom Hours 4h",
1424+
"Zoë",
1425+
FREQUENCY_CUSTOM,
1426+
4,
1427+
TIME_UNIT_HOURS,
1428+
id="custom",
1429+
),
1430+
],
1431+
)
1432+
@pytest.mark.asyncio
1433+
async def test_per_user_sensor_attributes_parametrized(
1434+
self,
1435+
hass: HomeAssistant,
1436+
entity_registry: er.EntityRegistry,
1437+
scenario_enhanced_frequencies: SetupResult,
1438+
chore_name: str,
1439+
assignee_name: str,
1440+
frequency: str,
1441+
interval: int,
1442+
unit: str,
1443+
) -> None:
1444+
"""Per-user chore sensor exposes interval attrs across custom freqs."""
1445+
coordinator = scenario_enhanced_frequencies.coordinator
1446+
config_entry = scenario_enhanced_frequencies.config_entry
1447+
chore_id = scenario_enhanced_frequencies.chore_ids[chore_name]
1448+
assignee_id = scenario_enhanced_frequencies.assignee_ids[assignee_name]
1449+
1450+
chore_info = coordinator.chores_data.get(chore_id, {})
1451+
assert chore_info.get(DATA_CHORE_RECURRING_FREQUENCY) == frequency
1452+
assert chore_info.get(DATA_CHORE_CUSTOM_INTERVAL) == interval
1453+
assert chore_info.get(DATA_CHORE_CUSTOM_INTERVAL_UNIT) == unit
1454+
1455+
unique_id = self._per_user_sensor_unique_id(
1456+
config_entry.entry_id, assignee_id, chore_id
1457+
)
1458+
entity_id = self._entity_id_from_unique_id(entity_registry, unique_id)
1459+
assert entity_id is not None
1460+
state = hass.states.get(entity_id)
1461+
assert state is not None
1462+
assert state.attributes[const.ATTR_CUSTOM_FREQUENCY_INTERVAL] == interval
1463+
assert state.attributes[const.ATTR_CUSTOM_FREQUENCY_UNIT] == unit

0 commit comments

Comments
 (0)