Skip to content

Commit b944157

Browse files
authored
Prevent chores from staying overdue after reset (#54)
* doc: draft plan to implement enable/disable chore * refactor(startup): split migration and integrity lanes What changed: - moved frozen pre-v50 migration code under custom_components/choreops/migrations/ - added modern boot integrity and modern migration entry points - updated imports, docs, and regression coverage for the new startup lanes Why: - keeps SystemManager as the orchestrator while separating legacy migration, modern migration, and boot repair responsibilities * fix(workflow): correct overdue reset behavior What changed: - clear stale due dates before immediate non-recurring reset can re-publish overdue state - prevent blocked single-claimer peers from emitting overdue behavior and clear their transient notifications - snapshot persisted store payloads and relax cancelled debounce persistence re-raise behavior Why: - fixes the user-visible reset/notification regression and the persistence race it exposed during validation
1 parent 908a995 commit b944157

27 files changed

+1081
-100
lines changed

custom_components/choreops/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ChoreOpsConfigEntry) ->
168168
# PHASE 2: Migrate entity data from config to storage (one-time hand-off) - LEGACY MIGRATION
169169
# This must happen BEFORE coordinator initialization to ensure coordinator
170170
# loads from storage-only mode (schema_version >= 43)
171-
from .migration_pre_v50 import (
171+
from .migrations.pre_v50 import (
172172
async_migrate_uid_suffixes_v0_5_0,
173173
migrate_config_to_storage,
174174
normalize_bonus_penalty_apply_shapes,

custom_components/choreops/config_flow.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,11 @@
1212
from homeassistant.util import dt as dt_util
1313
import voluptuous as vol
1414

15-
from . import const, data_builders as db, migration_pre_v50 as mp50
15+
from . import const, data_builders as db
1616
from .data_builders import EntityValidationError
1717
from .helpers import backup_helpers as bh, flow_helpers as fh
1818
from .helpers.storage_helpers import get_entry_storage_key_from_entry
19+
from .migrations import pre_v50 as mp50
1920
from .options_flow import ChoreOpsOptionsFlowHandler
2021

2122

custom_components/choreops/const.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from homeassistant.const import Platform
1616
import homeassistant.util.dt as dt_util
1717

18-
from .migration_pre_v50_constants import * # noqa: F403
18+
from .migrations.pre_v50_constants import * # noqa: F403
1919
from .utils import dt_utils
2020

2121

@@ -1267,7 +1267,7 @@ def set_default_timezone(hass):
12671267

12681268
# --- Averages ---
12691269
# NOTE: avg_*_week/month keys are not persisted. avg_per_chore is persisted.
1270-
# LEGACY constants moved to `migration_pre_v50_constants.py`
1270+
# LEGACY constants moved to `migrations/pre_v50_constants.py`
12711271

12721272
# ================================================================================================
12731273
# PRESENTATION CONSTANTS (assignee scoped) - Memory-only cache keys (NOT in storage)
@@ -3975,4 +3975,4 @@ class EntityRequirement(StrEnum):
39753975
# ================================================================================================
39763976
# Legacy constants (one-time migration support)
39773977
# ================================================================================================
3978-
# Legacy constants are defined in `migration_pre_v50_constants.py` and re-exported here.
3978+
# Legacy constants are defined in `migrations/pre_v50_constants.py` and re-exported here.

custom_components/choreops/coordinator.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,6 @@ async def _persist_debounced_impl(self, *, enforce_schema: bool = True):
323323
except asyncio.CancelledError:
324324
# Task was cancelled, new save scheduled
325325
const.LOGGER.debug("Debounced persist cancelled (replaced by new save)")
326-
raise
327326

328327
def _enforce_runtime_schema_on_persist(self) -> None:
329328
"""Ensure runtime persistence uses canonical schema metadata.
@@ -332,7 +331,7 @@ def _enforce_runtime_schema_on_persist(self) -> None:
332331
can bypass it by calling `_persist(..., enforce_schema=False)` to avoid
333332
premature schema stamping while transitional migration phases are active.
334333
"""
335-
from .migration_pre_v50 import has_legacy_migration_performed_marker
334+
from .migrations.pre_v50 import has_legacy_migration_performed_marker
336335

337336
if has_legacy_migration_performed_marker(self._data):
338337
return
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
"""Boot-time integrity repair entry points for modern storage payloads."""
2+
3+
from .boot_repairs import repair_impossible_due_state_residue, run_boot_repairs
4+
5+
__all__ = [
6+
"repair_impossible_due_state_residue",
7+
"run_boot_repairs",
8+
]
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
"""Boot-time data integrity repairs for modern storage payloads.
2+
3+
These repairs are not schema migrations. They normalize impossible runtime
4+
state that may enter storage through historic bugs, imports, or interrupted
5+
write sequences. Repairs in this module must be:
6+
7+
- idempotent
8+
- safe to run on every startup
9+
- named by invariant, not by incident or ticket number
10+
"""
11+
12+
from __future__ import annotations
13+
14+
from typing import Any
15+
16+
from custom_components.choreops import const
17+
from custom_components.choreops.engines.chore_engine import ChoreEngine
18+
19+
20+
def run_boot_repairs(data: dict[str, Any]) -> dict[str, dict[str, int]]:
21+
"""Run all modern boot repairs and return per-repair summaries."""
22+
return {
23+
"repair_impossible_due_state_residue": repair_impossible_due_state_residue(data)
24+
}
25+
26+
27+
def repair_impossible_due_state_residue(data: dict[str, Any]) -> dict[str, int]:
28+
"""Clear impossible overdue residue when no active due date exists."""
29+
summary = {
30+
"chores_sanitized": 0,
31+
"stale_due_dates_cleared": 0,
32+
"assignee_states_normalized": 0,
33+
"global_states_normalized": 0,
34+
}
35+
36+
chores_raw = data.get(const.DATA_CHORES)
37+
users_raw = data.get(const.DATA_USERS)
38+
if not isinstance(chores_raw, dict) or not isinstance(users_raw, dict):
39+
return summary
40+
41+
for chore_id, chore_value in chores_raw.items():
42+
if not isinstance(chore_value, dict):
43+
continue
44+
45+
chore_data: dict[str, Any] = chore_value
46+
chore_changed = False
47+
uses_chore_level_due_date = ChoreEngine.uses_chore_level_due_date(chore_data)
48+
due_date_raw = chore_data.get(const.DATA_CHORE_DUE_DATE)
49+
per_assignee_due_dates_raw = chore_data.get(
50+
const.DATA_CHORE_PER_ASSIGNEE_DUE_DATES, {}
51+
)
52+
per_assignee_due_dates = (
53+
per_assignee_due_dates_raw
54+
if isinstance(per_assignee_due_dates_raw, dict)
55+
else {}
56+
)
57+
58+
if not due_date_raw and uses_chore_level_due_date and per_assignee_due_dates:
59+
cleared_count = sum(
60+
1 for due_date in per_assignee_due_dates.values() if due_date
61+
)
62+
if cleared_count > 0:
63+
for assignee_id in list(per_assignee_due_dates):
64+
per_assignee_due_dates[assignee_id] = None
65+
summary["stale_due_dates_cleared"] += cleared_count
66+
chore_changed = True
67+
68+
has_active_due_date = (
69+
bool(due_date_raw)
70+
if uses_chore_level_due_date
71+
else any(
72+
due_date for due_date in per_assignee_due_dates.values() if due_date
73+
)
74+
)
75+
if has_active_due_date:
76+
if chore_changed:
77+
summary["chores_sanitized"] += 1
78+
continue
79+
80+
assignee_ids_raw = chore_data.get(const.DATA_CHORE_ASSIGNED_USER_IDS, [])
81+
assignee_ids = assignee_ids_raw if isinstance(assignee_ids_raw, list) else []
82+
assignee_states: dict[str, str] = {}
83+
84+
for assignee_id in assignee_ids:
85+
user_value = users_raw.get(assignee_id, {})
86+
if not isinstance(user_value, dict):
87+
assignee_states[assignee_id] = const.CHORE_STATE_PENDING
88+
continue
89+
90+
chore_tracking_raw = user_value.get(const.DATA_USER_CHORE_DATA, {})
91+
chore_tracking = (
92+
chore_tracking_raw if isinstance(chore_tracking_raw, dict) else {}
93+
)
94+
assignee_chore_value = chore_tracking.get(chore_id, {})
95+
assignee_chore_data = (
96+
assignee_chore_value if isinstance(assignee_chore_value, dict) else {}
97+
)
98+
99+
current_state = assignee_chore_data.get(
100+
const.DATA_USER_CHORE_DATA_STATE,
101+
const.CHORE_STATE_PENDING,
102+
)
103+
if current_state in (
104+
const.CHORE_STATE_OVERDUE,
105+
const.CHORE_STATE_MISSED,
106+
):
107+
assignee_chore_data[const.DATA_USER_CHORE_DATA_STATE] = (
108+
const.CHORE_STATE_PENDING
109+
)
110+
current_state = const.CHORE_STATE_PENDING
111+
summary["assignee_states_normalized"] += 1
112+
chore_changed = True
113+
114+
assignee_states[assignee_id] = (
115+
current_state
116+
if isinstance(current_state, str)
117+
else const.CHORE_STATE_PENDING
118+
)
119+
120+
current_global_state = chore_data.get(const.DATA_CHORE_STATE)
121+
if current_global_state in (
122+
const.CHORE_STATE_OVERDUE,
123+
const.CHORE_STATE_MISSED,
124+
):
125+
normalized_global_state = (
126+
ChoreEngine.compute_global_chore_state(chore_data, assignee_states)
127+
if assignee_states
128+
else const.CHORE_STATE_PENDING
129+
)
130+
if current_global_state != normalized_global_state:
131+
chore_data[const.DATA_CHORE_STATE] = normalized_global_state
132+
summary["global_states_normalized"] += 1
133+
chore_changed = True
134+
135+
if chore_changed:
136+
summary["chores_sanitized"] += 1
137+
138+
return summary

custom_components/choreops/managers/chore_manager.py

Lines changed: 46 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1019,6 +1019,21 @@ async def _approve_chore_locked(
10191019
is_full_cycle_reset = is_single_claimer_mode or (
10201020
requires_all_assignees_approval and all_assignees_approved
10211021
)
1022+
due_assignee_id = assignee_id if is_independent_mode else None
1023+
should_clear_due_date_after_immediate_reset = (
1024+
should_reset_immediately
1025+
and chore_data.get(
1026+
const.DATA_CHORE_RECURRING_FREQUENCY,
1027+
const.FREQUENCY_NONE,
1028+
)
1029+
== const.FREQUENCY_NONE
1030+
and self.get_due_date(chore_id, due_assignee_id) is not None
1031+
and reset_decision
1032+
in (
1033+
const.CHORE_RESET_DECISION_RESET_ONLY,
1034+
const.CHORE_RESET_DECISION_RESET_AND_RESCHEDULE,
1035+
)
1036+
)
10221037

10231038
if should_reset_immediately:
10241039
reset_targets: list[str] = []
@@ -1053,13 +1068,17 @@ async def _approve_chore_locked(
10531068
"decision": reset_decision,
10541069
"reschedule_assignee_id": reschedule_assignee_id,
10551070
"allow_reschedule": allow_per_assignee_reschedule,
1071+
"clear_due_date": should_clear_due_date_after_immediate_reset,
10561072
}
10571073
)
10581074

10591075
if reset_targets:
10601076
self._update_global_state(chore_id)
10611077

1062-
if should_reschedule_chore:
1078+
if (
1079+
should_reschedule_chore
1080+
and not should_clear_due_date_after_immediate_reset
1081+
):
10631082
self._reschedule_chore_due(chore_id)
10641083

10651084
if reset_targets and is_rotation_mode:
@@ -1076,35 +1095,6 @@ async def _approve_chore_locked(
10761095
completion_criteria,
10771096
)
10781097

1079-
# === NON-RECURRING PAST-DUE GUARD (Phase 1) ===
1080-
# For FREQUENCY_NONE chores that just reset via UPON_COMPLETION:
1081-
# Clear the past due date so the next scan doesn't immediately re-overdue.
1082-
# The chore stays PENDING indefinitely until user sets a new due date.
1083-
if should_reset_immediately:
1084-
frequency = chore_data.get(
1085-
const.DATA_CHORE_RECURRING_FREQUENCY, const.FREQUENCY_NONE
1086-
)
1087-
if frequency == const.FREQUENCY_NONE:
1088-
completion_criteria = chore_data.get(
1089-
const.DATA_CHORE_COMPLETION_CRITERIA,
1090-
const.COMPLETION_CRITERIA_INDEPENDENT,
1091-
)
1092-
if completion_criteria == const.COMPLETION_CRITERIA_INDEPENDENT:
1093-
# Clear per-assignee due date
1094-
per_assignee_dates = chore_data.get(
1095-
const.DATA_CHORE_PER_ASSIGNEE_DUE_DATES, {}
1096-
)
1097-
per_assignee_dates.pop(assignee_id, None)
1098-
else:
1099-
# Clear chore-level due date (SHARED/SHARED_FIRST)
1100-
chore_data.pop(const.DATA_CHORE_DUE_DATE, None)
1101-
1102-
const.LOGGER.debug(
1103-
"Cleared past due date for non-recurring chore %s "
1104-
"after UPON_COMPLETION reset (prevents re-overdue)",
1105-
chore_id,
1106-
)
1107-
11081098
# For non-UPON_COMPLETION reset types (AT_MIDNIGHT_*, AT_DUE_DATE_*):
11091099
# Do NOT set approval_period_start here. It is ONLY set on RESET events.
11101100
# The chore remains approved until the scheduled reset updates approval_period_start.
@@ -1579,6 +1569,12 @@ def _apply_reset_action(self, context: ResetApplyContext) -> None:
15791569
allow_reschedule = context.get("allow_reschedule", True)
15801570
clear_due_date = context.get("clear_due_date", False)
15811571

1572+
if clear_due_date:
1573+
self._clear_due_date_after_reset(
1574+
chore_id,
1575+
assignee_id if allow_reschedule else None,
1576+
)
1577+
15821578
self._transition_chore_state(
15831579
assignee_id,
15841580
chore_id,
@@ -1587,12 +1583,6 @@ def _apply_reset_action(self, context: ResetApplyContext) -> None:
15871583
clear_ownership=True,
15881584
)
15891585

1590-
if clear_due_date:
1591-
self._clear_due_date_after_reset(
1592-
chore_id,
1593-
assignee_id if allow_reschedule else None,
1594-
)
1595-
15961586
if (
15971587
not clear_due_date
15981588
and allow_reschedule
@@ -3363,6 +3353,26 @@ def chore_is_actionable(self, assignee_id: str, chore_id: str) -> bool:
33633353
)
33643354
if assignee_state == const.CHORE_STATE_MISSED:
33653355
return False
3356+
3357+
chore_data: ChoreData | dict[str, Any] = self._coordinator.chores_data.get(
3358+
chore_id, {}
3359+
)
3360+
if ChoreEngine.is_single_claimer_mode(chore_data):
3361+
assigned_assignees = chore_data.get(const.DATA_CHORE_ASSIGNED_USER_IDS, [])
3362+
for other_assignee_id in assigned_assignees:
3363+
if not other_assignee_id or other_assignee_id == assignee_id:
3364+
continue
3365+
3366+
other_state = self._derive_boundary_assignee_state(
3367+
other_assignee_id,
3368+
chore_id,
3369+
)
3370+
if other_state in (
3371+
const.CHORE_STATE_CLAIMED,
3372+
const.CHORE_STATE_APPROVED,
3373+
):
3374+
return False
3375+
33663376
return True
33673377

33683378
def chore_is_overdue(self, assignee_id: str, chore_id: str) -> bool:

0 commit comments

Comments
 (0)