From b88d5ee6b36dedea3b08185f3aec5a9de7fb9fd1 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 12 May 2026 14:35:57 -0400 Subject: [PATCH] feat(journal): register per-user closeout job at day_rollover_hour Co-Authored-By: Claude Opus 4.7 (1M context) --- .../services/journal_scheduler.py | 67 ++++++++++++++----- tests/test_journal_closeout.py | 40 +++++++++++ 2 files changed, 91 insertions(+), 16 deletions(-) diff --git a/src/fabledassistant/services/journal_scheduler.py b/src/fabledassistant/services/journal_scheduler.py index b7f8a24..b569f94 100644 --- a/src/fabledassistant/services/journal_scheduler.py +++ b/src/fabledassistant/services/journal_scheduler.py @@ -89,30 +89,65 @@ def _run_daily_prep_threadsafe(user_id: int) -> None: asyncio.run_coroutine_threadsafe(_do_daily_prep(user_id), _loop) +async def _do_closeout(user_id: int) -> None: + try: + tz_str = await get_user_timezone(user_id) + tz = _resolve_tz(tz_str) + now = datetime.datetime.now(tz) + # We just rolled into a new day in user-local time. The day that + # just ended is yesterday's calendar date regardless of whether + # rollover_hour is 0 or 4 — APScheduler fires precisely at the + # configured hour so no clock-skew correction is needed. + yesterday = now.date() - datetime.timedelta(days=1) + from fabledassistant.services.journal_closeout import run_for_user + await run_for_user(user_id=user_id, yesterday=yesterday) + except Exception: + logger.exception("Closeout failed for user %d", user_id) + + +def _run_closeout_threadsafe(user_id: int) -> None: + if _loop is None: + return + asyncio.run_coroutine_threadsafe(_do_closeout(user_id), _loop) + + async def update_user_schedule(user_id: int) -> None: - """Add or replace this user's daily-prep job using their current config.""" + """Add or replace this user's daily-prep + closeout jobs from current config.""" if _scheduler is None: return - job_id = f"journal_prep_{user_id}" - if _scheduler.get_job(job_id): - _scheduler.remove_job(job_id) config = await get_journal_config(user_id) - if not config.get("prep_enabled", True): - return - tz_str = await get_user_timezone(user_id) tz = _resolve_tz(tz_str) - prep_hour = int(config.get("prep_hour", 5)) - prep_minute = int(config.get("prep_minute", 0)) - _scheduler.add_job( - _run_daily_prep_threadsafe, - trigger=CronTrigger(hour=prep_hour, minute=prep_minute, timezone=tz), - args=[user_id], - id=job_id, - replace_existing=True, - ) + # ── Prep job ────────────────────────────────────────────────────────── + prep_job_id = f"journal_prep_{user_id}" + if _scheduler.get_job(prep_job_id): + _scheduler.remove_job(prep_job_id) + if config.get("prep_enabled", True): + prep_hour = int(config.get("prep_hour", 5)) + prep_minute = int(config.get("prep_minute", 0)) + _scheduler.add_job( + _run_daily_prep_threadsafe, + trigger=CronTrigger(hour=prep_hour, minute=prep_minute, timezone=tz), + args=[user_id], + id=prep_job_id, + replace_existing=True, + ) + + # ── Closeout job ────────────────────────────────────────────────────── + closeout_job_id = f"journal_closeout_{user_id}" + if _scheduler.get_job(closeout_job_id): + _scheduler.remove_job(closeout_job_id) + if config.get("closeout_enabled", True): + rollover_hour = int(config.get("day_rollover_hour", 4)) + _scheduler.add_job( + _run_closeout_threadsafe, + trigger=CronTrigger(hour=rollover_hour, minute=0, timezone=tz), + args=[user_id], + id=closeout_job_id, + replace_existing=True, + ) async def _register_all_user_jobs() -> None: diff --git a/tests/test_journal_closeout.py b/tests/test_journal_closeout.py index 3ef2be8..d9f788f 100644 --- a/tests/test_journal_closeout.py +++ b/tests/test_journal_closeout.py @@ -162,3 +162,43 @@ async def test_run_for_user_respects_nothing_to_note_sentinel(): await journal_closeout.run_for_user(user_id=42, yesterday=datetime.date(2026, 5, 11)) mock_append.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_update_user_schedule_registers_closeout_when_enabled(monkeypatch): + from fabledassistant.services import journal_scheduler as sched + + fake_scheduler = MagicMock() + fake_scheduler.get_job = MagicMock(return_value=None) + monkeypatch.setattr(sched, "_scheduler", fake_scheduler) + monkeypatch.setattr(sched, "get_journal_config", AsyncMock(return_value={ + "prep_enabled": False, # isolate the closeout job + "closeout_enabled": True, + "day_rollover_hour": 4, + })) + monkeypatch.setattr(sched, "get_user_timezone", AsyncMock(return_value="UTC")) + + await sched.update_user_schedule(user_id=7) + + added = [c.kwargs.get("id") for c in fake_scheduler.add_job.call_args_list] + assert "journal_closeout_7" in added + + +@pytest.mark.asyncio +async def test_update_user_schedule_does_not_register_closeout_when_disabled(monkeypatch): + from fabledassistant.services import journal_scheduler as sched + + fake_scheduler = MagicMock() + fake_scheduler.get_job = MagicMock(return_value=None) + monkeypatch.setattr(sched, "_scheduler", fake_scheduler) + monkeypatch.setattr(sched, "get_journal_config", AsyncMock(return_value={ + "prep_enabled": False, + "closeout_enabled": False, + "day_rollover_hour": 4, + })) + monkeypatch.setattr(sched, "get_user_timezone", AsyncMock(return_value="UTC")) + + await sched.update_user_schedule(user_id=7) + + added = [c.kwargs.get("id") for c in fake_scheduler.add_job.call_args_list] + assert "journal_closeout_7" not in added