From b3822e649663a3917030e7ee7df8a18d96b7f553 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:42:43 +0000 Subject: [PATCH 1/4] Initial plan From 23727281b35e1038929ed01f29f854ab24efa2cf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:46:36 +0000 Subject: [PATCH 2/4] Add total_increasing times-completed sensor per task Co-authored-by: gensyn <36128035+gensyn@users.noreply.github.com> --- coordinator.py | 2 + sensor.py | 50 ++++++++++++++++- tests/integration_tests/test_integration.py | 54 +++++++++++++++++++ .../homeassistant/components/sensor.py | 4 ++ 4 files changed, 108 insertions(+), 2 deletions(-) diff --git a/coordinator.py b/coordinator.py index f13bb04..273639e 100644 --- a/coordinator.py +++ b/coordinator.py @@ -70,6 +70,7 @@ def __init__( self.repeat_days_before_end: int = max(0, repeat_days_before_end or 0) self.repeat_months_interval: int = max(1, repeat_months_interval or 1) self.due_soon_days: int = max(0, due_soon_days or 0) + self.times_completed: int = 0 self._listeners: list[Callable[[], None]] = [] def async_add_listener(self, update_callback: Callable[[], None]) -> Callable[[], None]: @@ -136,6 +137,7 @@ async def async_mark_as_done(self, today: date) -> None: self.last_done = self._find_most_recent_occurrence(today) else: self.last_done = today + self.times_completed += 1 self._async_notify_listeners() async def async_set_last_done_date(self, new_date: date) -> None: diff --git a/sensor.py b/sensor.py index 43298c8..e4733e7 100644 --- a/sensor.py +++ b/sensor.py @@ -8,7 +8,7 @@ from typing import Any, Callable from homeassistant.components.sensor import ( - SensorEntity, RestoreSensor, + SensorEntity, RestoreSensor, SensorStateClass, ) from homeassistant.const import CONF_NAME, CONF_ICON, CONF_ENTITY_ID, EVENT_STATE_CHANGED from homeassistant.core import HomeAssistant, EventStateChangedData, callback @@ -49,7 +49,8 @@ async def async_setup_entry( options.get(CONF_ACTIVE_OVERRIDE), options.get(CONF_TASK_INTERVAL_OVERRIDE), options.get(CONF_DUE_SOON_OVERRIDE), - options.get(CONF_DEPENDENCIES) or [])]) + options.get(CONF_DEPENDENCIES) or []), + TaskTrackerTimesCompletedSensor(coordinator, data[CONF_NAME], entry.entry_id, hass)]) class TaskTrackerSensor(RestoreSensor, SensorEntity): @@ -440,3 +441,48 @@ async def async_mark_as_done(self) -> None: async def async_set_last_done_date(self, new_date: date) -> None: """Set the last done date.""" await self.coordinator.async_set_last_done_date(new_date) + + +class TaskTrackerTimesCompletedSensor(RestoreSensor, SensorEntity): + """Sensor tracking how many times a task was completed.""" + + _attr_has_entity_name = True + _attr_should_poll = False + _attr_name = "Times completed" + _attr_state_class = SensorStateClass.TOTAL_INCREASING + + def __init__(self, coordinator: TaskTrackerCoordinator, entry_name: str, entry_id: str, hass: HomeAssistant) -> None: + """Initialize the times-completed sensor.""" + self.coordinator = coordinator + self.entry_id = entry_id + device_id = f"{DOMAIN}_{self.entry_id}" + self._attr_unique_id = f"{entry_id}_times_completed" + self.entity_id = generate_entity_id("sensor.task_tracker_{}_times_completed", slugify(entry_name), hass=hass) + self._attr_native_value = 0 + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device_id)}, + manufacturer="Gensyn", + model="Task Tracker", + name=entry_name, + ) + + async def async_added_to_hass(self) -> None: + """Restore last known state on startup.""" + await super().async_added_to_hass() + self.async_on_remove( + self.coordinator.async_add_listener( + lambda: self.async_schedule_update_ha_state(force_refresh=True) + ) + ) + last_sensor_state = await self.async_get_last_sensor_data() + if last_sensor_state is not None: + try: + restored_times_completed = int(last_sensor_state.native_value) + except (TypeError, ValueError): + restored_times_completed = 0 + self.coordinator.times_completed = max(self.coordinator.times_completed, restored_times_completed) + self._attr_native_value = self.coordinator.times_completed + + async def async_update(self) -> None: + """Update sensor state from coordinator.""" + self._attr_native_value = self.coordinator.times_completed diff --git a/tests/integration_tests/test_integration.py b/tests/integration_tests/test_integration.py index 433268b..6a91dd8 100644 --- a/tests/integration_tests/test_integration.py +++ b/tests/integration_tests/test_integration.py @@ -125,6 +125,15 @@ async def test_button_state_created(self, hass: HomeAssistant) -> None: state = hass.states.get("button.task_tracker_water_plants_mark_as_done") assert state is not None + async def test_times_completed_sensor_created(self, hass: HomeAssistant) -> None: + entry = _make_entry() + await _setup_entry(hass, entry) + + state = hass.states.get("sensor.task_tracker_water_plants_times_completed") + assert state is not None + assert state.state == "0" + assert state.attributes["state_class"] == "total_increasing" + async def test_sensor_initial_state_is_due(self, hass: HomeAssistant) -> None: """With last_done at the epoch the task is always overdue → state is 'due'.""" entry = _make_entry() @@ -218,6 +227,21 @@ async def test_last_done_attribute_updated_to_today(self, hass: HomeAssistant) - state = hass.states.get("sensor.task_tracker_water_plants") assert state.attributes["last_done"] == str(dt_util.now().date()) + async def test_service_increments_times_completed(self, hass: HomeAssistant) -> None: + entry = _make_entry() + await _setup_entry(hass, entry) + + await hass.services.async_call( + DOMAIN, + SERVICE_MARK_AS_DONE, + {"entity_id": "sensor.task_tracker_water_plants"}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.task_tracker_water_plants_times_completed") + assert state.state == "1" + # --------------------------------------------------------------------------- # set_last_done_date service @@ -266,6 +290,21 @@ async def test_service_recalculates_due_date(self, hass: HomeAssistant) -> None: state = hass.states.get("sensor.task_tracker_water_plants") assert state.attributes["due_date"] == "2024-06-22" + async def test_service_does_not_increment_times_completed(self, hass: HomeAssistant) -> None: + entry = _make_entry() + await _setup_entry(hass, entry) + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_LAST_DONE_DATE, + {"entity_id": "sensor.task_tracker_water_plants", "date": date(2024, 6, 15)}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.task_tracker_water_plants_times_completed") + assert state.state == "0" + # --------------------------------------------------------------------------- # Button press @@ -306,6 +345,21 @@ async def test_button_press_updates_coordinator_last_done(self, hass: HomeAssist coordinator = hass.data[DOMAIN]["e1"] assert coordinator.last_done == dt_util.now().date() + async def test_button_press_increments_times_completed(self, hass: HomeAssistant) -> None: + entry = _make_entry() + await _setup_entry(hass, entry) + + await hass.services.async_call( + "button", + "press", + {"entity_id": "button.task_tracker_water_plants_mark_as_done"}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.task_tracker_water_plants_times_completed") + assert state.state == "1" + # --------------------------------------------------------------------------- # Inactive task diff --git a/tests/unit_tests/homeassistant_mock/homeassistant/components/sensor.py b/tests/unit_tests/homeassistant_mock/homeassistant/components/sensor.py index 4d5d8c5..33ebbae 100644 --- a/tests/unit_tests/homeassistant_mock/homeassistant/components/sensor.py +++ b/tests/unit_tests/homeassistant_mock/homeassistant/components/sensor.py @@ -29,3 +29,7 @@ async def async_get_last_sensor_data(self): async def async_get_last_state(self): return None + + +class SensorStateClass: + TOTAL_INCREASING = "total_increasing" From 97672dd88e1807fe1c6a4180f1071818e68201b0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:53:32 +0000 Subject: [PATCH 3/4] feat: localize times completed sensor name Co-authored-by: gensyn <36128035+gensyn@users.noreply.github.com> --- sensor.py | 3 ++- strings.json | 3 +++ tests/unit_tests/test_sensor.py | 10 +++++++++- translations/de.json | 3 +++ translations/en.json | 3 +++ translations/fr.json | 3 +++ 6 files changed, 23 insertions(+), 2 deletions(-) diff --git a/sensor.py b/sensor.py index e4733e7..36133ba 100644 --- a/sensor.py +++ b/sensor.py @@ -448,7 +448,8 @@ class TaskTrackerTimesCompletedSensor(RestoreSensor, SensorEntity): _attr_has_entity_name = True _attr_should_poll = False - _attr_name = "Times completed" + _attr_name = None + _attr_translation_key = "times_completed" _attr_state_class = SensorStateClass.TOTAL_INCREASING def __init__(self, coordinator: TaskTrackerCoordinator, entry_name: str, entry_id: str, hass: HomeAssistant) -> None: diff --git a/strings.json b/strings.json index c885349..9fdccc3 100644 --- a/strings.json +++ b/strings.json @@ -372,6 +372,9 @@ }, "entity": { "sensor": { + "times_completed": { + "name": "Times completed" + }, "status": { "state": { "done": "Done", diff --git a/tests/unit_tests/test_sensor.py b/tests/unit_tests/test_sensor.py index de39d22..8b96274 100644 --- a/tests/unit_tests/test_sensor.py +++ b/tests/unit_tests/test_sensor.py @@ -11,7 +11,7 @@ sys.path.insert(0, absolute_plugin_path) from task_tracker.coordinator import TaskTrackerCoordinator -from task_tracker.sensor import TaskTrackerSensor +from task_tracker.sensor import TaskTrackerSensor, TaskTrackerTimesCompletedSensor from task_tracker.const import ( CONF_DAY, CONF_WEEK, CONF_MONTH, CONF_YEAR, CONST_DUE, CONST_DUE_SOON, CONST_DONE, CONST_INACTIVE, @@ -235,6 +235,14 @@ async def test_sync_called_for_each_todo_list(self): mock_sync.assert_any_call("todo.list2") +class TestTaskTrackerTimesCompletedSensor(unittest.TestCase): + + def test_uses_translation_key_for_name(self): + sensor = TaskTrackerTimesCompletedSensor(make_sensor().coordinator, "My Task", "abc123", MagicMock()) + self.assertIsNone(sensor._attr_name) + self.assertEqual(sensor._attr_translation_key, "times_completed") + + class TestTaskTrackerSensorFilterStateChanges(unittest.TestCase): def _make_event_data(self, entity_id, old_state, new_state): diff --git a/translations/de.json b/translations/de.json index 999649f..4c98b41 100644 --- a/translations/de.json +++ b/translations/de.json @@ -368,6 +368,9 @@ }, "entity": { "sensor": { + "times_completed": { + "name": "Anzahl Abschlüsse" + }, "status": { "state": { "done": "Erledigt", diff --git a/translations/en.json b/translations/en.json index c885349..9fdccc3 100644 --- a/translations/en.json +++ b/translations/en.json @@ -372,6 +372,9 @@ }, "entity": { "sensor": { + "times_completed": { + "name": "Times completed" + }, "status": { "state": { "done": "Done", diff --git a/translations/fr.json b/translations/fr.json index 097811a..1db8bdb 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -371,6 +371,9 @@ }, "entity": { "sensor": { + "times_completed": { + "name": "Nombre d'accomplissements" + }, "status": { "state": { "done": "Fait", From 4b2ba4b9e97019fd6955b717f9b0a21d5ce91722 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:54:50 +0000 Subject: [PATCH 4/4] fix: write restored times completed state Co-authored-by: gensyn <36128035+gensyn@users.noreply.github.com> --- sensor.py | 1 + tests/unit_tests/test_sensor.py | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/sensor.py b/sensor.py index 36133ba..e8a9cc0 100644 --- a/sensor.py +++ b/sensor.py @@ -483,6 +483,7 @@ async def async_added_to_hass(self) -> None: restored_times_completed = 0 self.coordinator.times_completed = max(self.coordinator.times_completed, restored_times_completed) self._attr_native_value = self.coordinator.times_completed + self.async_write_ha_state() async def async_update(self) -> None: """Update sensor state from coordinator.""" diff --git a/tests/unit_tests/test_sensor.py b/tests/unit_tests/test_sensor.py index 8b96274..7197571 100644 --- a/tests/unit_tests/test_sensor.py +++ b/tests/unit_tests/test_sensor.py @@ -235,13 +235,29 @@ async def test_sync_called_for_each_todo_list(self): mock_sync.assert_any_call("todo.list2") -class TestTaskTrackerTimesCompletedSensor(unittest.TestCase): +class TestTaskTrackerTimesCompletedSensor(unittest.IsolatedAsyncioTestCase): def test_uses_translation_key_for_name(self): sensor = TaskTrackerTimesCompletedSensor(make_sensor().coordinator, "My Task", "abc123", MagicMock()) self.assertIsNone(sensor._attr_name) self.assertEqual(sensor._attr_translation_key, "times_completed") + def test_entity_id_uses_times_completed_suffix(self): + sensor = TaskTrackerTimesCompletedSensor(make_sensor().coordinator, "My Task", "abc123", MagicMock()) + self.assertIn("times_completed", sensor.entity_id) + + async def test_async_added_to_hass_writes_restored_state(self): + sensor = TaskTrackerTimesCompletedSensor(make_sensor().coordinator, "My Task", "abc123", MagicMock()) + sensor.async_on_remove = MagicMock() + restored_state = MagicMock(native_value="3") + + with patch.object(sensor, "async_get_last_sensor_data", new_callable=AsyncMock, return_value=restored_state): + with patch.object(sensor, "async_write_ha_state") as mock_write_ha_state: + await sensor.async_added_to_hass() + + self.assertEqual(sensor._attr_native_value, 3) + mock_write_ha_state.assert_called_once() + class TestTaskTrackerSensorFilterStateChanges(unittest.TestCase):