init
This commit is contained in:
121 files changed
+17641
No files matched your search
@@ -0,0 +1,318 @@
|
||||
"""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()
|
||||
]
|
||||
Reference in new issue
Block a user