diff --git a/custom_components/nestquest/dao_instances.py b/custom_components/nestquest/dao_instances.py index 8c9e852..5079f27 100644 --- a/custom_components/nestquest/dao_instances.py +++ b/custom_components/nestquest/dao_instances.py @@ -487,6 +487,40 @@ class QuestInstancesDao: ) return result.rowcount + async def delete_future_uncompleted_for_child( + self, child_id: int, cutoff_date: str + ) -> int: + """Delete the child's open instances at/after cutoff, across every + definition. + + Child-scoped counterpart to :meth:`delete_future_uncompleted`, + used when a child's presence changes (Feature 09): only instances + at or after the cutoff with NO completion event are removed, inside + one transaction — an instance that gains a completion event + mid-flight cannot be deleted. The cutoff cannot be backdated below + today, for the same reason as the definition-scoped method (see its + docstring). Any instance with a completion event is never touched, + regardless of its date. Returns the number deleted. + """ + _validate_date(cutoff_date, "cutoff_date") + async with _connection_lock(self._database): + async with self._database.transaction(): + # "today" is read under the lock, matching the + # definition-scoped method: the DELETE executes on this + # date, so a caller queued across midnight cannot delete + # instances that became past while it waited. + today = datetime.date.today().isoformat() + effective_cutoff = max(cutoff_date, today) + result = await self._database.execute( + "DELETE FROM quest_instances WHERE child_id = ? " + "AND due_date >= ? AND NOT EXISTS (" + " SELECT 1 FROM completion_events " + " WHERE instance_id = quest_instances.id" + ")", + (child_id, effective_cutoff), + ) + return result.rowcount + class CompletionEventsDao: """Typed append-only access to the ``completion_events`` table. diff --git a/custom_components/nestquest/materialize.py b/custom_components/nestquest/materialize.py index a5e5abf..f00179b 100644 --- a/custom_components/nestquest/materialize.py +++ b/custom_components/nestquest/materialize.py @@ -37,6 +37,23 @@ window), so re-running the materialization over the same range never duplicates a row; a tuple whose instance is already completed is SKIPPED (never rewritten, never raised) by :meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid`. + +Regeneration: a config or presence change must rebuild future instances +so they track the new state. :func:`regenerate_for_definition` covers +definition edits / reassignment (Features 06/07); :func:`regenerate_for_child` +covers a single child's presence change (Feature 09). Both delete the +affected open instances at or after today — never a completed instance, +never the past — and re-run the walk over the rolling horizon +``[today, today + DEFAULT_HORIZON_DAYS]``. + +HOOK (Feature 09 presence services): the presence business layer that +lands in Feature 09 (``set_presence_pattern`` / ``create_presence_override`` +/ ``delete_presence_override``) MUST call :func:`regenerate_for_child` +after each successful presence write so a child's future instances track +its presence. This hook is documented, not yet wired: no presence +business layer exists yet, and the presence DAO (``dao_presence.py``) +must stay free of business rules — never call :func:`regenerate_for_child` +from inside the DAO layer. """ from __future__ import annotations @@ -200,3 +217,45 @@ async def regenerate_for_definition( instances = QuestInstancesDao(database) await instances.delete_future_uncompleted(definition_id, start_date) return await materialize(database, start_date, end_date) + + +# HOOK (Feature 09 presence services): ``regenerate_for_child`` is the +# child-presence counterpart to ``regenerate_for_definition``. The +# presence business layer that lands in Feature 09 +# (set_presence_pattern / create_presence_override / +# delete_presence_override) MUST call ``regenerate_for_child`` after each +# successful write, so future instances track a child's changed presence. +# It is deliberately NOT wired into ``dao_presence.py``: the DAO layer +# stays pure storage (no business rules), and no presence business layer +# exists yet to host the call — see the module docstring note below. +async def regenerate_for_child( + database: NestQuestDatabase, + child_id: int, +) -> int: + """Regenerate a child's future instances after a presence change. + + The Feature 09 presence services (set_presence_pattern / + create_presence_override / delete_presence_override) call this after + every presence write so a child's future instances track its new + presence: the child's open instances at or after today are deleted + across ALL its definitions (never a completed instance, never the + past) via + :meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted_for_child`, + then the materialization walk re-runs over the rolling horizon + ``[today, today + DEFAULT_HORIZON_DAYS]`` so the child's + now-absent/present dates re-materialize against today's presence. + + "today" is computed the same way the walk computes it — + ``datetime.date.today()`` — so the delete cutoff and the + re-materialization range share one anchor. Returns the number of + instances the re-materialization upserted across the whole snapshot + (idempotent — other children's and unaffected definitions' tuples + refresh in place, never duplicate). + """ + today = datetime.date.today() + start_date = today.isoformat() + end_date = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat() + + instances = QuestInstancesDao(database) + await instances.delete_future_uncompleted_for_child(child_id, start_date) + return await materialize(database, start_date, end_date) diff --git a/tests/test_materialize.py b/tests/test_materialize.py index e114678..3e50ed0 100644 --- a/tests/test_materialize.py +++ b/tests/test_materialize.py @@ -19,6 +19,7 @@ from custom_components.nestquest.dao_rules import ( from custom_components.nestquest.db import NestQuestDatabase from custom_components.nestquest.materialize import ( materialize, + regenerate_for_child, regenerate_for_definition, ) from custom_components.nestquest.migrations import apply_migrations @@ -772,4 +773,117 @@ def test_edit_quest_definition_regenerates_future_instances(tmp_path) -> None: assert (day.isoformat() in by_due) == occurs_on(weekly, day) return None - _with_db(tmp_path, "edit-regenerates.db")(_body) \ No newline at end of file + _with_db(tmp_path, "edit-regenerates.db")(_body) + + +def test_regenerate_for_child_rebuilds_horizon_preserves_history( + tmp_path, +) -> None: + async def _body(database): + from custom_components.nestquest.dao_presence import ( + PresenceOverridesDao, + ) + + children = ChildrenDao(database) + overrides = PresenceOverridesDao(database) + child = await children.create("Ada", NOW) + + today = datetime.date.today() + horizon_end = today + datetime.timedelta(days=14) + start_iso = today.isoformat() + + created = await create_quest_definition( + database, + "Daily chore", + ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso), + [child.id], + [("morning", "09:00")], + ) + + dao = QuestInstancesDao(database) + events = CompletionEventsDao(database) + + # Instances across the full horizon: 15 daily rows. + await materialize(database, start_iso, horizon_end.isoformat()) + all_future = await dao.list_by_date_range( + child.id, start_iso, horizon_end.isoformat() + ) + assert len(all_future) == 15 + + # A completed instance on tomorrow: immutable, must be spared. + completed_due = (today + datetime.timedelta(days=1)).isoformat() + completed = next(r for r in all_future if r.due_date == completed_due) + await events.append( + completed.id, + child.id, + "completed", + "user", + f"{completed_due}T08:00:00+00:00", + True, + actor_user_id="user-1", + ) + + # A past instance (yesterday): raw insert bypasses the walk's + # no-past guard so the child-scoped delete has history to spare. + past_due = (today - datetime.timedelta(days=1)).isoformat() + await database.execute( + "INSERT INTO quest_instances (definition_id, child_id, " + "window, due_date, due_time, generated_at) " + "VALUES (?, ?, 'morning', ?, '09:00', ?)", + (created.definition.id, child.id, past_due, NOW), + ) + + # An ABSENT override covering three future dates that had + # instances: days 2..4 after today. + absent_start = (today + datetime.timedelta(days=2)).isoformat() + absent_end = (today + datetime.timedelta(days=4)).isoformat() + override = await overrides.create( + child.id, absent_start, absent_end, False, note="away" + ) + + await regenerate_for_child(database, child.id) + + records = await dao.list_by_date_range( + child.id, past_due, horizon_end.isoformat() + ) + by_due = {r.due_date: r for r in records} + + # The now-absent days' instances are gone. + for offset in range(2, 5): + iso = (today + datetime.timedelta(days=offset)).isoformat() + assert iso not in by_due + + # The completed instance survived untouched (same physical id). + assert by_due[completed_due].id == completed.id + + # The past instance survived. + assert past_due in by_due + + # Every non-absent future day still has its instance. + for offset in range(15): + iso = (today + datetime.timedelta(days=offset)).isoformat() + if 2 <= offset <= 4: + continue + assert iso in by_due + + # Remove the override and regenerate again: the absent days return. + await overrides.delete(override.id) + await regenerate_for_child(database, child.id) + + records = await dao.list_by_date_range( + child.id, past_due, horizon_end.isoformat() + ) + by_due = {r.due_date: r for r in records} + + for offset in range(15): + iso = (today + datetime.timedelta(days=offset)).isoformat() + assert iso in by_due + + # The completed instance is still the same untouched row, and the + # past instance is still there. + assert by_due[completed_due].id == completed.id + assert by_due[completed_due].due_time == "09:00" + assert past_due in by_due + return None + + _with_db(tmp_path, "regenerate-child.db")(_body) \ No newline at end of file