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 {
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"
},