diff --git a/coordinator.py b/coordinator.py index f13bb04..3adf303 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]: @@ -135,7 +136,12 @@ async def async_mark_as_done(self, today: date) -> None: else: self.last_done = self._find_most_recent_occurrence(today) else: + if self.last_done == today: + # Already marked done today; nothing changed, so skip + # incrementing the counter and notifying listeners. + return 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/frontend/task-tracker-card.js b/frontend/task-tracker-card.js index 908cade..89c8474 100644 --- a/frontend/task-tracker-card.js +++ b/frontend/task-tracker-card.js @@ -34,12 +34,16 @@ class TaskTracker extends HTMLElement { const entityEntry = hass.entities && hass.entities[this.config?.entity]; const deviceEntry = entityEntry && entityEntry.device_id && hass.devices && hass.devices[entityEntry.device_id]; + const tcEntityId = this.config?.entity && (this.config.entity + "_times_completed"); + const tcEntity = tcEntityId ? hass.states[tcEntityId] : undefined; if (entity === this._entity && entityEntry === this._entityEntry && - deviceEntry === this._deviceEntry) return; + deviceEntry === this._deviceEntry && + tcEntity === this._tcEntity) return; this._entity = entity; this._entityEntry = entityEntry; this._deviceEntry = deviceEntry; + this._tcEntity = tcEntity; this._render(); } @@ -48,10 +52,11 @@ class TaskTracker extends HTMLElement { throw new Error("You need to define an entity"); } this.config = config; - // show_area, show_tags, show_labels default to false when omitted - this._showArea = config.show_area === true; - this._showTags = config.show_tags === true; - this._showLabels = config.show_labels === true; + // show_area, show_tags, show_labels, show_times_completed default to false when omitted + this._showArea = config.show_area === true; + this._showTags = config.show_tags === true; + this._showLabels = config.show_labels === true; + this._showTimesCompleted = config.show_times_completed === true; } _stateColor(state) { @@ -70,6 +75,24 @@ class TaskTracker extends HTMLElement { return `,\u00a0${this._t("every")}\u00a0${n}\u00a0${this._t(`month_${sp}`)}`; } + _todayInHassTimeZone() { + const now = new Date(); + const timeZone = this._hass && this._hass.config && this._hass.config.time_zone; + try { + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone: timeZone || undefined, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).formatToParts(now); + const year = parts.find((p) => p.type === "year")?.value; + const month = parts.find((p) => p.type === "month")?.value; + const day = parts.find((p) => p.type === "day")?.value; + if (year && month && day) return `${year}-${month}-${day}`; + } catch (_err) {} + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; + } + _scheduleStr(attrs) { const repeatMode = attrs.repeat_mode; if (repeatMode !== "repeat_every") { @@ -144,8 +167,11 @@ class TaskTracker extends HTMLElement { dueValue = `${attrs.overdue_by}\u00a0${this._t(`day_${sp}`)}`; } - // "Mark as done" is a no-op for repeat_every tasks that are already done. - const showMarkDone = !(attrs.repeat_mode === "repeat_every" && stateStr === "done"); + const todayStr = this._todayInHassTimeZone(); + const completedTodayRepeatAfter = attrs.repeat_mode === "repeat_after" && attrs.last_done === todayStr; + // "Mark as done" is a no-op for repeat_every tasks that are already done, + // and for repeat_after tasks that were already completed today. + const showMarkDone = !(attrs.repeat_mode === "repeat_every" && stateStr === "done") && !completedTodayRepeatAfter; // --- optional area / tags / labels --- const entityEntry = this._entityEntry; @@ -163,6 +189,13 @@ class TaskTracker extends HTMLElement { const tagsArr = this._showTags ? (attrs.tags || []) : []; + let timesCompletedValue = null; + if (this._showTimesCompleted) { + const tcEntityId = entityId + "_times_completed"; + const tcState = this._hass.states[tcEntityId]; + timesCompletedValue = tcState ? tcState.state : null; + } + let labelItems = []; if (this._showLabels) { const entityLabelIds = (entityEntry && entityEntry.labels) || []; @@ -290,6 +323,11 @@ class TaskTracker extends HTMLElement { ${this._t("labels")} ${labelItems.map((l) => `${this._esc(l.name)}`).join(" ")} ` : ""} + ${timesCompletedValue !== null ? ` + + ${this._t("times_completed") || "Times completed"} + ${this._esc(timesCompletedValue)} + ` : ""} ${showMarkDone ? `
diff --git a/frontend/task-tracker-panel.js b/frontend/task-tracker-panel.js index 0f85ba7..3a90444 100644 --- a/frontend/task-tracker-panel.js +++ b/frontend/task-tracker-panel.js @@ -8,9 +8,10 @@ class TaskTrackerPanel extends HTMLElement { this._sortBy = "name"; this._sortDir = "asc"; this._narrow = false; - this._showArea = localStorage.getItem("tt_panel_show_area") === "1"; - this._showTags = localStorage.getItem("tt_panel_show_tags") === "1"; - this._showLabels = localStorage.getItem("tt_panel_show_labels") === "1"; + this._showArea = localStorage.getItem("tt_panel_show_area") === "1"; + this._showTags = localStorage.getItem("tt_panel_show_tags") === "1"; + this._showLabels = localStorage.getItem("tt_panel_show_labels") === "1"; + this._showTimesCompleted = localStorage.getItem("tt_panel_show_times_completed") === "1"; // Pre-render sort controls immediately so they are present in the // shadow DOM as soon as the element is created, even before HA calls // set hass(). Full render (with live task data) happens once hass @@ -45,8 +46,13 @@ class TaskTrackerPanel extends HTMLElement { } } + _isStatusSensor(entityId) { + return entityId.startsWith("sensor.task_tracker_") && + !entityId.endsWith("_times_completed"); + } + _tasksChanged(oldStates, newStates) { - const isTask = (id) => id.startsWith("sensor.task_tracker_"); + const isTask = (id) => this._isStatusSensor(id); const oldTaskIds = Object.keys(oldStates).filter(isTask); const newTaskIds = Object.keys(newStates).filter(isTask); if (oldTaskIds.length !== newTaskIds.length) return true; @@ -93,7 +99,7 @@ class TaskTrackerPanel extends HTMLElement { _getAllTasks() { return Object.values(this._hass.states) - .filter((entity) => entity.entity_id.startsWith("sensor.task_tracker_")) + .filter((entity) => this._isStatusSensor(entity.entity_id)) .sort((a, b) => { let cmp = 0; if (this._sortBy === "due_date") { @@ -155,6 +161,9 @@ class TaskTrackerPanel extends HTMLElement { } else if (key === "labels") { this._showLabels = !this._showLabels; localStorage.setItem("tt_panel_show_labels", this._showLabels ? "1" : "0"); + } else if (key === "times_completed") { + this._showTimesCompleted = !this._showTimesCompleted; + localStorage.setItem("tt_panel_show_times_completed", this._showTimesCompleted ? "1" : "0"); } this._render(); } @@ -192,6 +201,24 @@ class TaskTrackerPanel extends HTMLElement { return `,\u00a0${this._t("every")}\u00a0${n}\u00a0${this._t(`month_${sp}`)}`; } + _todayInHassTimeZone() { + const now = new Date(); + const timeZone = this._hass && this._hass.config && this._hass.config.time_zone; + try { + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone: timeZone || undefined, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).formatToParts(now); + const year = parts.find((p) => p.type === "year")?.value; + const month = parts.find((p) => p.type === "month")?.value; + const day = parts.find((p) => p.type === "day")?.value; + if (year && month && day) return `${year}-${month}-${day}`; + } catch (_err) {} + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; + } + _scheduleStr(attrs) { const repeatMode = attrs.repeat_mode; if (repeatMode !== "repeat_every") { @@ -251,8 +278,11 @@ class TaskTrackerPanel extends HTMLElement { dueValue = `${attrs.overdue_by}\u00a0${this._t(`day_${sp}`)}`; } - // "Mark as done" is a no-op for repeat_every tasks that are already done. - const showMarkDone = !(attrs.repeat_mode === "repeat_every" && state === "done"); + const todayStr = this._todayInHassTimeZone(); + const completedTodayRepeatAfter = attrs.repeat_mode === "repeat_after" && attrs.last_done === todayStr; + // "Mark as done" is a no-op for repeat_every tasks that are already done, + // and for repeat_after tasks that were already completed today. + const showMarkDone = !(attrs.repeat_mode === "repeat_every" && state === "done") && !completedTodayRepeatAfter; // --- optional area / tags / labels --- const entityEntry = this._hass.entities && this._hass.entities[entity.entity_id]; @@ -271,6 +301,13 @@ class TaskTrackerPanel extends HTMLElement { const tagsArr = this._showTags ? (attrs.tags || []) : []; + let timesCompletedValue = null; + if (this._showTimesCompleted) { + const tcEntityId = entity.entity_id + "_times_completed"; + const tcState = this._hass.states[tcEntityId]; + timesCompletedValue = tcState ? tcState.state : null; + } + let labelItems = []; if (this._showLabels) { const entityLabelIds = (entityEntry && entityEntry.labels) || []; @@ -297,6 +334,7 @@ class TaskTrackerPanel extends HTMLElement { ${areaName ? `${this._t("area")}${this._esc(areaName)}` : ""} ${tagsArr.length ? `${this._t("tags")}${tagsArr.map((t) => `${this._esc(t)}`).join(" ")}` : ""} ${labelItems.length ? `${this._t("labels")}${labelItems.map((l) => `${this._esc(l.name)}`).join(" ")}` : ""} + ${timesCompletedValue !== null ? `${this._t("times_completed") || "Times completed"}${this._esc(timesCompletedValue)}` : ""} ${showMarkDone ? `
@@ -634,6 +672,9 @@ class TaskTrackerPanel extends HTMLElement { +
bool: """ reg = entity_registry.async_get(self.hass) - # Find the entity_id of the sensor for the current config entry. + # Find the entity_id of the status sensor for the current config entry. current_entity_id: str | None = None for e in entity_registry.async_entries_for_config_entry(reg, self.config_entry.entry_id): - if e.entity_id.startswith("sensor."): + if e.entity_id.startswith("sensor.") and not e.entity_id.endswith("_times_completed"): current_entity_id = e.entity_id break @@ -254,11 +254,11 @@ def _has_circular_dependency(self, new_dep_entity_ids: list[str]) -> bool: # setup). No cycle can exist. return False - # Build graph: sensor entity_id → list of dependency entity_ids. + # Build graph: status sensor entity_id → list of dependency entity_ids. graph: dict[str, list[str]] = {} for entry in self.hass.config_entries.async_entries(DOMAIN): for e in entity_registry.async_entries_for_config_entry(reg, entry.entry_id): - if e.entity_id.startswith("sensor."): + if e.entity_id.startswith("sensor.") and not e.entity_id.endswith("_times_completed"): if entry.entry_id == self.config_entry.entry_id: graph[e.entity_id] = new_dep_entity_ids else: @@ -290,6 +290,7 @@ def _validate_dependencies(self, user_input: dict[str, Any]) -> dict[str, str]: for entry in self.hass.config_entries.async_entries(DOMAIN) for e in entity_registry.async_entries_for_config_entry(reg, entry.entry_id) if e.entity_id.startswith("sensor.") + and not e.entity_id.endswith("_times_completed") } if any(dep not in task_tracker_entity_ids for dep in new_deps): return {CONF_DEPENDENCIES: "invalid_dependency"} diff --git a/sensor.py b/sensor.py index 43298c8..ff8a25f 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,49 @@ 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_translation_key = "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 + self.async_write_ha_state() + + async def async_update(self) -> None: + """Update sensor state from coordinator.""" + self._attr_native_value = self.coordinator.times_completed diff --git a/strings.json b/strings.json index c885349..5e541ec 100644 --- a/strings.json +++ b/strings.json @@ -372,6 +372,9 @@ }, "entity": { "sensor": { + "times_completed": { + "name": "Times completed" + }, "status": { "state": { "done": "Done", @@ -445,6 +448,9 @@ "overdue_by": { "name": "Overdue by" }, + "times_completed": { + "name": "Times completed" + }, "mark_as_done": { "name": "Mark as done" }, 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" diff --git a/tests/unit_tests/test_sensor.py b/tests/unit_tests/test_sensor.py index de39d22..7197571 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,30 @@ async def test_sync_called_for_each_todo_list(self): mock_sync.assert_any_call("todo.list2") +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): def _make_event_data(self, entity_id, old_state, new_state): diff --git a/translations/de.json b/translations/de.json index 999649f..dd327db 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", @@ -441,6 +444,9 @@ "overdue_by": { "name": "Überfällig seit" }, + "times_completed": { + "name": "Anzahl Abschlüsse" + }, "mark_as_done": { "name": "Als erledigt markieren" }, diff --git a/translations/en.json b/translations/en.json index c885349..5e541ec 100644 --- a/translations/en.json +++ b/translations/en.json @@ -372,6 +372,9 @@ }, "entity": { "sensor": { + "times_completed": { + "name": "Times completed" + }, "status": { "state": { "done": "Done", @@ -445,6 +448,9 @@ "overdue_by": { "name": "Overdue by" }, + "times_completed": { + "name": "Times completed" + }, "mark_as_done": { "name": "Mark as done" }, diff --git a/translations/fr.json b/translations/fr.json index 097811a..c234f26 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", @@ -444,6 +447,9 @@ "overdue_by": { "name": "En retard de" }, + "times_completed": { + "name": "Nombre d'accomplissements" + }, "mark_as_done": { "name": "Marquer comme fait" },