319 lines
11 KiB
Python
319 lines
11 KiB
Python
"""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"Предлагаю: <b>{report._esc(label)}</b>")
|
|
if ru
|
|
else f"Suggested next: <b>{report._esc(label)}</b>"
|
|
)
|
|
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(
|
|
"<i>/start_work — запустить таймер</i>" if ru else "<i>/start_work to begin</i>"
|
|
)
|
|
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()
|
|
]
|