From daffa894d857eb429538834bf2b258e8f9d8b0f2 Mon Sep 17 00:00:00 2001 From: timmy Date: Thu, 20 Aug 2026 19:28:32 +0000 Subject: [PATCH] feat: remind from due Week Ahead plans (Closes #1192) --- src/main.py | 2 +- src/today_store.py | 26 ++++++++++++ tests/test_start_day_reminders.py | 7 +++- tests/test_week_plan.py | 66 +++++++++++++++++++++++++++++++ 4 files changed, 98 insertions(+), 3 deletions(-) diff --git a/src/main.py b/src/main.py index cb103c2..d76abea 100644 --- a/src/main.py +++ b/src/main.py @@ -127,7 +127,7 @@ async def _start_day_plan_snapshot() -> dict: login = user.get("login") if isinstance(user, dict) else None if not isinstance(login, str) or not login: return {} - return await asyncio.to_thread(_today_store().get_tomorrow, login) + return await asyncio.to_thread(_today_store().get_start_day_plan, login) async def _push_poll_loop() -> None: diff --git a/src/today_store.py b/src/today_store.py index d2ca401..783d5a5 100644 --- a/src/today_store.py +++ b/src/today_store.py @@ -450,6 +450,32 @@ class TodayStore: raise PrivateStateEncryptionError("private state could not be decrypted") return {"revision": int(row[0]), **payload} + def get_start_day_plan(self, login: str) -> dict: + """Return the next private planned day without changing either plan.""" + login = self._normalize_login(login) + with self._connect() as connection: + week_row = connection.execute( + "SELECT revision, payload FROM week_plans WHERE login = ?", (login,) + ).fetchone() + if week_row is not None: + week = self._week_snapshot(week_row, login) + local_date = datetime.fromtimestamp( + self.clock(), ZoneInfo(week["timezone"]) + ).date().isoformat() + for day in week["days"]: + if ( + isinstance(day, dict) + and isinstance(day.get("ids"), list) + and day["ids"] + and day.get("plan_date", "") <= local_date + ): + return {**day, "timezone": week.get("timezone")} + tomorrow_row = connection.execute( + "SELECT revision, payload FROM tomorrow_plans WHERE login = ?", (login,) + ).fetchone() + tomorrow = self._tomorrow_snapshot(tomorrow_row, login) + return tomorrow if tomorrow.get("ids") and tomorrow.get("plan_date") else {} + def get_week(self, login: str) -> dict: login = self._normalize_login(login) with self._connect() as connection: diff --git a/tests/test_start_day_reminders.py b/tests/test_start_day_reminders.py index eea69a7..00fa514 100644 --- a/tests/test_start_day_reminders.py +++ b/tests/test_start_day_reminders.py @@ -281,15 +281,18 @@ async def test_authenticated_device_can_enable_start_day_reminders(tmp_path, mon @pytest.mark.anyio -async def test_worker_snapshot_reads_the_confirmed_accounts_tomorrow_plan(monkeypatch): +async def test_worker_snapshot_reads_the_confirmed_accounts_next_planned_day(monkeypatch): async def current_user(): return {"login": "Timmy"} class Store: - def get_tomorrow(self, login): + def get_start_day_plan(self, login): assert login == "Timmy" return {"ids": ["one"], "plan_date": "2026-08-20"} + def get_tomorrow(self, _login): + raise AssertionError("the worker must use Week Ahead discovery") + monkeypatch.setattr(main, "current_user", current_user) monkeypatch.setattr(main, "_today_store", lambda: Store()) diff --git a/tests/test_week_plan.py b/tests/test_week_plan.py index b12ae4e..1fc195a 100644 --- a/tests/test_week_plan.py +++ b/tests/test_week_plan.py @@ -51,6 +51,72 @@ def test_week_plan_is_encrypted_account_scoped_revisioned_and_preserves_other_da assert conflict.value.snapshot == updated +def test_start_day_plan_selects_earliest_nonempty_week_date_without_mutation(tmp_path): + store = TodayStore( + tmp_path / "today.sqlite3", encryption_key=b"r" * 32, + clock=lambda: datetime(2026, 8, 21, 13, tzinfo=timezone.utc).timestamp(), + ) + days = [ + { + "plan_date": "2026-08-20", "ids": [], + "capacity_minutes": 60, "estimates": {}, + }, + { + "plan_date": "2026-08-21", "ids": ["issue:private/repo:7:"], + "capacity_minutes": 90, + "estimates": {"issue:private/repo:7:": 45}, + }, + { + "plan_date": "2026-08-22", "ids": ["issue:private/repo:8:"], + "capacity_minutes": 120, + "estimates": {"issue:private/repo:8:": 30}, + }, + ] + week = store.replace_week( + "timmy", base_revision=0, days=days, timezone="America/New_York" + ) + + assert store.get_start_day_plan("Timmy") == { + **days[1], "timezone": "America/New_York", + } + assert store.get_week("timmy") == week + + +def test_start_day_plan_falls_back_to_tomorrow_without_migrating_it(tmp_path): + store = TodayStore(tmp_path / "today.sqlite3", encryption_key=b"r" * 32) + tomorrow = store.replace_tomorrow( + "timmy", base_revision=0, ids=["issue:private/repo:9:"], + capacity_minutes=75, estimates={"issue:private/repo:9:": 45}, + plan_date="2026-08-21", timezone="UTC", + ) + + assert store.get_start_day_plan("timmy") == tomorrow + assert store.get_tomorrow("timmy") == tomorrow + with store._connect() as connection: + assert connection.execute( + "SELECT COUNT(*) FROM week_plans WHERE login = ?", ("timmy",) + ).fetchone()[0] == 0 + + +def test_start_day_plan_uses_due_tomorrow_fallback_before_future_week_day(tmp_path): + store = TodayStore( + tmp_path / "today.sqlite3", encryption_key=b"r" * 32, + clock=lambda: datetime(2026, 8, 20, 13, tzinfo=timezone.utc).timestamp(), + ) + future = [{ + "plan_date": "2026-08-21", "ids": ["issue:private/repo:10:"], + "capacity_minutes": 90, "estimates": {"issue:private/repo:10:": 45}, + }] + store.replace_week("timmy", base_revision=0, days=future, timezone="UTC") + tomorrow = store.replace_tomorrow( + "timmy", base_revision=0, ids=["issue:private/repo:9:"], + capacity_minutes=75, estimates={"issue:private/repo:9:": 45}, + plan_date="2026-08-20", timezone="UTC", + ) + + assert store.get_start_day_plan("timmy") == tomorrow + + def test_week_plan_rejects_the_same_work_on_multiple_dates_without_advancing_revision(tmp_path): store = TodayStore(tmp_path / "today.sqlite3", encryption_key=b"w" * 32) original = store.replace_week( -- 2.43.0