"""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()
]