From 9cb884b6af009fce19d3fce5d5c34ad879b83039 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:45:52 +0000 Subject: [PATCH 1/3] Remove mode selector from repeat_after options flow Agent-Logs-Url: https://github.com/gensyn/task_tracker/sessions/7981acca-d34f-4728-be0d-807d248c0e4c Co-authored-by: gensyn <36128035+gensyn@users.noreply.github.com> --- options_flow.py | 25 +++++----------- tests/unit_tests/test_options_flow.py | 42 ++++++++++++--------------- 2 files changed, 26 insertions(+), 41 deletions(-) diff --git a/options_flow.py b/options_flow.py index b212dc9..30dda48 100644 --- a/options_flow.py +++ b/options_flow.py @@ -21,7 +21,8 @@ _WEEKDAYS = [CONF_MONDAY, CONF_TUESDAY, CONF_WEDNESDAY, CONF_THURSDAY, CONF_FRIDAY, CONF_SATURDAY, CONF_SUNDAY] _NTH_OCCURRENCES = ["1", "2", "3", "4", "last"] -# Step 1 (init) – common task settings that apply regardless of repeat mode +# Step 1 (init) – common task settings for repeat_after mode (no mode selector; +# changing the mode of an existing task is not supported). _STEP_INIT_SCHEMA = vol.Schema( { vol.Optional(CONF_ACTIVE): bool, @@ -30,13 +31,6 @@ "domain": "input_boolean", } }), - vol.Required(CONF_REPEAT_MODE, default=CONF_REPEAT_AFTER): selector({ - CONF_SELECT: { - CONF_OPTIONS: [CONF_REPEAT_AFTER, CONF_REPEAT_EVERY], - CONF_MODE: CONF_DROPDOWN, - "translation_key": "repeat_mode", - } - }), vol.Optional(CONF_ICON, default="mdi:calendar-question"): str, vol.Optional(CONF_TAGS): str, vol.Optional(CONF_TODO_LISTS): selector({ @@ -244,16 +238,14 @@ def __init__(self) -> None: async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Step 1 – common settings and repeat mode selection. + """Step 1 – common settings for repeat_after tasks. For ``repeat_every`` entries the init form is skipped entirely: the flow jumps directly to the mode-specific combined options step, which shows both the mode-specific fields and the common fields in one page. - The repeat mode itself is therefore not user-editable in options for - ``repeat_every`` tasks. - For ``repeat_after`` entries the existing two-step flow - (init → repeat_after) is kept exactly as it was. + For ``repeat_after`` entries the mode is fixed (changing it is not + supported) and the two-step flow (init → repeat_after) is used. """ # For repeat_every entries: skip the init form entirely and go straight # to the combined mode-specific step. @@ -270,7 +262,9 @@ async def async_step_init( return await self.async_step_options_repeat_every_days_before_end_of_month() return await self.async_step_options_repeat_every_weekday() - # repeat_after: existing flow unchanged. + # repeat_after: show common settings form (mode is fixed; changing it is + # not supported). + self._accumulated_options[CONF_REPEAT_MODE] = CONF_REPEAT_AFTER if user_input is None: return self.async_show_form( step_id="init", @@ -280,9 +274,6 @@ async def async_step_init( ) self._accumulated_options.update(user_input) - - if user_input[CONF_REPEAT_MODE] == CONF_REPEAT_EVERY: - return await self.async_step_repeat_every() return await self.async_step_repeat_after() async def async_step_repeat_after( diff --git a/tests/unit_tests/test_options_flow.py b/tests/unit_tests/test_options_flow.py index 207b286..2152dca 100644 --- a/tests/unit_tests/test_options_flow.py +++ b/tests/unit_tests/test_options_flow.py @@ -146,10 +146,9 @@ class TestOptionsFlowRepeatAfterUnchanged(unittest.IsolatedAsyncioTestCase): async def _start(self, **extra_options): flow = _make_flow(_make_repeat_after_entry(**extra_options)) - # Submit the init form with repeat_after mode + # Submit the init form (no mode selector; mode is fixed as repeat_after) await flow.async_step_init(user_input={ CONF_ACTIVE: True, - CONF_REPEAT_MODE: CONF_REPEAT_AFTER, CONF_ICON: "mdi:calendar", CONF_TAGS: "", CONF_TODO_LISTS: [], @@ -162,7 +161,6 @@ async def test_init_routes_to_repeat_after_step(self): flow = _make_flow(_make_repeat_after_entry()) result = await flow.async_step_init(user_input={ CONF_ACTIVE: True, - CONF_REPEAT_MODE: CONF_REPEAT_AFTER, CONF_ICON: "mdi:calendar", CONF_TAGS: "", CONF_TODO_LISTS: [], @@ -191,25 +189,23 @@ async def test_repeat_after_shows_form_on_no_input(self): # --------------------------------------------------------------------------- -# Tests: validation in non-combined options steps (switching from repeat_after -# to repeat_every in the options flow) +# Tests: validation in non-combined options steps (repeat_every sub-type +# validation in the options flow) # --------------------------------------------------------------------------- class TestOptionsFlowRepeatEveryDayOfMonthValidation(unittest.IsolatedAsyncioTestCase): """Validation tests for async_step_repeat_every_day_of_month in options flow.""" async def _start(self): - """Submit init with repeat_every mode from a repeat_after entry.""" + """Set up a flow with repeat_every/day_of_month accumulated options.""" flow = _make_flow(_make_repeat_after_entry()) - await flow.async_step_init(user_input={ - CONF_ACTIVE: True, - CONF_REPEAT_MODE: CONF_REPEAT_EVERY, - CONF_ICON: "mdi:calendar", - CONF_TAGS: "", - CONF_TODO_LISTS: [], - CONF_DUE_SOON_DAYS: 0, - CONF_NOTIFICATION_INTERVAL: 1, - }) + flow._accumulated_options[CONF_ACTIVE] = True + flow._accumulated_options[CONF_REPEAT_MODE] = CONF_REPEAT_EVERY + flow._accumulated_options[CONF_ICON] = "mdi:calendar" + flow._accumulated_options[CONF_TAGS] = "" + flow._accumulated_options[CONF_TODO_LISTS] = [] + flow._accumulated_options[CONF_DUE_SOON_DAYS] = 0 + flow._accumulated_options[CONF_NOTIFICATION_INTERVAL] = 1 await flow.async_step_repeat_every( user_input={CONF_REPEAT_EVERY_TYPE: CONF_REPEAT_EVERY_DAY_OF_MONTH} ) @@ -237,15 +233,13 @@ class TestOptionsFlowRepeatEveryDaysBeforeEndValidation(unittest.IsolatedAsyncio async def _start(self): flow = _make_flow(_make_repeat_after_entry()) - await flow.async_step_init(user_input={ - CONF_ACTIVE: True, - CONF_REPEAT_MODE: CONF_REPEAT_EVERY, - CONF_ICON: "mdi:calendar", - CONF_TAGS: "", - CONF_TODO_LISTS: [], - CONF_DUE_SOON_DAYS: 0, - CONF_NOTIFICATION_INTERVAL: 1, - }) + flow._accumulated_options[CONF_ACTIVE] = True + flow._accumulated_options[CONF_REPEAT_MODE] = CONF_REPEAT_EVERY + flow._accumulated_options[CONF_ICON] = "mdi:calendar" + flow._accumulated_options[CONF_TAGS] = "" + flow._accumulated_options[CONF_TODO_LISTS] = [] + flow._accumulated_options[CONF_DUE_SOON_DAYS] = 0 + flow._accumulated_options[CONF_NOTIFICATION_INTERVAL] = 1 await flow.async_step_repeat_every( user_input={CONF_REPEAT_EVERY_TYPE: CONF_REPEAT_EVERY_DAYS_BEFORE_END_OF_MONTH} ) From a8e0a43dc8f6f694186e14e1b3518056ff7b5a48 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 14:04:24 +0000 Subject: [PATCH 2/3] Merge repeat_after options into single combined step (options_repeat_after) Agent-Logs-Url: https://github.com/gensyn/task_tracker/sessions/33b7900a-4c9c-4638-bb3e-3db6201848c2 Co-authored-by: gensyn <36128035+gensyn@users.noreply.github.com> --- options_flow.py | 101 ++++++++------------------ strings.json | 72 ++++++++---------- tests/unit_tests/test_options_flow.py | 62 ++++++++-------- translations/de.json | 72 ++++++++---------- translations/en.json | 72 ++++++++---------- 5 files changed, 154 insertions(+), 225 deletions(-) diff --git a/options_flow.py b/options_flow.py index 30dda48..a3a2812 100644 --- a/options_flow.py +++ b/options_flow.py @@ -21,52 +21,7 @@ _WEEKDAYS = [CONF_MONDAY, CONF_TUESDAY, CONF_WEDNESDAY, CONF_THURSDAY, CONF_FRIDAY, CONF_SATURDAY, CONF_SUNDAY] _NTH_OCCURRENCES = ["1", "2", "3", "4", "last"] -# Step 1 (init) – common task settings for repeat_after mode (no mode selector; -# changing the mode of an existing task is not supported). -_STEP_INIT_SCHEMA = vol.Schema( - { - vol.Optional(CONF_ACTIVE): bool, - vol.Optional(CONF_ACTIVE_OVERRIDE): selector({ - "entity": { - "domain": "input_boolean", - } - }), - vol.Optional(CONF_ICON, default="mdi:calendar-question"): str, - vol.Optional(CONF_TAGS): str, - vol.Optional(CONF_TODO_LISTS): selector({ - "entity": { - "domain": "todo", - "multiple": True, - } - }), - vol.Optional(CONF_DUE_SOON_DAYS, default=0): int, - vol.Optional(CONF_DUE_SOON_OVERRIDE): selector({ - "entity": { - "domain": "input_number", - } - }), - vol.Optional(CONF_NOTIFICATION_INTERVAL, default=1): int, - } -) -# Step 2a – repeat_after interval settings -_STEP_REPEAT_AFTER_SCHEMA = vol.Schema( - { - vol.Required(CONF_TASK_INTERVAL_VALUE, default=7): int, - vol.Required(CONF_TASK_INTERVAL_TYPE): selector({ - CONF_SELECT: { - CONF_OPTIONS: [CONF_DAY, CONF_WEEK, CONF_MONTH, CONF_YEAR], - CONF_MODE: CONF_DROPDOWN, - "translation_key": "task_interval", - } - }), - vol.Optional(CONF_TASK_INTERVAL_OVERRIDE): selector({ - "entity": { - "domain": "input_number", - } - }), - } -) # Step 2b – repeat_every sub-type selection _STEP_REPEAT_EVERY_SCHEMA = vol.Schema( @@ -135,7 +90,7 @@ ) # --------------------------------------------------------------------------- -# Common optional fields shared by all combined options steps for repeat_every. +# Common optional fields shared by all combined options steps. # Split into "head" (active/active_override – always shown first) and "tail" # (icon, tags, … – shown after the mode-specific fields). # --------------------------------------------------------------------------- @@ -166,6 +121,26 @@ vol.Optional(CONF_NOTIFICATION_INTERVAL, default=1): int, } +# Combined options steps for repeat_after mode. +# Field order: active / active_override → interval fields → remaining common fields. +_STEP_OPTIONS_REPEAT_AFTER_SCHEMA = vol.Schema({ + **_REPEAT_EVERY_HEAD_OPTIONS, + vol.Required(CONF_TASK_INTERVAL_VALUE, default=7): int, + vol.Required(CONF_TASK_INTERVAL_TYPE): selector({ + CONF_SELECT: { + CONF_OPTIONS: [CONF_DAY, CONF_WEEK, CONF_MONTH, CONF_YEAR], + CONF_MODE: CONF_DROPDOWN, + "translation_key": "task_interval", + } + }), + vol.Optional(CONF_TASK_INTERVAL_OVERRIDE): selector({ + "entity": { + "domain": "input_number", + } + }), + **_REPEAT_EVERY_TAIL_OPTIONS, +}) + # Combined options steps for repeat_every modes. # Field order: active / active_override → mode-specific fields → remaining common fields. _STEP_OPTIONS_REPEAT_EVERY_WEEKDAY_SCHEMA = vol.Schema({ @@ -238,14 +213,12 @@ def __init__(self) -> None: async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Step 1 – common settings for repeat_after tasks. - - For ``repeat_every`` entries the init form is skipped entirely: the - flow jumps directly to the mode-specific combined options step, which - shows both the mode-specific fields and the common fields in one page. + """Step 1 – options entry point; routes to the appropriate combined step. - For ``repeat_after`` entries the mode is fixed (changing it is not - supported) and the two-step flow (init → repeat_after) is used. + For all modes the init form is skipped entirely: the flow jumps directly + to the mode-specific combined options step, which shows both the + mode-specific fields and the common fields in one page. The repeat mode + (and, for ``repeat_every`` tasks, the sub-type) are preserved as-is. """ # For repeat_every entries: skip the init form entirely and go straight # to the combined mode-specific step. @@ -262,29 +235,19 @@ async def async_step_init( return await self.async_step_options_repeat_every_days_before_end_of_month() return await self.async_step_options_repeat_every_weekday() - # repeat_after: show common settings form (mode is fixed; changing it is - # not supported). + # repeat_after: skip the init form, go straight to the combined step. self._accumulated_options[CONF_REPEAT_MODE] = CONF_REPEAT_AFTER - if user_input is None: - return self.async_show_form( - step_id="init", - data_schema=self.add_suggested_values_to_schema( - _STEP_INIT_SCHEMA, self.config_entry.options - ), - ) - - self._accumulated_options.update(user_input) - return await self.async_step_repeat_after() + return await self.async_step_options_repeat_after() - async def async_step_repeat_after( + async def async_step_options_repeat_after( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Step 2a – interval settings for repeat_after mode.""" + """Combined options step for repeat_after mode.""" if user_input is None: return self.async_show_form( - step_id="repeat_after", + step_id="options_repeat_after", data_schema=self.add_suggested_values_to_schema( - _STEP_REPEAT_AFTER_SCHEMA, self.config_entry.options + _STEP_OPTIONS_REPEAT_AFTER_SCHEMA, self.config_entry.options ), ) diff --git a/strings.json b/strings.json index 888bf03..f0c4069 100644 --- a/strings.json +++ b/strings.json @@ -98,46 +98,6 @@ "invalid_days_before_end": "Days before month end must be between 0 and 30" }, "step": { - "init": { - "title": "Task Tracker", - "description": "Configure task.", - "data": { - "repeat_mode": "Repeat Mode", - "notification_interval": "Notification Interval (days)", - "todo_lists": "Todo-Lists", - "due_soon_days": "Due soon (days)", - "due_soon_override": "Due soon Override (days)", - "tags": "Tags", - "icon": "Icon", - "active": "Active", - "active_override": "Active Override" - }, - "data_description": { - "repeat_mode": "How the next due date is calculated when the task is completed. 'Repeat after completion' sets the next due date relative to when the task was completed. 'Repeat every (fixed schedule)' keeps the task on its original schedule regardless of when it was completed.", - "notification_interval": "The time in days after which notifications are sent again.", - "todo_lists": "Todo-Lists, to which this task will be added.", - "due_soon_days": "The number of days before the task is due to show it as due soon.", - "due_soon_override": "Select an input_number (value in days) to override the Due soon threshold.", - "tags": "Tags to categorize the task, separated by commas.", - "icon": "Icon to represent the task.", - "active": "Enable or disable the task.", - "active_override": "Select an input_boolean to override the Active setting." - } - }, - "repeat_after": { - "title": "Repeat After Completion", - "description": "The task becomes due again after a fixed interval from the last completion.", - "data": { - "task_interval_value": "Task Interval", - "task_interval_type": "Task Interval Unit", - "task_interval_override": "Task Interval Override" - }, - "data_description": { - "task_interval_value": "Time after which the task becomes due again.", - "task_interval_type": "The unit of time for the task interval.", - "task_interval_override": "Select an input_number (value in days) to override the Task Interval." - } - }, "repeat_every": { "title": "Repeat on a Fixed Schedule", "description": "Choose the type of schedule for this task.", @@ -299,6 +259,36 @@ "due_soon_override": "Select an input_number (value in days) to override the Due soon threshold.", "notification_interval": "The time in days after which notifications are sent again." } + }, + "options_repeat_after": { + "title": "Repeat After Completion – Options", + "description": "Update the interval and task settings.", + "data": { + "active": "Active", + "active_override": "Active Override", + "task_interval_value": "Task Interval", + "task_interval_type": "Task Interval Unit", + "task_interval_override": "Task Interval Override", + "icon": "Icon", + "tags": "Tags", + "todo_lists": "Todo-Lists", + "due_soon_days": "Due soon (days)", + "due_soon_override": "Due soon Override (days)", + "notification_interval": "Notification Interval (days)" + }, + "data_description": { + "active": "Enable or disable the task.", + "active_override": "Select an input_boolean to override the Active setting.", + "task_interval_value": "Time after which the task becomes due again.", + "task_interval_type": "The unit of time for the task interval.", + "task_interval_override": "Select an input_number (value in days) to override the Task Interval.", + "icon": "Icon to represent the task.", + "tags": "Tags to categorize the task, separated by commas.", + "todo_lists": "Todo-Lists to which this task will be added.", + "due_soon_days": "The number of days before the task is due to show it as due soon.", + "due_soon_override": "Select an input_number (value in days) to override the Due soon threshold.", + "notification_interval": "The time in days after which notifications are sent again." + } } } }, @@ -527,4 +517,4 @@ } } } -} \ No newline at end of file +} diff --git a/tests/unit_tests/test_options_flow.py b/tests/unit_tests/test_options_flow.py index 2152dca..4e7d5d4 100644 --- a/tests/unit_tests/test_options_flow.py +++ b/tests/unit_tests/test_options_flow.py @@ -84,12 +84,12 @@ def _make_flow(config_entry: ConfigEntry) -> TaskTrackerOptionsFlow: class TestOptionsFlowInitRouting(unittest.IsolatedAsyncioTestCase): """async_step_init should branch on the stored repeat_mode.""" - async def test_repeat_after_shows_init_form(self): - """For repeat_after entries the existing init form is shown.""" + async def test_repeat_after_routes_to_options_repeat_after(self): + """For repeat_after entries the combined options_repeat_after form is shown.""" flow = _make_flow(_make_repeat_after_entry()) result = await flow.async_step_init(user_input=None) self.assertEqual(result["type"], "form") - self.assertEqual(result["step_id"], "init") + self.assertEqual(result["step_id"], "options_repeat_after") async def test_repeat_every_weekday_skips_init_form(self): """For repeat_every_weekday entries the init form is bypassed.""" @@ -138,41 +138,37 @@ async def test_repeat_mode_and_type_pre_seeded_for_day_of_month(self): # --------------------------------------------------------------------------- -# Tests: repeat_after options flow unchanged +# Tests: repeat_after combined options step # --------------------------------------------------------------------------- -class TestOptionsFlowRepeatAfterUnchanged(unittest.IsolatedAsyncioTestCase): - """The repeat_after options flow must be exactly preserved.""" +class TestOptionsFlowRepeatAfterCombined(unittest.IsolatedAsyncioTestCase): + """The repeat_after options flow uses a single combined step (options_repeat_after).""" - async def _start(self, **extra_options): - flow = _make_flow(_make_repeat_after_entry(**extra_options)) - # Submit the init form (no mode selector; mode is fixed as repeat_after) - await flow.async_step_init(user_input={ - CONF_ACTIVE: True, - CONF_ICON: "mdi:calendar", - CONF_TAGS: "", - CONF_TODO_LISTS: [], - CONF_DUE_SOON_DAYS: 0, - CONF_NOTIFICATION_INTERVAL: 1, - }) - return flow + def _make_flow(self): + return _make_flow(_make_repeat_after_entry()) - async def test_init_routes_to_repeat_after_step(self): - flow = _make_flow(_make_repeat_after_entry()) - result = await flow.async_step_init(user_input={ + async def test_init_routes_to_options_repeat_after(self): + flow = self._make_flow() + result = await flow.async_step_init(user_input=None) + self.assertEqual(result["type"], "form") + self.assertEqual(result["step_id"], "options_repeat_after") + + async def test_shows_form_on_no_input(self): + flow = self._make_flow() + result = await flow.async_step_options_repeat_after(user_input=None) + self.assertEqual(result["type"], "form") + self.assertEqual(result["step_id"], "options_repeat_after") + + async def test_creates_entry_with_all_fields(self): + flow = self._make_flow() + flow._accumulated_options[CONF_REPEAT_MODE] = CONF_REPEAT_AFTER + result = await flow.async_step_options_repeat_after(user_input={ CONF_ACTIVE: True, CONF_ICON: "mdi:calendar", CONF_TAGS: "", CONF_TODO_LISTS: [], CONF_DUE_SOON_DAYS: 0, CONF_NOTIFICATION_INTERVAL: 1, - }) - self.assertEqual(result["type"], "form") - self.assertEqual(result["step_id"], "repeat_after") - - async def test_repeat_after_creates_entry(self): - flow = await self._start() - result = await flow.async_step_repeat_after(user_input={ CONF_TASK_INTERVAL_VALUE: 14, CONF_TASK_INTERVAL_TYPE: CONF_DAY, }) @@ -181,11 +177,11 @@ async def test_repeat_after_creates_entry(self): self.assertEqual(opts[CONF_TASK_INTERVAL_VALUE], 14) self.assertEqual(opts[CONF_REPEAT_MODE], CONF_REPEAT_AFTER) - async def test_repeat_after_shows_form_on_no_input(self): - flow = await self._start() - result = await flow.async_step_repeat_after(user_input=None) - self.assertEqual(result["type"], "form") - self.assertEqual(result["step_id"], "repeat_after") + async def test_repeat_mode_pre_seeded_from_init(self): + """init pre-seeds CONF_REPEAT_MODE so validate_options sees it.""" + flow = self._make_flow() + await flow.async_step_init(user_input=None) + self.assertEqual(flow._accumulated_options[CONF_REPEAT_MODE], CONF_REPEAT_AFTER) # --------------------------------------------------------------------------- diff --git a/translations/de.json b/translations/de.json index f841228..a8f735b 100644 --- a/translations/de.json +++ b/translations/de.json @@ -95,46 +95,6 @@ "invalid_days_before_end": "Die Anzahl der Tage vor Monatsende muss zwischen 0 und 30 liegen" }, "step": { - "init": { - "title": "Task Tracker", - "description": "Task bearbeiten.", - "data": { - "repeat_mode": "Wiederholungsmodus", - "notification_interval": "Benachrichtigungsintervall (Tage)", - "todo_lists": "Todo-Listen", - "due_soon_days": "Bald fällig (Tage)", - "due_soon_override": "Bald fällig überschreiben (Tage)", - "tags": "Tags", - "icon": "Icon", - "active": "Aktiv", - "active_override": "Aktiv überschreiben" - }, - "data_description": { - "repeat_mode": "Wie das nächste Fälligkeitsdatum berechnet wird, wenn die Task abgeschlossen wurde. 'Nach Abschluss wiederholen' berechnet das nächste Fälligkeitsdatum relativ zum Abschlussdatum. 'Regelmäßig wiederholen (fester Zeitplan)' hält die Task im ursprünglichen Zeitplan unabhängig davon, wann sie abgeschlossen wurde.", - "notification_interval": "Die Zeit in Tagen, nach dem Benachrichtigungen erneut gesendet werden.", - "todo_lists": "Die Todo-Listen, zu denen diese Task hinzugefügt wird.", - "due_soon_days": "Die Anzahl der Tage vor Fälligkeit der Task, um sie als bald fällig anzuzeigen.", - "due_soon_override": "input_number (Wert in Tagen) zur Überschreibung der Bald-fällig-Schwelle auswählen.", - "tags": "Tags zur Kategorisierung der Task, getrennt durch Kommas.", - "icon": "Icon zur Darstellung der Task.", - "active": "Task aktivieren oder deaktivieren.", - "active_override": "input_boolean zur Überschreibung des Aktiv-Status auswählen." - } - }, - "repeat_after": { - "title": "Nach Abschluss wiederholen", - "description": "Die Task wird nach einem festen Intervall seit der letzten Erledigung wieder fällig.", - "data": { - "task_interval_value": "Taskintervall", - "task_interval_type": "Einheit des Taskintervalls", - "task_interval_override": "Taskintervall überschreiben" - }, - "data_description": { - "task_interval_value": "Die Zeit, nach der die Task wieder fällig wird.", - "task_interval_type": "Die Zeiteinheit für das Taskintervall.", - "task_interval_override": "input_number (Wert in Tagen) zur Überschreibung des Taskintervalls auswählen." - } - }, "repeat_every": { "title": "Regelmäßig wiederholen (fester Zeitplan)", "description": "Wählen Sie den Zeitplantyp für diese Task.", @@ -296,6 +256,36 @@ "due_soon_override": "Wähle ein input_number (Wert in Tagen), um den 'Bald fällig'-Schwellenwert zu überschreiben.", "notification_interval": "Zeitintervall in Tagen, nach dem Benachrichtigungen erneut gesendet werden." } + }, + "options_repeat_after": { + "title": "Nach Abschluss wiederholen – Optionen", + "description": "Intervall und Taskeinstellungen aktualisieren.", + "data": { + "active": "Aktiv", + "active_override": "Aktiv-Override", + "task_interval_value": "Taskintervall", + "task_interval_type": "Einheit des Taskintervalls", + "task_interval_override": "Taskintervall überschreiben", + "icon": "Symbol", + "tags": "Tags", + "todo_lists": "Todo-Listen", + "due_soon_days": "Bald fällig (Tage)", + "due_soon_override": "Bald-fällig-Override (Tage)", + "notification_interval": "Benachrichtigungsintervall (Tage)" + }, + "data_description": { + "active": "Aktiviert oder deaktiviert die Task.", + "active_override": "Wähle ein input_boolean, um die Aktiv-Einstellung zu überschreiben.", + "task_interval_value": "Die Zeit, nach der die Task wieder fällig wird.", + "task_interval_type": "Die Zeiteinheit für das Taskintervall.", + "task_interval_override": "input_number (Wert in Tagen) zur Überschreibung des Taskintervalls auswählen.", + "icon": "Symbol für die Task.", + "tags": "Tags zur Kategorisierung der Task, durch Komma getrennt.", + "todo_lists": "Todo-Listen, zu denen diese Task hinzugefügt wird.", + "due_soon_days": "Anzahl der Tage vor Fälligkeit, ab denen die Task als 'bald fällig' gilt.", + "due_soon_override": "Wähle ein input_number (Wert in Tagen), um den 'Bald fällig'-Schwellenwert zu überschreiben.", + "notification_interval": "Zeitintervall in Tagen, nach dem Benachrichtigungen erneut gesendet werden." + } } } }, @@ -524,4 +514,4 @@ } } } -} \ No newline at end of file +} diff --git a/translations/en.json b/translations/en.json index 888bf03..f0c4069 100644 --- a/translations/en.json +++ b/translations/en.json @@ -98,46 +98,6 @@ "invalid_days_before_end": "Days before month end must be between 0 and 30" }, "step": { - "init": { - "title": "Task Tracker", - "description": "Configure task.", - "data": { - "repeat_mode": "Repeat Mode", - "notification_interval": "Notification Interval (days)", - "todo_lists": "Todo-Lists", - "due_soon_days": "Due soon (days)", - "due_soon_override": "Due soon Override (days)", - "tags": "Tags", - "icon": "Icon", - "active": "Active", - "active_override": "Active Override" - }, - "data_description": { - "repeat_mode": "How the next due date is calculated when the task is completed. 'Repeat after completion' sets the next due date relative to when the task was completed. 'Repeat every (fixed schedule)' keeps the task on its original schedule regardless of when it was completed.", - "notification_interval": "The time in days after which notifications are sent again.", - "todo_lists": "Todo-Lists, to which this task will be added.", - "due_soon_days": "The number of days before the task is due to show it as due soon.", - "due_soon_override": "Select an input_number (value in days) to override the Due soon threshold.", - "tags": "Tags to categorize the task, separated by commas.", - "icon": "Icon to represent the task.", - "active": "Enable or disable the task.", - "active_override": "Select an input_boolean to override the Active setting." - } - }, - "repeat_after": { - "title": "Repeat After Completion", - "description": "The task becomes due again after a fixed interval from the last completion.", - "data": { - "task_interval_value": "Task Interval", - "task_interval_type": "Task Interval Unit", - "task_interval_override": "Task Interval Override" - }, - "data_description": { - "task_interval_value": "Time after which the task becomes due again.", - "task_interval_type": "The unit of time for the task interval.", - "task_interval_override": "Select an input_number (value in days) to override the Task Interval." - } - }, "repeat_every": { "title": "Repeat on a Fixed Schedule", "description": "Choose the type of schedule for this task.", @@ -299,6 +259,36 @@ "due_soon_override": "Select an input_number (value in days) to override the Due soon threshold.", "notification_interval": "The time in days after which notifications are sent again." } + }, + "options_repeat_after": { + "title": "Repeat After Completion – Options", + "description": "Update the interval and task settings.", + "data": { + "active": "Active", + "active_override": "Active Override", + "task_interval_value": "Task Interval", + "task_interval_type": "Task Interval Unit", + "task_interval_override": "Task Interval Override", + "icon": "Icon", + "tags": "Tags", + "todo_lists": "Todo-Lists", + "due_soon_days": "Due soon (days)", + "due_soon_override": "Due soon Override (days)", + "notification_interval": "Notification Interval (days)" + }, + "data_description": { + "active": "Enable or disable the task.", + "active_override": "Select an input_boolean to override the Active setting.", + "task_interval_value": "Time after which the task becomes due again.", + "task_interval_type": "The unit of time for the task interval.", + "task_interval_override": "Select an input_number (value in days) to override the Task Interval.", + "icon": "Icon to represent the task.", + "tags": "Tags to categorize the task, separated by commas.", + "todo_lists": "Todo-Lists to which this task will be added.", + "due_soon_days": "The number of days before the task is due to show it as due soon.", + "due_soon_override": "Select an input_number (value in days) to override the Due soon threshold.", + "notification_interval": "The time in days after which notifications are sent again." + } } } }, @@ -527,4 +517,4 @@ } } } -} \ No newline at end of file +} From fe979fc6ab5ead40019866c269f931ce4527d3de Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 09:30:56 +0000 Subject: [PATCH 3/3] Fix repeat_every weekday cycle: respect weeks_interval when catching up overdue tasks Agent-Logs-Url: https://github.com/gensyn/task_tracker/sessions/4a0ed8b0-49a0-48a7-b60d-55e3b5b9a8c2 Co-authored-by: gensyn <36128035+gensyn@users.noreply.github.com> --- coordinator.py | 36 ++++++++++++++++++++++++++++++++- tests/unit_tests/test_sensor.py | 29 ++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/coordinator.py b/coordinator.py index 7bc0fbe..ca8d909 100644 --- a/coordinator.py +++ b/coordinator.py @@ -184,7 +184,9 @@ def _find_most_recent_occurrence(self, today: date) -> date: """ etype = self.repeat_every_type if etype == CONF_REPEAT_EVERY_WEEKDAY: - return self._calc_most_recent_weekday(today, self.repeat_weekday or "monday") + return self._calc_most_recent_weekday_in_cycle( + today, self.repeat_weekday or "monday", self.repeat_weeks_interval + ) if etype == CONF_REPEAT_EVERY_DAY_OF_MONTH: return self._calc_most_recent_day_of_month(today, self.repeat_month_day) if etype == CONF_REPEAT_EVERY_WEEKDAY_OF_MONTH: @@ -299,6 +301,38 @@ def _calc_most_recent_weekday(today: date, weekday_name: str) -> date: days_back = (today.weekday() - target) % 7 return today - timedelta(days=days_back) + def _calc_most_recent_weekday_in_cycle( + self, today: date, weekday_name: str, weeks_interval: int + ) -> date: + """Return the most recent cycle occurrence of *weekday_name* on or before *today*. + + Cycle occurrences are spaced *weeks_interval* weeks apart, anchored at + the ``last_done`` date. The first cycle date is computed with the same + logic as ``_calc_next_weekday`` so that the forward and backward + calculations stay consistent. + + When *weeks_interval* is 1 every occurrence of that weekday is a valid + cycle date, so the result is identical to ``_calc_most_recent_weekday``. + """ + anchor = self.last_done + # Compute the first cycle date strictly after anchor (mirrors _calc_next_weekday) + target = self._weekday_number(weekday_name) + days_ahead = (target - anchor.weekday()) % 7 + if days_ahead == 0: + first_cycle = anchor + timedelta(weeks=weeks_interval) + else: + first_cycle = anchor + timedelta(days=days_ahead) + timedelta(weeks=weeks_interval - 1) + + if first_cycle > today: + # The first cycle date is already in the future — return anchor as a + # safe fallback (this path is not reached in normal overdue flow). + return anchor + + # Find the largest N such that first_cycle + N * weeks_interval weeks <= today + days_elapsed = (today - first_cycle).days + periods_elapsed = days_elapsed // (weeks_interval * 7) + return first_cycle + timedelta(weeks=periods_elapsed * weeks_interval) + @staticmethod def _calc_most_recent_day_of_month(today: date, day: int) -> date: """Return the most recent date on or before *today* that is the *day*-th of its month.""" diff --git a/tests/unit_tests/test_sensor.py b/tests/unit_tests/test_sensor.py index b1e0cda..05276a2 100644 --- a/tests/unit_tests/test_sensor.py +++ b/tests/unit_tests/test_sensor.py @@ -675,6 +675,35 @@ async def test_repeat_every_mark_as_done_catches_up_from_epoch_weekday(self): await self._run_update(sensor) self.assertGreater(sensor.due_date, today) + async def test_repeat_every_weekday_mark_as_done_respects_weeks_interval(self): + """mark_as_done with weeks_interval > 1 must land on a cycle date, not just the latest weekday. + + Scenario from the bug report: every 3 weeks on Tuesday. + last_done = 2026-02-24 (Tuesday, week 0) + cycle dates: 2026-03-17 (week 3), 2026-04-07 (week 6), 2026-04-28 (week 9), … + today = 2026-04-14 (Tuesday, 7 weeks after last_done) + + Expected: last_done → 2026-04-07 (the most recent cycle date ≤ today). + Wrong: last_done → 2026-04-14 (the latest Tuesday, ignoring the cycle). + """ + from datetime import timedelta + sensor = make_sensor( + repeat_mode=CONF_REPEAT_EVERY, + repeat_every_type=CONF_REPEAT_EVERY_WEEKDAY, + repeat_weekday=CONF_TUESDAY, + repeat_weeks_interval=3, + due_soon_days=0, + ) + sensor.coordinator.last_done = date(2026, 2, 24) # Tuesday + with patch("task_tracker.coordinator.date") as mock_date: + mock_date.today.return_value = date(2026, 4, 14) + mock_date.side_effect = lambda *args, **kwargs: date(*args, **kwargs) + await sensor.coordinator.async_mark_as_done() + self.assertEqual(sensor.coordinator.last_done, date(2026, 4, 7)) + # Next due date should be 3 weeks after 2026-04-07 = 2026-04-28 + next_due = sensor.coordinator._calculate_repeat_every_due_date() + self.assertEqual(next_due, date(2026, 4, 28)) + async def test_repeat_every_mark_as_done_coordinator_direct_uses_most_recent(self): """Calling coordinator.async_mark_as_done() directly always uses the most recent occurrence.