"""Background jobs: the part that makes this an instrument instead of a timer. Five jobs, all re-planned whenever the settings change: * refresh every todo source on an interval; * close intervals a crashed client left running; * the morning briefing, the daily report and the weekly review, at your times; * the nudge — "you have not started anything in 90 minutes" — which is the one that actually changes behaviour. """ from __future__ import annotations import logging from datetime import timedelta from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.interval import IntervalTrigger from sqlalchemy import select from app.config import env_settings from app.db import session_scope from app.models import PomodoroSession, utcnow from app.services import notify, pomodoro, report, settings_service, stats, tasks_service from app.settings_schema import AppSettings from app.timeutil import ensure_utc, in_window, local_date, local_time, to_local log = logging.getLogger(__name__) _scheduler: BackgroundScheduler | None = None JOB_REFRESH = "refresh_tasks" JOB_REAP = "reap_sessions" JOB_MORNING = "morning_briefing" JOB_DAILY = "daily_report" JOB_WEEKLY = "weekly_report" JOB_NUDGE = "nudge" # -------------------------------------------------------------------------- # Jobs # -------------------------------------------------------------------------- def job_refresh_tasks() -> None: with session_scope() as session: settings = settings_service.load_settings(session) try: result = tasks_service.refresh_tasks(session, settings) failed = [item for item in result["sources"] if not item["ok"]] if failed: log.warning("task refresh: %s", "; ".join(f"{i['label']}: {i['error']}" for i in failed)) except Exception: # pragma: no cover log.exception("task refresh failed") def job_reap_sessions() -> None: with session_scope() as session: settings = settings_service.load_settings(session) closed = pomodoro.reap_stale(session, settings) if closed: log.info("auto-closed %s abandoned interval(s)", closed) def job_morning_briefing() -> None: with session_scope() as session: settings = settings_service.load_settings(session) telegram = settings.notifications.telegram if not (telegram.enabled and telegram.morning_briefing): return today = local_date(settings.timezone) if notify.already_sent(session, JOB_MORNING, today): return text = report.render_morning_briefing(session, settings) deliveries = notify.notify(settings, text, urgent=True) if any(delivery.ok for delivery in deliveries): notify.record_sent(session, JOB_MORNING, today) def job_daily_report() -> None: with session_scope() as session: settings = settings_service.load_settings(session) telegram = settings.notifications.telegram if not (telegram.enabled and telegram.daily_report): return today = local_date(settings.timezone) report.send_daily(session, settings, today) def job_weekly_report() -> None: with session_scope() as session: settings = settings_service.load_settings(session) telegram = settings.notifications.telegram if not (telegram.enabled and telegram.weekly_report): return today = local_date(settings.timezone) if today.weekday() != telegram.weekly_report_weekday: return if notify.already_sent(session, JOB_WEEKLY, today): return payload = report.build_weekly(session, settings, today) text = report.render_weekly_telegram(payload, settings) deliveries = notify.notify(settings, text, urgent=True) if any(delivery.ok for delivery in deliveries): notify.record_sent(session, JOB_WEEKLY, today) def job_nudge() -> None: """Poke the user when the day is passing without any work on it.""" with session_scope() as session: settings = settings_service.load_settings(session) telegram = settings.notifications.telegram if not (telegram.enabled and telegram.nudge_enabled): return now_local = local_time(settings.timezone) if not in_window(now_local, telegram.nudge_window_start, telegram.nudge_window_end): return if notify.in_quiet_hours(settings): return today = local_date(settings.timezone) if notify.count_sent(session, JOB_NUDGE, today) >= telegram.nudge_max_per_day: return if pomodoro.current_session(session) is not None: return # already working — the last thing they need is a message idle_for = _idle_minutes(session, settings) if idle_for is None or idle_for < telegram.nudge_after_idle_minutes: return text = _nudge_text(session, settings, idle_for) deliveries = notify.notify(settings, text) if any(delivery.ok for delivery in deliveries): notify.record_sent(session, JOB_NUDGE, today, {"idle_minutes": idle_for}) def _idle_minutes(session, settings: AppSettings) -> int | None: """Minutes since the last work interval ended, or since the nudge window opened.""" today = local_date(settings.timezone) last = session.scalar( select(PomodoroSession) .where( PomodoroSession.kind == "work", PomodoroSession.local_date == today, PomodoroSession.status.in_(("completed", "abandoned")), ) .order_by(PomodoroSession.started_at.desc()) ) now = utcnow() if last is not None and last.ended_at is not None: return int((now - ensure_utc(last.ended_at)).total_seconds() // 60) window_start = settings.notifications.telegram.nudge_window_start local_now = to_local(now, settings.timezone) opened = local_now.replace( hour=window_start.hour, minute=window_start.minute, second=0, microsecond=0 ) if local_now < opened: return None return int((local_now - opened).total_seconds() // 60) def _nudge_text(session, settings: AppSettings, idle_minutes: int) -> str: ru = settings.notifications.report.language == "ru" tone = settings.notifications.report.tone today = local_date(settings.timezone) done = pomodoro.completed_work_today(session, settings) goal = settings.pomodoro.daily_goal_pomodoros streak = stats.streak(session, settings, until=today) tasks = tasks_service.list_tasks(session) buckets = tasks_service.bucket_tasks(tasks, today) candidate = (buckets["overdue"] or buckets["today"] or buckets["upcoming"] or tasks) suggestion = candidate[0] if candidate else None openers = { "friendly": ( f"👋 Прошло {idle_minutes} минут без работы. Запустим одну помидорку?", f"👋 {idle_minutes} minutes without a pomodoro. Shall we start one?", ), "coach": ( f"⏳ {idle_minutes} минут простоя. {done}/{goal} сделано — ещё один блок, и догоняешь план.", f"⏳ {idle_minutes} idle minutes. You are at {done}/{goal} — one block puts you back on plan.", ), "drill_sergeant": ( f"🫡 {idle_minutes} минут безделья. {done}/{goal}. Таймер. Сейчас.", f"🫡 {idle_minutes} minutes of nothing. {done}/{goal}. Timer. Now.", ), "dry": ( f"Простой: {idle_minutes} мин. Выполнено {done} из {goal}.", f"Idle for {idle_minutes} min. {done} of {goal} pomodoros done.", ), } opener_ru, opener_en = openers.get(tone, openers["friendly"]) lines = [opener_ru if ru else opener_en] if suggestion is not None: label = suggestion.title lines.append("") lines.append( (f"Предлагаю: {report._esc(label)}") if ru else f"Suggested next: {report._esc(label)}" ) if streak.get("current"): lines.append( (f"🔥 Серия {streak['current']} дн. на кону.") if ru else f"🔥 A {streak['current']}-day streak is on the line." ) lines.append("") lines.append( "/start_work — запустить таймер" if ru else "/start_work to begin" ) return "\n".join(lines) # -------------------------------------------------------------------------- # Wiring # -------------------------------------------------------------------------- def _plan(scheduler: BackgroundScheduler, settings: AppSettings) -> None: tz_name = settings.timezone telegram = settings.notifications.telegram scheduler.add_job( job_refresh_tasks, IntervalTrigger(minutes=settings.sources.refresh_interval_minutes), id=JOB_REFRESH, replace_existing=True, next_run_time=utcnow() + timedelta(seconds=20), max_instances=1, coalesce=True, ) scheduler.add_job( job_reap_sessions, IntervalTrigger(minutes=15), id=JOB_REAP, replace_existing=True, max_instances=1, coalesce=True, ) scheduler.add_job( job_morning_briefing, CronTrigger( hour=telegram.morning_briefing_time.hour, minute=telegram.morning_briefing_time.minute, timezone=tz_name, ), id=JOB_MORNING, replace_existing=True, ) scheduler.add_job( job_daily_report, CronTrigger( hour=telegram.daily_report_time.hour, minute=telegram.daily_report_time.minute, timezone=tz_name, ), id=JOB_DAILY, replace_existing=True, ) scheduler.add_job( job_weekly_report, CronTrigger( day_of_week=telegram.weekly_report_weekday, hour=telegram.daily_report_time.hour, minute=min(telegram.daily_report_time.minute + 5, 59), timezone=tz_name, ), id=JOB_WEEKLY, replace_existing=True, ) scheduler.add_job( job_nudge, IntervalTrigger(minutes=10), id=JOB_NUDGE, replace_existing=True, max_instances=1, coalesce=True, ) def start(settings: AppSettings) -> BackgroundScheduler | None: """Start the scheduler and re-plan it on every settings change.""" global _scheduler if not env_settings().scheduler_enabled: log.info("scheduler disabled by configuration") return None if _scheduler is not None: return _scheduler scheduler = BackgroundScheduler(timezone=settings.timezone) _plan(scheduler, settings) scheduler.start() _scheduler = scheduler settings_service.on_change(lambda new_settings: _plan(scheduler, new_settings)) log.info("scheduler started with %s jobs", len(scheduler.get_jobs())) return scheduler def shutdown() -> None: global _scheduler if _scheduler is not None: _scheduler.shutdown(wait=False) _scheduler = None def describe() -> list[dict[str, str]]: if _scheduler is None: return [] return [ { "id": job.id, "next_run": job.next_run_time.isoformat() if job.next_run_time else "", "trigger": str(job.trigger), } for job in _scheduler.get_jobs() ]