This commit is contained in:
timka123 committed 2026-10-18 18:02:05 +03:00
commit 17a0a3c4c9
121 files changed
+17641

No files matched your search

+1
View File
@@ -0,0 +1 @@
"""Domain services: settings, timer, task aggregation, reports, notifications."""
+320
View File
@@ -0,0 +1,320 @@
"""Badges and fun facts — the part of the report you actually read.
Rules are pure functions of a day's metrics plus a little history, so they are
trivial to extend: add a `Rule` to `RULES` and it shows up in the web report and
the Telegram message at once.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import date
from typing import Any, Callable
Metrics = dict[str, Any]
Context = dict[str, Any]
def _plural(count: int, singular: str, plural: str | None = None) -> str:
""""1 pomodoro" / "6 pomodoros" — the English side; Russian gets its own text."""
return f"{count} {singular if count == 1 else (plural or singular + 's')}"
def _ru_pomodoros(count: int) -> str:
if count % 10 == 1 and count % 100 != 11:
return f"{count} помидорка"
if count % 10 in (2, 3, 4) and count % 100 not in (12, 13, 14):
return f"{count} помидорки"
return f"{count} помидорок"
@dataclass(frozen=True, slots=True)
class Achievement:
code: str
emoji: str
title_en: str
title_ru: str
detail_en: str = ""
detail_ru: str = ""
def render(self, language: str) -> dict[str, str]:
ru = language == "ru"
return {
"code": self.code,
"emoji": self.emoji,
"title": self.title_ru if ru else self.title_en,
"detail": (self.detail_ru if ru else self.detail_en),
}
@dataclass(frozen=True, slots=True)
class Rule:
code: str
emoji: str
title_en: str
title_ru: str
test: Callable[[Metrics, Context], bool]
detail: Callable[[Metrics, Context], tuple[str, str]] | None = None
def _nothing(_m: Metrics, _c: Context) -> tuple[str, str]:
return "", ""
RULES: list[Rule] = [
Rule(
"goal_met",
"🎯",
"Daily goal met",
"Дневная цель взята",
lambda m, c: m["goal_met"],
lambda m, c: (
f"{m['pomodoros']}/{m['goal']} pomodoros.",
f"{m['pomodoros']}/{m['goal']} помидорок.",
),
),
Rule(
"double_goal",
"🔥",
"Double the goal",
"Двойная норма",
lambda m, c: m["goal"] > 0 and m["pomodoros"] >= 2 * m["goal"],
),
Rule(
"early_bird",
"🌅",
"Early bird",
"Ранняя пташка",
lambda m, c: bool(m["first_start"]) and m["first_start"] < "08:00",
lambda m, c: (
f"First pomodoro at {m['first_start']}.",
f"Первая помидорка в {m['first_start']}.",
),
),
Rule(
"night_owl",
"🦉",
"Night owl",
"Ночная сова",
lambda m, c: bool(m["last_end"]) and m["last_end"] > "23:00",
),
Rule(
"deep_work",
"🧘",
"Deep work",
"Глубокая работа",
lambda m, c: m["longest_stretch_minutes"] >= 90,
lambda m, c: (
f"{m['longest_stretch_minutes']} minutes without breaking the chain.",
f"{m['longest_stretch_minutes']} минут без разрыва цепочки.",
),
),
Rule(
"flawless",
"💎",
"Flawless",
"Безупречно",
lambda m, c: m["pomodoros"] >= 4 and m["abandoned"] == 0 and m["interruptions"] == 0,
),
Rule(
"task_crusher",
"✅",
"Task crusher",
"Крушитель задач",
lambda m, c: m["tasks_completed_count"] >= 5,
lambda m, c: (
_plural(m["tasks_completed_count"], "task") + " closed.",
f"{m['tasks_completed_count']} задач закрыто.",
),
),
Rule(
"gatekeeper",
"🛡️",
"Gatekeeper",
"Страж",
lambda m, c: m["blocked_attempts"] >= 5,
lambda m, c: (
f"The blocker turned you away {m['blocked_attempts']} times. It won.",
f"Блокировщик развернул тебя {m['blocked_attempts']} раз. И выиграл.",
),
),
Rule(
"well_rested",
"☕",
"Took the breaks",
"Отдыхал как надо",
lambda m, c: m["pomodoros"] >= 4 and m["breaks_taken"] >= m["pomodoros"] - 1,
),
Rule(
"streak_3",
"📆",
"3-day streak",
"Серия 3 дня",
lambda m, c: c.get("streak", {}).get("current", 0) >= 3,
lambda m, c: (
f"{c['streak']['current']} days in a row with work logged.",
f"{c['streak']['current']} дней подряд с работой.",
),
),
Rule(
"streak_7",
"🏆",
"Week-long streak",
"Неделя подряд",
lambda m, c: c.get("streak", {}).get("current", 0) >= 7,
),
Rule(
"personal_best",
"🚀",
"Personal best",
"Личный рекорд",
lambda m, c: m["pomodoros"] > 0 and m["pomodoros"] > c.get("previous_best", 0),
lambda m, c: (
"Your best day ever: " + _plural(m["pomodoros"], "pomodoro") + ".",
f"Лучший день: {_ru_pomodoros(m['pomodoros'])}.",
),
),
Rule(
"comeback",
"🪃",
"Comeback",
"Возвращение",
lambda m, c: m["pomodoros"] >= 4 and c.get("yesterday_pomodoros", 0) == 0,
),
]
def evaluate(metrics: Metrics, context: Context, language: str = "en") -> list[dict[str, str]]:
earned: list[dict[str, str]] = []
for rule in RULES:
try:
if not rule.test(metrics, context):
continue
except Exception: # pragma: no cover - a bad rule must not sink the report
logging.getLogger(__name__).debug("rule %s failed", rule.code, exc_info=True)
continue
detail_en, detail_ru = (rule.detail or _nothing)(metrics, context)
earned.append(
Achievement(
rule.code, rule.emoji, rule.title_en, rule.title_ru, detail_en, detail_ru
).render(language)
)
return earned
# --------------------------------------------------------------------------
# Fun facts
# --------------------------------------------------------------------------
def fun_facts(metrics: Metrics, context: Context, language: str = "en") -> list[str]:
"""One-liners that make the numbers land. Only the true ones are returned."""
ru = language == "ru"
facts: list[str] = []
focus = metrics["focus_minutes"]
pomodoros = metrics["pomodoros"]
if focus:
episodes = round(focus / 22, 1)
facts.append(
f"🍅 {focus} минут фокуса — это {episodes} серий ситкома, которые ты не посмотрел."
if ru
else f"🍅 {focus} minutes of focus — that is {episodes} sitcom episodes you did not watch."
)
if metrics["blocked_attempts"]:
attempts = metrics["blocked_attempts"]
top = metrics["blocked_top"][0]["target"] if metrics["blocked_top"] else "?"
facts.append(
f"🚫 {attempts} попыток сбежать, чаще всего в {top}. Не вышло."
if ru
else f"🚫 {attempts} escape attempts, mostly to {top}. None made it through."
)
if metrics["interruptions"]:
facts.append(
f"📵 {metrics['interruptions']} прерываний. Каждое стоит ~23 минуты концентрации — по науке."
if ru
else (
"📵 " + _plural(metrics["interruptions"], "interruption")
+ ". Research prices each one at ~23 minutes of refocusing."
)
)
if metrics["peak_hour"] is not None and pomodoros >= 2:
hour = metrics["peak_hour"]
facts.append(
f"⏰ Пик продуктивности — {hour:02d}:00. Защищай это время."
if ru
else f"⏰ Your peak hour was {hour:02d}:00. Defend it."
)
if metrics["wasted_minutes"] >= 10:
facts.append(
f"💨 {metrics['wasted_minutes']} минут ушло в брошенные помидорки."
if ru
else f"💨 {metrics['wasted_minutes']} minutes went into pomodoros you dropped."
)
if pomodoros and metrics["breaks_taken"] < pomodoros - 1:
skipped = pomodoros - 1 - metrics["breaks_taken"]
facts.append(
f"⚠️ Пропущено перерывов: {skipped}. Мозг это запомнит."
if ru
else "⚠️ You skipped " + _plural(skipped, "break") + ". Your brain keeps score."
)
average = context.get("average_7d", {}).get("pomodoros", 0)
if average and pomodoros:
delta = round(100 * (pomodoros - average) / average)
if abs(delta) >= 10:
better = delta > 0
facts.append(
(f"📈 На {abs(delta)}% {'выше' if better else 'ниже'} твоего среднего за неделю.")
if ru
else (
f"📈 {abs(delta)}% {'above' if better else 'below'} your 7-day average."
)
)
lifetime_hours = context.get("lifetime", {}).get("focus_hours", 0)
if lifetime_hours >= 1:
facts.append(
f"🏛️ Всего в приложении: {lifetime_hours} часов фокуса."
if ru
else f"🏛️ All-time focus logged here: {lifetime_hours} hours."
)
return facts
def verdict(metrics: Metrics, tone: str, language: str = "en") -> str:
"""The closing line. Four tones, because motivation is a personal taste."""
ru = language == "ru"
pct = metrics["goal_pct"]
table = {
"friendly": [
(100, "Goal met. Enjoy the evening — you earned it. 🎉", "Цель взята. Отдыхай, заслужил. 🎉"),
(60, "Solid day. Tomorrow you close the gap. 🙂", "Хороший день. Завтра добьёшь остаток. 🙂"),
(1, "Something beats nothing. Start tomorrow with one pomodoro.", "Что-то лучше, чем ничего. Завтра начни с одной помидорки."),
(0, "Nothing logged today. No drama — just start the timer tomorrow.", "Сегодня пусто. Без драмы — просто запусти таймер завтра."),
],
"coach": [
(100, "Target hit. Now protect the streak. 💪", "Цель закрыта. Теперь держи серию. 💪"),
(60, "Close. One more block would have done it. Plan it in.", "Почти. Ещё один блок — и всё. Запланируй."),
(1, "You started, which is the hard part. Scale it tomorrow.", "Ты начал — это самое сложное. Завтра увеличивай."),
(0, "Zero. Pick the smallest task and run one 10-minute timer tomorrow.", "Ноль. Завтра — самая мелкая задача и таймер на 10 минут."),
],
"drill_sergeant": [
(100, "Acceptable. Do it again tomorrow. 🫡", "Принято. Завтра повторить. 🫡"),
(60, "Half-measures. The goal was the goal.", "Полумеры. Цель была целью."),
(1, "That was a warm-up, not a day of work.", "Это была разминка, а не рабочий день."),
(0, "Nothing. Zero. The timer was right there.", "Ничего. Ноль. Таймер был прямо здесь."),
],
"dry": [
(100, "Goal reached.", "Цель достигнута."),
(60, "Goal partially reached.", "Цель достигнута частично."),
(1, "Minimal activity recorded.", "Зафиксирована минимальная активность."),
(0, "No activity recorded.", "Активность не зафиксирована."),
],
}
rows = table.get(tone, table["friendly"])
for threshold, text_en, text_ru in rows:
if pct >= threshold:
return text_ru if ru else text_en
return ""
def previous_best(day_counts: dict[date, int], until: date) -> int:
return max((count for day, count in day_counts.items() if day < until), default=0)
+165
View File
@@ -0,0 +1,165 @@
"""Blocking policy.
The backend decides *whether* blocking should be on right now and *what* is on
the list; the desktop app is the thing that actually enforces it (it is the only
piece running on your machine with the privileges to rewrite `/etc/hosts` or
kill a process). Keeping the policy here means the web UI can edit the rules,
and a second machine running the desktop app obeys the same list.
"""
from __future__ import annotations
from datetime import date, datetime
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models import BlockEvent
from app.services import pomodoro
from app.settings_schema import AppSettings
from app.timeutil import ensure_utc, in_window, local_date, local_time
def expand_domains(settings: AppSettings) -> list[str]:
"""Every hostname the desktop app should point at the redirect IP.
A rule like `youtube.com` is meant as "that site", so `www.` and the common
subdomains come along; anything in `allowed_domains` is removed again, which
is how `youtube.com` blocked + `music.youtube.com` allowed works.
"""
blocker = settings.blocker
allowed = {entry.strip().lower().lstrip(".") for entry in blocker.allowed_domains if entry.strip()}
hosts: list[str] = []
for raw in blocker.blocked_domains:
domain = raw.strip().lower().lstrip(".")
if not domain or domain in allowed:
continue
for candidate in (domain, f"www.{domain}", f"m.{domain}"):
if candidate not in allowed and candidate not in hosts:
hosts.append(candidate)
return hosts
def schedule_active(settings: AppSettings, moment: datetime | None = None) -> bool:
now_time = local_time(settings.timezone, moment)
weekday = (moment or datetime.now()).weekday()
for window in settings.blocker.schedule:
if weekday in window.days and in_window(now_time, window.start, window.end):
return True
return False
def policy(session: Session, settings: AppSettings) -> dict[str, Any]:
"""What the desktop app polls: should I block, and what."""
blocker = settings.blocker
current = pomodoro.current_session(session)
active = False
reason = "disabled"
if blocker.enabled:
if blocker.mode == "always":
active, reason = True, "always on"
elif blocker.mode == "scheduled":
active = schedule_active(settings)
reason = "inside a blocking window" if active else "outside every window"
else: # during_work
if current is not None and current.kind == "work" and current.status in ("running", "paused"):
active, reason = True, "a work interval is running"
elif (
current is not None
and current.kind != "work"
and blocker.block_during_breaks
and current.status in ("running", "paused")
):
active, reason = True, "blocking stays on through breaks"
else:
reason = "no work interval is running"
return {
"active": active,
"reason": reason,
"mode": blocker.mode,
"enabled": blocker.enabled,
"strict": blocker.strict,
"redirect_ip": blocker.redirect_ip,
"domains": expand_domains(settings) if active else [],
"apps": [app.strip() for app in blocker.blocked_apps if app.strip()] if active else [],
"kill_apps": blocker.kill_blocked_apps,
"notify_on_block": blocker.notify_on_block,
"poll_seconds": blocker.app_poll_seconds,
"session_id": current.id if current else None,
"all_domains": expand_domains(settings),
"all_apps": [app.strip() for app in blocker.blocked_apps if app.strip()],
}
def record_event(
session: Session,
settings: AppSettings,
*,
kind: str,
target: str,
action: str = "blocked",
details: str | None = None,
) -> BlockEvent:
current = pomodoro.current_session(session)
event = BlockEvent(
kind=kind,
target=target.strip().lower(),
action=action,
details=details,
local_date=local_date(settings.timezone),
session_id=current.id if current else None,
)
session.add(event)
session.commit()
return event
def events_for_day(session: Session, settings: AppSettings, day: date | None = None) -> dict[str, Any]:
day = day or local_date(settings.timezone)
rows = list(session.scalars(select(BlockEvent).where(BlockEvent.local_date == day)))
by_target = dict(
session.execute(
select(BlockEvent.target, func.count(BlockEvent.id))
.where(BlockEvent.local_date == day)
.group_by(BlockEvent.target)
.order_by(func.count(BlockEvent.id).desc())
).all()
)
return {
"date": day.isoformat(),
"total": len(rows),
"by_target": [{"target": target, "count": count} for target, count in by_target.items()],
"events": [
{
"id": row.id,
"kind": row.kind,
"target": row.target,
"action": row.action,
"at": ensure_utc(row.at).isoformat(),
"details": row.details,
}
for row in sorted(rows, key=lambda row: ensure_utc(row.at), reverse=True)[:100]
],
}
DEFAULT_DOMAINS = [
"youtube.com",
"twitter.com",
"x.com",
"instagram.com",
"tiktok.com",
"reddit.com",
"facebook.com",
"vk.com",
"twitch.tv",
"netflix.com",
"9gag.com",
"pikabu.ru",
]
DEFAULT_APPS = ["steam", "discord", "telegram-desktop", "spotify"]
+169
View File
@@ -0,0 +1,169 @@
"""Outbound messages: Telegram first, Slack next, the log as a last resort.
The chunking, the HTML parse mode and the "Telegram > Slack > console" order are
jira-helper's, because they are the right answers. What is added here is quiet
hours and a record of what was sent, so the nudges cannot turn into spam.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import date
import httpx
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import EventLog
from app.settings_schema import AppSettings
from app.timeutil import in_window, local_date, local_time
log = logging.getLogger(__name__)
# Telegram rejects messages longer than 4096 characters; leave headroom.
_TELEGRAM_MAX_CHARS = 4000
_TIMEOUT = 20
class NotifyError(RuntimeError):
pass
@dataclass(slots=True)
class Delivery:
channel: str
ok: bool
detail: str = ""
def as_dict(self) -> dict[str, object]:
return {"channel": self.channel, "ok": self.ok, "detail": self.detail}
def _chunk(text: str, size: int = _TELEGRAM_MAX_CHARS) -> list[str]:
"""Split on line boundaries so a message never exceeds Telegram's limit."""
chunks: list[str] = []
current = ""
for line in text.split("\n"):
candidate = f"{current}\n{line}" if current else line
if len(candidate) > size and current:
chunks.append(current)
current = line
else:
current = candidate
if current:
chunks.append(current)
return chunks or [""]
def send_telegram(
settings: AppSettings, text: str, *, reply_markup: dict | None = None
) -> Delivery:
telegram = settings.notifications.telegram
if not (telegram.enabled and telegram.bot_token and telegram.chat_id):
return Delivery("telegram", False, "Telegram is not configured.")
url = f"https://api.telegram.org/bot{telegram.bot_token}/sendMessage"
parts = _chunk(text)
try:
with httpx.Client(timeout=_TIMEOUT) as client:
for index, part in enumerate(parts):
payload = {
"chat_id": telegram.chat_id,
"text": part,
"parse_mode": "HTML",
"disable_web_page_preview": True,
}
# Buttons belong on the last chunk, where the message ends.
if reply_markup and index == len(parts) - 1:
payload["reply_markup"] = reply_markup
resp = client.post(url, json=payload)
if not resp.is_success:
return Delivery(
"telegram", False, f"{resp.status_code}: {resp.text[:200]}"
)
except httpx.HTTPError as exc:
return Delivery("telegram", False, str(exc))
return Delivery("telegram", True, f"{len(parts)} message(s)")
def send_slack(settings: AppSettings, text: str) -> Delivery:
slack = settings.notifications.slack
if not (slack.enabled and slack.webhook_url):
return Delivery("slack", False, "Slack is not configured.")
try:
with httpx.Client(timeout=_TIMEOUT) as client:
resp = client.post(slack.webhook_url, json={"text": _strip_html(text)})
if not resp.is_success:
return Delivery("slack", False, f"{resp.status_code}: {resp.text[:200]}")
except httpx.HTTPError as exc:
return Delivery("slack", False, str(exc))
return Delivery("slack", True, "ok")
def _strip_html(text: str) -> str:
import re
return re.sub(r"<[^>]+>", "", text)
def in_quiet_hours(settings: AppSettings) -> bool:
quiet = settings.notifications.telegram.quiet_hours
if not quiet.enabled:
return False
return in_window(local_time(settings.timezone), quiet.start, quiet.end)
def notify(
settings: AppSettings,
text: str,
*,
urgent: bool = False,
reply_markup: dict | None = None,
) -> list[Delivery]:
"""Send to every configured channel. `urgent` ignores quiet hours."""
if not urgent and in_quiet_hours(settings):
return [Delivery("quiet-hours", False, "suppressed by quiet hours")]
deliveries: list[Delivery] = []
telegram = send_telegram(settings, text, reply_markup=reply_markup)
if telegram.ok or telegram.detail != "Telegram is not configured.":
deliveries.append(telegram)
slack = send_slack(settings, text)
if slack.ok or slack.detail != "Slack is not configured.":
deliveries.append(slack)
if not deliveries:
log.info("notification (no channel configured):\n%s", _strip_html(text))
deliveries.append(Delivery("log", True, "written to the server log"))
return deliveries
# --------------------------------------------------------------------------
# "Did we already send this today?"
# --------------------------------------------------------------------------
def already_sent(session: Session, kind: str, day: date) -> bool:
return (
session.scalar(
select(EventLog.id).where(EventLog.kind == kind, EventLog.local_date == day)
)
is not None
)
def count_sent(session: Session, kind: str, day: date) -> int:
return len(
list(
session.scalars(
select(EventLog.id).where(EventLog.kind == kind, EventLog.local_date == day)
)
)
)
def record_sent(session: Session, kind: str, day: date, payload: dict | None = None) -> None:
session.add(EventLog(kind=kind, local_date=day, payload=payload or {}))
session.commit()
def today(settings: AppSettings) -> date:
return local_date(settings.timezone)
+354
View File
@@ -0,0 +1,354 @@
"""The timer engine.
The server owns the clock. A browser tab or the desktop window only renders
`remaining_seconds`, computed from `started_at` — so reloading the page, closing
the laptop lid or switching from the web UI to the desktop app all leave the
same interval running, and the history is never a guess.
One interval is one row. Work and breaks are both intervals, which is what
makes "you actually rested 3 of the 7 breaks you were owed" a thing the report
can say.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime, timedelta
from typing import Any, Literal
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models import PomodoroSession, TaskRecord, utcnow
from app.settings_schema import AppSettings
from app.timeutil import day_bounds_utc, ensure_utc, local_date
Kind = Literal["work", "short_break", "long_break"]
ACTIVE_STATUSES = ("running", "paused")
class TimerError(RuntimeError):
"""An operation that does not fit the timer's current state."""
# --------------------------------------------------------------------------
# Reading state
# --------------------------------------------------------------------------
def current_session(session: Session) -> PomodoroSession | None:
return session.scalar(
select(PomodoroSession)
.where(PomodoroSession.status.in_(ACTIVE_STATUSES))
.order_by(PomodoroSession.started_at.desc())
)
def elapsed_seconds(row: PomodoroSession, now: datetime | None = None) -> int:
"""Wall-clock seconds spent inside the interval, excluding paused time."""
now = now or utcnow()
if row.status == "paused" and row.paused_at is not None:
end = ensure_utc(row.paused_at)
elif row.ended_at is not None:
end = ensure_utc(row.ended_at)
else:
end = now
started = ensure_utc(row.started_at)
return max(int((end - started).total_seconds()) - row.paused_seconds, 0)
def remaining_seconds(row: PomodoroSession, now: datetime | None = None) -> int:
return max(row.planned_seconds - elapsed_seconds(row, now), 0)
def duration_for(settings: AppSettings, kind: Kind) -> int:
minutes = {
"work": settings.pomodoro.work_minutes,
"short_break": settings.pomodoro.short_break_minutes,
"long_break": settings.pomodoro.long_break_minutes,
}[kind]
return minutes * 60
def completed_work_today(session: Session, settings: AppSettings, day: date | None = None) -> int:
day = day or local_date(settings.timezone)
return int(
session.scalar(
select(func.count(PomodoroSession.id)).where(
PomodoroSession.local_date == day,
PomodoroSession.kind == "work",
PomodoroSession.status == "completed",
)
)
or 0
)
def next_kind(session: Session, settings: AppSettings) -> Kind:
"""What the timer should run next, following the long-break cycle.
After every `cycles_before_long_break` completed work intervals today the
next break is the long one. The count resets with the day, so a long break
is never "owed" from yesterday.
"""
last = session.scalar(
select(PomodoroSession)
.where(PomodoroSession.status.in_(("completed", "abandoned")))
.order_by(PomodoroSession.started_at.desc())
)
if last is None or last.kind != "work" or last.status == "abandoned":
return "work"
done = completed_work_today(session, settings)
if done and done % settings.pomodoro.cycles_before_long_break == 0:
return "long_break"
return "short_break"
# --------------------------------------------------------------------------
# Transitions
# --------------------------------------------------------------------------
def start(
session: Session,
settings: AppSettings,
*,
kind: Kind | None = None,
minutes: int | None = None,
task_id: int | None = None,
note: str | None = None,
) -> PomodoroSession:
"""Begin an interval, abandoning whatever was still running."""
running = current_session(session)
if running is not None:
_finish(session, settings, running, status="abandoned", reason="superseded")
kind = kind or next_kind(session, settings)
planned = (minutes * 60) if minutes else duration_for(settings, kind)
task_source = task_external_id = task_title = None
if task_id is not None:
task = session.get(TaskRecord, task_id)
if task is None:
raise TimerError(f"no task {task_id}")
task_source, task_external_id, task_title = task.source, task.external_id, task.title
done_today = completed_work_today(session, settings)
row = PomodoroSession(
kind=kind,
status="running",
started_at=utcnow(),
local_date=local_date(settings.timezone),
planned_seconds=planned,
cycle_index=(done_today % settings.pomodoro.cycles_before_long_break) + 1,
task_source=task_source,
task_external_id=task_external_id,
task_title=task_title,
note=note,
)
session.add(row)
session.commit()
return row
def pause(session: Session, settings: AppSettings) -> PomodoroSession:
row = _require_active(session)
if not settings.pomodoro.allow_pause:
raise TimerError("Pausing is switched off in your settings.")
if row.status == "paused":
return row
row.status = "paused"
row.paused_at = utcnow()
if settings.pomodoro.count_interruptions and row.is_work:
row.interruptions += 1
session.commit()
return row
def resume(session: Session) -> PomodoroSession:
row = _require_active(session)
if row.status != "paused" or row.paused_at is None:
return row
row.paused_seconds += max(int((utcnow() - ensure_utc(row.paused_at)).total_seconds()), 0)
row.paused_at = None
row.status = "running"
session.commit()
return row
def complete(
session: Session, settings: AppSettings, *, auto_next: bool = True
) -> tuple[PomodoroSession, PomodoroSession | None]:
"""Finish the current interval and, if configured, roll into the next one."""
row = _require_active(session)
finished = _finish(session, settings, row, status="completed")
follow_on: PomodoroSession | None = None
if auto_next:
wants_break = finished.is_work and settings.pomodoro.auto_start_breaks
wants_work = (not finished.is_work) and settings.pomodoro.auto_start_work
if wants_break or wants_work:
follow_on = start(session, settings, kind=next_kind(session, settings))
return finished, follow_on
def abandon(
session: Session, settings: AppSettings, *, reason: str | None = None
) -> PomodoroSession:
row = _require_active(session)
if settings.pomodoro.strict_mode and row.is_work and not (reason or "").strip():
raise TimerError("Strict mode: give a reason for dropping this pomodoro.")
return _finish(session, settings, row, status="abandoned", reason=reason)
def add_interruption(session: Session) -> PomodoroSession:
""""The phone rang" — counted without stopping the clock."""
row = _require_active(session)
row.interruptions += 1
session.commit()
return row
def extend(session: Session, minutes: int) -> PomodoroSession:
"""Add time to the running interval ("just five more minutes")."""
row = _require_active(session)
row.planned_seconds = max(row.planned_seconds + minutes * 60, 60)
session.commit()
return row
def attach_task(session: Session, task_id: int | None) -> PomodoroSession:
row = _require_active(session)
if task_id is None:
row.task_source = row.task_external_id = row.task_title = None
else:
task = session.get(TaskRecord, task_id)
if task is None:
raise TimerError(f"no task {task_id}")
row.task_source, row.task_external_id, row.task_title = (
task.source,
task.external_id,
task.title,
)
session.commit()
return row
def _require_active(session: Session) -> PomodoroSession:
row = current_session(session)
if row is None:
raise TimerError("No interval is running.")
return row
def _finish(
session: Session,
settings: AppSettings,
row: PomodoroSession,
*,
status: str,
reason: str | None = None,
) -> PomodoroSession:
now = utcnow()
if row.status == "paused" and row.paused_at is not None:
row.paused_seconds += max(int((now - ensure_utc(row.paused_at)).total_seconds()), 0)
row.paused_at = None
row.actual_seconds = elapsed_seconds(row, now)
row.ended_at = now
row.status = status
if reason:
row.abandon_reason = reason
session.commit()
return row
# --------------------------------------------------------------------------
# Serialisation
# --------------------------------------------------------------------------
@dataclass(slots=True)
class TimerState:
"""What the clients render. Everything derived, nothing stored twice."""
session: dict[str, Any] | None
next_kind: str
completed_today: int
goal: int
settings_version: str
def session_dict(row: PomodoroSession | None) -> dict[str, Any] | None:
if row is None:
return None
return {
"id": row.id,
"kind": row.kind,
"status": row.status,
"started_at": ensure_utc(row.started_at).isoformat(),
"ended_at": ensure_utc(row.ended_at).isoformat() if row.ended_at else None,
"planned_seconds": row.planned_seconds,
"elapsed_seconds": elapsed_seconds(row),
"remaining_seconds": remaining_seconds(row),
"paused_seconds": row.paused_seconds,
"interruptions": row.interruptions,
"cycle_index": row.cycle_index,
"task": (
{
"source": row.task_source,
"external_id": row.task_external_id,
"title": row.task_title,
}
if row.task_title
else None
),
"note": row.note,
}
def state(session: Session, settings: AppSettings) -> dict[str, Any]:
row = current_session(session)
done = completed_work_today(session, settings)
return {
"current": session_dict(row),
"next_kind": next_kind(session, settings),
"completed_today": done,
"goal": settings.pomodoro.daily_goal_pomodoros,
"cycle_position": (done % settings.pomodoro.cycles_before_long_break) + 1,
"cycles_before_long_break": settings.pomodoro.cycles_before_long_break,
"durations": {
"work": settings.pomodoro.work_minutes,
"short_break": settings.pomodoro.short_break_minutes,
"long_break": settings.pomodoro.long_break_minutes,
},
"server_time": utcnow().isoformat(),
}
def sessions_for_day(session: Session, settings: AppSettings, day: date) -> list[PomodoroSession]:
start_utc, end_utc = day_bounds_utc(day, settings.timezone)
return list(
session.scalars(
select(PomodoroSession)
.where(PomodoroSession.started_at >= start_utc, PomodoroSession.started_at < end_utc)
.order_by(PomodoroSession.started_at.asc())
)
)
def reap_stale(session: Session, settings: AppSettings, *, max_overrun_hours: int = 6) -> int:
"""Close intervals left running by a crash or a machine that went to sleep.
Without this one forgotten tab would report a 14-hour pomodoro and poison
every average in the stats.
"""
cutoff = utcnow() - timedelta(hours=max_overrun_hours)
stale = session.scalars(
select(PomodoroSession).where(
PomodoroSession.status.in_(ACTIVE_STATUSES),
PomodoroSession.started_at < cutoff,
)
).all()
for row in stale:
row.status = "abandoned"
row.ended_at = ensure_utc(row.started_at) + timedelta(seconds=row.planned_seconds)
row.actual_seconds = row.planned_seconds
row.abandon_reason = "auto-closed: left running"
if stale:
session.commit()
return len(stale)
+441
View File
@@ -0,0 +1,441 @@
"""Reports: the daily wrap-up, the morning briefing, the weekly review.
A report is built once, stored as JSON, and then *rendered* — to HTML for
Telegram or to a structure the web UI draws cards from. Because both come from
the same payload, the message in your chat and the page in the browser can
never tell you different numbers.
"""
from __future__ import annotations
import html
from datetime import date, timedelta
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models import DailyReport, PomodoroSession, utcnow
from app.services import achievements as ach
from app.services import notify, stats, tasks_service
from app.settings_schema import AppSettings
from app.sources.registry import SOURCE_LABELS
from app.timeutil import humanize_minutes, local_date
BAR_FULL = "█"
BAR_EMPTY = "░"
def _days(count: int, ru: bool) -> str:
""""1 day" / "3 days" / "1 день" / "3 дня" — plurals, cheaply."""
if ru:
if count % 10 == 1 and count % 100 != 11:
return f"{count} день"
if count % 10 in (2, 3, 4) and count % 100 not in (12, 13, 14):
return f"{count} дня"
return f"{count} дней"
return f"{count} day" if count == 1 else f"{count} days"
def _esc(value: Any) -> str:
"""Escape borrowed text for Telegram's HTML parse mode.
Without this one task summary containing `&` makes Telegram reject the
entire message — jira-helper learned that the hard way.
"""
return html.escape(str(value), quote=False)
def progress_bar(value: int, total: int, width: int = 10) -> str:
if total <= 0:
return BAR_EMPTY * width
filled = min(width, round(width * value / total))
return BAR_FULL * filled + BAR_EMPTY * (width - filled)
# --------------------------------------------------------------------------
# Daily report
# --------------------------------------------------------------------------
def build_daily(session: Session, settings: AppSettings, day: date) -> dict[str, Any]:
"""Assemble everything the daily report shows, as a plain dict."""
metrics = stats.day_metrics(session, settings, day)
streak = stats.streak(session, settings, until=day)
average_7d = stats.averages(session, settings, 7, until=day)
lifetime = stats.lifetime(session, settings)
day_counts = dict(
session.execute(
select(PomodoroSession.local_date, func.count(PomodoroSession.id))
.where(
PomodoroSession.kind == "work",
PomodoroSession.status == "completed",
)
.group_by(PomodoroSession.local_date)
).all()
)
yesterday = day - timedelta(days=1)
context = {
"streak": streak,
"average_7d": average_7d,
"lifetime": lifetime,
"previous_best": ach.previous_best(day_counts, day),
"yesterday_pomodoros": day_counts.get(yesterday, 0),
}
report_settings = settings.notifications.report
language = report_settings.language
open_tasks = tasks_service.list_tasks(session)
buckets = tasks_service.bucket_tasks(open_tasks, day)
payload: dict[str, Any] = {
"date": day.isoformat(),
"generated_at": utcnow().isoformat(),
"timezone": settings.timezone,
"metrics": metrics,
"streak": streak,
"average_7d": average_7d,
"lifetime": lifetime,
"comparison": {
"yesterday": day_counts.get(yesterday, 0),
"previous_best": context["previous_best"],
},
"achievements": (
ach.evaluate(metrics, context, language)
if report_settings.include_achievements
else []
),
"fun_facts": (
ach.fun_facts(metrics, context, language)
if report_settings.include_funny_stats
else []
),
"verdict": ach.verdict(metrics, report_settings.tone, language),
"open_tasks": {
"overdue": len(buckets["overdue"]),
"today": len(buckets["today"]),
"upcoming": len(buckets["upcoming"]),
"someday": len(buckets["someday"]),
},
"score": _score(metrics, streak),
}
return payload
def _score(metrics: dict[str, Any], streak: dict[str, int]) -> float:
"""A single 0-100 number for the dashboard's big dial.
Goal progress is most of it; finishing tasks, resisting the blocker and
keeping a streak nudge it up, dropped pomodoros pull it down.
"""
score = min(metrics["goal_pct"], 120) * 0.6
score += min(metrics["tasks_completed_count"], 6) * 3
score += min(streak.get("current", 0), 7) * 1.5
score += min(metrics["longest_stretch_minutes"] / 30, 4) * 2
score -= metrics["abandoned"] * 3
score -= max(metrics["interruptions"] - 2, 0) * 1.5
return round(max(0.0, min(100.0, score)), 1)
def render_daily_telegram(payload: dict[str, Any], settings: AppSettings) -> str:
"""The daily report as Telegram HTML."""
report_settings = settings.notifications.report
ru = report_settings.language == "ru"
metrics = payload["metrics"]
lines: list[str] = []
header = "🍅 Отчёт за день" if ru else "🍅 Daily report"
lines.append(f"<b>{header} — {payload['date']}</b>")
lines.append("")
bar = progress_bar(metrics["pomodoros"], metrics["goal"])
goal_line = (
f"{bar} <b>{metrics['pomodoros']}/{metrics['goal']}</b> "
+ ("помидорок" if ru else "pomodoros")
)
lines.append(goal_line)
lines.append(
("⏱ Фокус: " if ru else "⏱ Focus: ")
+ f"<b>{humanize_minutes(metrics['focus_minutes'])}</b>"
+ (" · перерывы: " if ru else " · breaks: ")
+ humanize_minutes(metrics["break_minutes"])
)
detail_bits: list[str] = []
if metrics["longest_stretch_minutes"]:
detail_bits.append(
("🧠 дольше всего без разрыва: " if ru else "🧠 longest stretch: ")
+ humanize_minutes(metrics["longest_stretch_minutes"])
)
if metrics["first_start"]:
detail_bits.append(
("🕘 с " if ru else "🕘 from ")
+ f"{metrics['first_start']}"
+ (" до " if ru else " to ")
+ f"{metrics['last_end']}"
)
if metrics["abandoned"]:
detail_bits.append(
("💨 брошено: " if ru else "💨 dropped: ") + str(metrics["abandoned"])
)
if metrics["interruptions"]:
detail_bits.append(
("📵 прерываний: " if ru else "📵 interruptions: ") + str(metrics["interruptions"])
)
if detail_bits:
lines.append(" · ".join(detail_bits))
streak = payload["streak"]
if streak.get("current"):
lines.append(
(f"🔥 Серия: {_days(streak['current'], True)} (рекорд {streak['best']})")
if ru
else f"🔥 Streak: {_days(streak['current'], False)} (best {streak['best']})"
)
if report_settings.include_comparison:
avg = payload["average_7d"]["pomodoros"]
if avg:
lines.append(
(f"📊 Среднее за 7 дней: {avg} · вчера: {payload['comparison']['yesterday']}")
if ru
else (
f"📊 7-day average: {avg} · yesterday: {payload['comparison']['yesterday']}"
)
)
tasks = metrics["tasks_completed"]
if report_settings.include_task_list and tasks:
lines.append("")
lines.append(
f"<b>{'✅ Закрытые задачи' if ru else '✅ Tasks closed'} ({len(tasks)})</b>"
)
for task in tasks[: report_settings.max_tasks_listed]:
label = SOURCE_LABELS.get(task["source"], task["source"])
title = _esc(task["title"])
if task.get("url"):
lines.append(f"• <a href=\"{_esc(task['url'])}\">{title}</a> <i>({label})</i>")
else:
lines.append(f"• {title} <i>({label})</i>")
if len(tasks) > report_settings.max_tasks_listed:
rest = len(tasks) - report_settings.max_tasks_listed
lines.append(("… и ещё " if ru else "… and ") + f"{rest}" + ("" if ru else " more"))
if report_settings.include_blocked_sites and metrics["blocked_top"]:
lines.append("")
lines.append(f"<b>{'🚫 Соблазны' if ru else '🚫 Temptations blocked'}</b>")
for entry in metrics["blocked_top"][:5]:
lines.append(f"• {_esc(entry['target'])} × {entry['count']}")
if payload["achievements"]:
lines.append("")
lines.append(f"<b>{'🏅 Достижения' if ru else '🏅 Achievements'}</b>")
for item in payload["achievements"]:
detail = f" — {_esc(item['detail'])}" if item.get("detail") else ""
lines.append(f"{item['emoji']} <b>{_esc(item['title'])}</b>{detail}")
if payload["fun_facts"]:
lines.append("")
for fact in payload["fun_facts"]:
lines.append(_esc(fact))
open_tasks = payload["open_tasks"]
if open_tasks["overdue"] or open_tasks["today"]:
lines.append("")
lines.append(
(f"📋 Осталось: {open_tasks['overdue']} просрочено, {open_tasks['today']} на сегодня")
if ru
else (
f"📋 Still open: {open_tasks['overdue']} overdue, "
f"{open_tasks['today']} due today"
)
)
if payload["verdict"]:
lines.append("")
lines.append(f"<i>{_esc(payload['verdict'])}</i>")
return "\n".join(lines)
def get_or_build_daily(
session: Session, settings: AppSettings, day: date, *, rebuild: bool = False
) -> tuple[DailyReport, dict[str, Any]]:
row = session.scalar(select(DailyReport).where(DailyReport.report_date == day))
if row is not None and not rebuild:
return row, row.payload
payload = build_daily(session, settings, day)
if row is None:
row = DailyReport(report_date=day, payload=payload, score=payload["score"])
session.add(row)
else:
row.payload = payload
row.score = payload["score"]
row.generated_at = utcnow()
session.commit()
return row, payload
def send_daily(
session: Session, settings: AppSettings, day: date, *, force: bool = False
) -> dict[str, Any]:
"""Build (or reuse) the day's report and push it to the channels."""
row, payload = get_or_build_daily(session, settings, day, rebuild=True)
if row.sent_at is not None and not force:
return {"sent": False, "reason": "already sent", "payload": payload}
text = render_daily_telegram(payload, settings)
deliveries = notify.notify(settings, text, urgent=True)
ok = any(delivery.ok for delivery in deliveries)
if ok:
row.sent_at = utcnow()
row.sent_channel = ",".join(d.channel for d in deliveries if d.ok)
session.commit()
return {
"sent": ok,
"deliveries": [d.as_dict() for d in deliveries],
"payload": payload,
"text": text,
}
# --------------------------------------------------------------------------
# Morning briefing
# --------------------------------------------------------------------------
def render_morning_briefing(session: Session, settings: AppSettings) -> str:
""""Here is your day" — the todo list, the streak, and the goal to beat."""
ru = settings.notifications.report.language == "ru"
today = local_date(settings.timezone)
tasks = tasks_service.list_tasks(session)
buckets = tasks_service.bucket_tasks(tasks, today)
streak = stats.streak(session, settings, until=today - timedelta(days=1))
limit = settings.notifications.report.max_tasks_listed
lines = [f"<b>{'☀️ Доброе утро!' if ru else '☀️ Good morning!'}</b>", ""]
goal = settings.pomodoro.daily_goal_pomodoros
lines.append(
(f"🎯 Цель на сегодня: <b>{goal}</b> помидорок "
f"(~{humanize_minutes(goal * settings.pomodoro.work_minutes)} фокуса)")
if ru
else (
f"🎯 Today's goal: <b>{goal}</b> pomodoros "
f"(~{humanize_minutes(goal * settings.pomodoro.work_minutes)} of focus)"
)
)
if streak.get("current"):
lines.append(
f"🔥 {'Серия' if ru else 'Streak'}: {_days(streak['current'], ru)}"
+ (" — не разрывай" if ru else " — keep it alive")
)
def section(title: str, records: list, emoji: str) -> None:
if not records:
return
lines.append("")
lines.append(f"<b>{emoji} {title} ({len(records)})</b>")
for record in records[:limit]:
label = SOURCE_LABELS.get(record.source, record.source)
title_text = _esc(record.title)
due = ""
if record.due_date and record.due_date < today:
days = (today - record.due_date).days
due = (
f" — <b>{days} дн. просрочки</b>" if ru else f" — <b>{days}d overdue</b>"
)
if record.url:
lines.append(
f"• <a href=\"{_esc(record.url)}\">{title_text}</a> <i>({label})</i>{due}"
)
else:
lines.append(f"• {title_text} <i>({label})</i>{due}")
if len(records) > limit:
lines.append(("… и ещё " if ru else "… and ") + f"{len(records) - limit}")
section("Просрочено" if ru else "Overdue", buckets["overdue"], "🔴")
section("На сегодня" if ru else "Due today", buckets["today"], "🟡")
section("Скоро" if ru else "Coming up", buckets["upcoming"][:5], "🔵")
laundry = [task for task in tasks if task.source == "laundry"]
if laundry:
lines.append("")
lines.append(f"<b>{'🧺 Стирка' if ru else '🧺 Laundry'}</b>")
for slot in laundry:
lines.append(f"• {_esc(slot.title)}")
if not any(buckets.values()):
lines.append("")
lines.append(
"✨ Входящих нет. Отличный день, чтобы поработать над чем-то важным."
if ru
else "✨ Nothing queued. A good day to work on something that matters."
)
lines.append("")
lines.append(
"<i>/start_work — запустить помидорку, /today — список, /report — отчёт</i>"
if ru
else "<i>/start_work to begin, /today for the list, /report for the numbers</i>"
)
return "\n".join(lines)
# --------------------------------------------------------------------------
# Weekly review
# --------------------------------------------------------------------------
def build_weekly(session: Session, settings: AppSettings, end_day: date) -> dict[str, Any]:
start_day = end_day - timedelta(days=6)
days = stats.range_metrics(session, settings, start_day, end_day)
total = sum(day["pomodoros"] for day in days)
focus = sum(day["focus_minutes"] for day in days)
best = max(days, key=lambda day: day["pomodoros"]) if days else None
goal_days = sum(1 for day in days if day["goal_met"])
return {
"start": start_day.isoformat(),
"end": end_day.isoformat(),
"days": days,
"pomodoros": total,
"focus_minutes": focus,
"best_day": best,
"goal_days": goal_days,
"average": round(total / len(days), 1) if days else 0,
"streak": stats.streak(session, settings, until=end_day),
"lifetime": stats.lifetime(session, settings),
}
def render_weekly_telegram(payload: dict[str, Any], settings: AppSettings) -> str:
ru = settings.notifications.report.language == "ru"
lines = [
f"<b>{'📅 Итоги недели' if ru else '📅 Weekly review'} "
f"({payload['start']} → {payload['end']})</b>",
"",
("🍅 Помидорок: " if ru else "🍅 Pomodoros: ")
+ f"<b>{payload['pomodoros']}</b>"
+ (" · в среднем " if ru else " · average ")
+ f"{payload['average']}/"
+ ("день" if ru else "day"),
("⏱ Фокус: " if ru else "⏱ Focus: ") + humanize_minutes(payload["focus_minutes"]),
("🎯 Дней с выполненной целью: " if ru else "🎯 Days the goal was met: ")
+ f"{payload['goal_days']}/7",
]
if payload["best_day"] and payload["best_day"]["pomodoros"]:
best = payload["best_day"]
lines.append(
("🏆 Лучший день: " if ru else "🏆 Best day: ")
+ f"{best['date']} ({best['pomodoros']} 🍅)"
)
lines.append("")
lines.append(f"<b>{'По дням' if ru else 'Day by day'}</b>")
for day in payload["days"]:
bar = progress_bar(day["pomodoros"], settings.pomodoro.daily_goal_pomodoros, width=8)
lines.append(f"<code>{day['date'][5:]} {bar}</code> {day['pomodoros']}")
streak = payload["streak"]
if streak.get("current"):
lines.append("")
lines.append(
f"🔥 {'Серия' if ru else 'Streak'}: {_days(streak['current'], ru)}"
+ f" · {'рекорд' if ru else 'best'}: {streak['best']}"
)
return "\n".join(lines)
+318
View File
@@ -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()
]
+184
View File
@@ -0,0 +1,184 @@
"""Reading and writing the settings document.
Three jobs:
* seed the document from environment variables the first time the app boots, so
a filled-in `.env` (the same variables jira-helper uses) is a working setup;
* hand the API a copy with secrets masked, and merge an edited copy back without
the UI ever having to know a real token;
* notify the rest of the process when something changed, so the scheduler can
re-plan its jobs.
"""
from __future__ import annotations
import threading
from typing import Any, Callable
from sqlalchemy.orm import Session
from app.config import env_settings
from app.models import SettingsRow
from app.settings_schema import SECRET_FIELDS, SECRET_MASK, AppSettings
_SETTINGS_ID = 1
_listeners: list[Callable[[AppSettings], None]] = []
_lock = threading.Lock()
def on_change(listener: Callable[[AppSettings], None]) -> None:
"""Register a callback fired after every successful settings save."""
_listeners.append(listener)
def _csv(value: str) -> list[str]:
return [part.strip() for part in value.split(",") if part.strip()]
def settings_from_env() -> AppSettings:
"""Build the initial document out of the environment.
The variable names are deliberately the ones from `~/code/jira-helper`, so
an existing `.env` can be copied across untouched.
"""
env = env_settings()
settings = AppSettings(timezone=env.timezone)
jira = settings.sources.jira
if env.jira_base_url and env.jira_email and env.jira_api_token:
jira.enabled = True
jira.base_url = env.jira_base_url.rstrip("/")
jira.email = env.jira_email
jira.api_token = env.jira_api_token
jira.extra_jql = env.jira_extra_jql
jira.start_date_field = env.jira_start_date_field or jira.start_date_field
google = settings.sources.google_tasks
if env.google_client_id and env.google_client_secret and env.google_refresh_token:
google.enabled = True
google.client_id = env.google_client_id
google.client_secret = env.google_client_secret
google.refresh_token = env.google_refresh_token
google.lists = _csv(env.google_tasks_lists)
todoist = settings.sources.todoist
if env.todoist_api_token:
todoist.enabled = True
todoist.api_token = env.todoist_api_token
todoist.projects = _csv(env.todoist_projects)
laundry = settings.sources.laundry
if env.laundry_session:
laundry.enabled = True
laundry.session_cookie = env.laundry_session
laundry.base_url = env.laundry_base_url.rstrip("/")
telegram = settings.notifications.telegram
if env.telegram_bot_token and env.telegram_chat_id:
telegram.enabled = True
telegram.bot_token = env.telegram_bot_token
telegram.chat_id = env.telegram_chat_id
if env.slack_webhook_url:
settings.notifications.slack.enabled = True
settings.notifications.slack.webhook_url = env.slack_webhook_url
return settings
def load_settings(session: Session) -> AppSettings:
"""The current settings, creating the row from the environment if absent."""
row = session.get(SettingsRow, _SETTINGS_ID)
if row is None:
settings = settings_from_env()
row = SettingsRow(id=_SETTINGS_ID, data=settings.model_dump(mode="json"))
session.add(row)
session.commit()
return settings
try:
return AppSettings.model_validate(row.data)
except Exception:
# A settings document written by an older build can be missing keys or
# carry ones that no longer exist; defaults fill the gaps rather than
# taking the whole app down.
return AppSettings.model_validate(_merge(AppSettings().model_dump(mode="json"), row.data))
def save_settings(session: Session, settings: AppSettings) -> AppSettings:
row = session.get(SettingsRow, _SETTINGS_ID)
data = settings.model_dump(mode="json")
if row is None:
row = SettingsRow(id=_SETTINGS_ID, data=data)
session.add(row)
else:
row.data = data
session.commit()
with _lock:
listeners = list(_listeners)
for listener in listeners:
try:
listener(settings)
except Exception: # pragma: no cover - a bad listener must not block saving
import logging
logging.getLogger(__name__).exception("settings listener failed")
return settings
# --------------------------------------------------------------------------
# Secret masking
# --------------------------------------------------------------------------
def _walk(node: Any, fn: Callable[[str, Any], Any]) -> Any:
if isinstance(node, dict):
return {key: fn(key, _walk(value, fn)) for key, value in node.items()}
if isinstance(node, list):
return [_walk(item, fn) for item in node]
return node
def mask_secrets(data: dict[str, Any]) -> dict[str, Any]:
"""Replace every configured secret that has a value with the mask."""
def mask(key: str, value: Any) -> Any:
if key in SECRET_FIELDS and isinstance(value, str) and value:
return SECRET_MASK
return value
return _walk(data, mask)
def _merge(base: Any, incoming: Any) -> Any:
"""Deep-merge `incoming` onto `base`, keeping `base` where a key is missing."""
if isinstance(base, dict) and isinstance(incoming, dict):
merged = dict(base)
for key, value in incoming.items():
merged[key] = _merge(base.get(key), value) if key in base else value
return merged
return incoming
def unmask_secrets(stored: dict[str, Any], incoming: dict[str, Any]) -> dict[str, Any]:
"""Put the stored secret back wherever the client sent the mask untouched."""
def restore(node_stored: Any, node_in: Any) -> Any:
if isinstance(node_in, dict):
out: dict[str, Any] = {}
for key, value in node_in.items():
old = node_stored.get(key) if isinstance(node_stored, dict) else None
if key in SECRET_FIELDS and value == SECRET_MASK:
out[key] = old or ""
else:
out[key] = restore(old, value)
return out
return node_in
return restore(stored, incoming)
def apply_patch(session: Session, patch: dict[str, Any]) -> AppSettings:
"""Merge a partial settings document (as the UI sends it) and store it."""
current = load_settings(session).model_dump(mode="json")
patch = unmask_secrets(current, patch)
merged = _merge(current, patch)
return save_settings(session, AppSettings.model_validate(merged))
+284
View File
@@ -0,0 +1,284 @@
"""Turning rows into numbers: a day, a range, streaks, and a heatmap.
Everything the report and the dashboard show comes from here, so the web UI and
the Telegram message can never disagree about how many pomodoros you did.
"""
from __future__ import annotations
from collections import Counter
from datetime import date, datetime, timedelta
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models import BlockEvent, PomodoroSession, TaskRecord
from app.settings_schema import AppSettings
from app.timeutil import local_date, to_local
# Two work intervals closer together than this count as one unbroken stretch.
_STREAK_GAP_SECONDS = 20 * 60
def _focus_blocks(rows: list[PomodoroSession], tz_name: str) -> list[tuple[datetime, datetime]]:
blocks: list[tuple[datetime, datetime]] = []
for row in rows:
if row.kind != "work" or row.status != "completed" or row.ended_at is None:
continue
blocks.append((to_local(row.started_at, tz_name), to_local(row.ended_at, tz_name)))
return sorted(blocks)
def longest_focus_stretch_minutes(rows: list[PomodoroSession], tz_name: str) -> int:
"""The longest run of back-to-back pomodoros, in minutes of actual focus."""
blocks = _focus_blocks(rows, tz_name)
if not blocks:
return 0
best = current = (blocks[0][1] - blocks[0][0]).total_seconds()
previous_end = blocks[0][1]
for start, end in blocks[1:]:
gap = (start - previous_end).total_seconds()
length = (end - start).total_seconds()
current = current + length if gap <= _STREAK_GAP_SECONDS else length
best = max(best, current)
previous_end = end
return int(best // 60)
def day_metrics(session: Session, settings: AppSettings, day: date) -> dict[str, Any]:
"""Everything measurable about one day."""
tz_name = settings.timezone
rows = list(
session.scalars(
select(PomodoroSession)
.where(PomodoroSession.local_date == day)
.order_by(PomodoroSession.started_at.asc())
)
)
work_done = [r for r in rows if r.kind == "work" and r.status == "completed"]
work_dropped = [r for r in rows if r.kind == "work" and r.status == "abandoned"]
breaks_done = [r for r in rows if r.kind != "work" and r.status == "completed"]
focus_seconds = sum(r.actual_seconds for r in work_done)
break_seconds = sum(r.actual_seconds for r in breaks_done)
dropped_seconds = sum(r.actual_seconds for r in work_dropped)
by_hour = Counter(to_local(r.started_at, tz_name).hour for r in work_done)
tasks = list(
session.scalars(
select(TaskRecord).where(
TaskRecord.completed_date == day, TaskRecord.completed_here.is_(True)
)
)
)
blocks = list(
session.scalars(select(BlockEvent).where(BlockEvent.local_date == day))
)
block_counter = Counter(event.target for event in blocks)
pomodoro_time_by_task: Counter[str] = Counter()
for row in work_done:
label = row.task_title or "Unassigned"
pomodoro_time_by_task[label] += row.actual_seconds
first = to_local(work_done[0].started_at, tz_name) if work_done else None
last_row = work_done[-1] if work_done else None
last = to_local(last_row.ended_at or last_row.started_at, tz_name) if last_row else None
goal = settings.pomodoro.daily_goal_pomodoros
completed = len(work_done)
planned_seconds = sum(r.planned_seconds for r in work_done)
return {
"date": day.isoformat(),
"pomodoros": completed,
"goal": goal,
"goal_pct": round(100 * completed / goal) if goal else 0,
"goal_met": completed >= goal,
"focus_minutes": round(focus_seconds / 60),
"break_minutes": round(break_seconds / 60),
"wasted_minutes": round(dropped_seconds / 60),
"abandoned": len(work_dropped),
"abandon_reasons": [r.abandon_reason for r in work_dropped if r.abandon_reason],
"breaks_taken": len(breaks_done),
"breaks_owed": completed,
"interruptions": sum(r.interruptions for r in rows),
"completion_rate": (
round(100 * focus_seconds / planned_seconds) if planned_seconds else 0
),
"longest_stretch_minutes": longest_focus_stretch_minutes(rows, tz_name),
"first_start": first.strftime("%H:%M") if first else None,
"last_end": last.strftime("%H:%M") if last else None,
"by_hour": [{"hour": hour, "count": by_hour.get(hour, 0)} for hour in range(24)],
"peak_hour": (by_hour.most_common(1)[0][0] if by_hour else None),
"tasks_completed": [
{
"id": task.id,
"title": task.title,
"source": task.source,
"project": task.project,
"url": task.url,
}
for task in tasks
],
"tasks_completed_count": len(tasks),
"time_by_task": [
{"title": title, "minutes": round(seconds / 60)}
for title, seconds in pomodoro_time_by_task.most_common(10)
],
"blocked_attempts": sum(block_counter.values()),
"blocked_top": [
{"target": target, "count": count} for target, count in block_counter.most_common(8)
],
"sessions": [
{
"id": row.id,
"kind": row.kind,
"status": row.status,
"started_at": to_local(row.started_at, tz_name).isoformat(),
"minutes": round(row.actual_seconds / 60),
"task": row.task_title,
"interruptions": row.interruptions,
}
for row in rows
],
}
def range_metrics(
session: Session, settings: AppSettings, start: date, end: date
) -> list[dict[str, Any]]:
"""Per-day metrics for `[start, end]`, inclusive, cheaply (one query)."""
rows = list(
session.scalars(
select(PomodoroSession).where(
PomodoroSession.local_date >= start,
PomodoroSession.local_date <= end,
PomodoroSession.kind == "work",
PomodoroSession.status == "completed",
)
)
)
per_day: dict[date, list[PomodoroSession]] = {}
for row in rows:
per_day.setdefault(row.local_date, []).append(row)
out: list[dict[str, Any]] = []
cursor = start
while cursor <= end:
day_rows = per_day.get(cursor, [])
out.append(
{
"date": cursor.isoformat(),
"pomodoros": len(day_rows),
"focus_minutes": round(sum(r.actual_seconds for r in day_rows) / 60),
"goal_met": len(day_rows) >= settings.pomodoro.daily_goal_pomodoros,
}
)
cursor += timedelta(days=1)
return out
def averages(session: Session, settings: AppSettings, days: int, *, until: date) -> dict[str, float]:
"""Mean pomodoros/focus over the `days` days ending the day before `until`."""
start = until - timedelta(days=days)
end = until - timedelta(days=1)
if end < start:
return {"pomodoros": 0.0, "focus_minutes": 0.0}
rows = range_metrics(session, settings, start, end)
if not rows:
return {"pomodoros": 0.0, "focus_minutes": 0.0}
return {
"pomodoros": round(sum(r["pomodoros"] for r in rows) / len(rows), 1),
"focus_minutes": round(sum(r["focus_minutes"] for r in rows) / len(rows), 1),
}
def streak(session: Session, settings: AppSettings, *, until: date | None = None) -> dict[str, int]:
"""Consecutive days ending at `until` with at least one pomodoro, and with the goal met.
Today only breaks a streak once it is over, so a day with nothing on it yet
keeps yesterday's streak alive.
"""
until = until or local_date(settings.timezone)
days = list(
session.scalars(
select(PomodoroSession.local_date)
.where(
PomodoroSession.kind == "work",
PomodoroSession.status == "completed",
PomodoroSession.local_date <= until,
)
.group_by(PomodoroSession.local_date)
)
)
counts = dict(
session.execute(
select(PomodoroSession.local_date, func.count(PomodoroSession.id))
.where(
PomodoroSession.kind == "work",
PomodoroSession.status == "completed",
PomodoroSession.local_date <= until,
)
.group_by(PomodoroSession.local_date)
).all()
)
active = set(days)
def run(from_day: date, predicate) -> int:
length, cursor = 0, from_day
while predicate(cursor):
length += 1
cursor -= timedelta(days=1)
return length
goal = settings.pomodoro.daily_goal_pomodoros
anchor = until if until in active else until - timedelta(days=1)
goal_anchor = (
until if counts.get(until, 0) >= goal else until - timedelta(days=1)
)
return {
"current": run(anchor, lambda day: day in active),
"goal_streak": run(goal_anchor, lambda day: counts.get(day, 0) >= goal),
"best": _best_streak(sorted(active)),
"total_days": len(active),
}
def _best_streak(days: list[date]) -> int:
best = run = 0
previous: date | None = None
for day in days:
run = run + 1 if previous is not None and (day - previous).days == 1 else 1
best = max(best, run)
previous = day
return best
def heatmap(session: Session, settings: AppSettings, weeks: int = 12) -> list[dict[str, Any]]:
"""Per-day counts for the last `weeks` weeks, for the dashboard's calendar."""
end = local_date(settings.timezone)
start = end - timedelta(weeks=weeks)
return range_metrics(session, settings, start, end)
def lifetime(session: Session, settings: AppSettings) -> dict[str, Any]:
total, seconds = session.execute(
select(func.count(PomodoroSession.id), func.coalesce(func.sum(PomodoroSession.actual_seconds), 0)).where(
PomodoroSession.kind == "work", PomodoroSession.status == "completed"
)
).one()
tasks = session.scalar(
select(func.count(TaskRecord.id)).where(TaskRecord.completed_here.is_(True))
)
blocked = session.scalar(select(func.count(BlockEvent.id)))
return {
"pomodoros": int(total or 0),
"focus_minutes": round(int(seconds or 0) / 60),
"focus_hours": round(int(seconds or 0) / 3600, 1),
"tasks_completed": int(tasks or 0),
"blocked_attempts": int(blocked or 0),
}
+275
View File
@@ -0,0 +1,275 @@
"""The task list: refreshing it from every source, and acting on it.
External tasks are mirrored into the `tasks` table on each refresh. The mirror
buys three things a live call cannot: the UI stays instant, the daily report
still works when a source is down, and "I finished this" is recorded locally
even if the write-back to Jira fails.
"""
from __future__ import annotations
import logging
from datetime import date
from typing import Any, Iterable
from uuid import uuid4
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import TaskRecord, utcnow
from app.settings_schema import AppSettings
from app.sources.base import SourceResult, TodoItem
from app.sources.registry import SOURCE_LABELS, complete_remote, fetch_all
from app.timeutil import local_date
log = logging.getLogger(__name__)
LOCAL_SOURCE = "local"
# --------------------------------------------------------------------------
# Refresh
# --------------------------------------------------------------------------
def _upsert(session: Session, item: TodoItem) -> TaskRecord:
record = session.scalar(
select(TaskRecord).where(
TaskRecord.source == item.source,
TaskRecord.external_id == item.external_id,
)
)
if record is None:
record = TaskRecord(source=item.source, external_id=item.external_id)
session.add(record)
record.title = item.title
record.description = item.description
record.url = item.url
record.project = item.project
record.status = item.status
record.priority = item.priority
record.due_date = item.due_date
record.start_date = item.start_date
record.labels = item.labels
record.raw = item.raw
record.last_seen_at = utcnow()
record.stale = False
if item.is_done and not record.is_done:
# Closed in the source, not here: it counts as done but not as "mine".
record.is_done = True
record.completed_at = record.completed_at or utcnow()
return record
def refresh_tasks(session: Session, settings: AppSettings) -> dict[str, Any]:
"""Pull every enabled source and reconcile the mirror.
Tasks a source stops returning are flagged `stale` rather than deleted: the
history behind "you finished 6 things today" has to survive a source that
quietly drops an item.
"""
results: list[SourceResult] = fetch_all(settings)
today = local_date(settings.timezone)
summary: list[dict[str, Any]] = []
for result in results:
if not result.ok:
summary.append(
{
"source": result.source,
"label": SOURCE_LABELS.get(result.source, result.source),
"ok": False,
"error": result.error,
"count": 0,
}
)
continue
seen_ids: set[str] = set()
for item in result.items:
_upsert(session, item)
seen_ids.add(item.external_id)
existing = session.scalars(
select(TaskRecord).where(
TaskRecord.source == result.source, TaskRecord.is_done.is_(False)
)
).all()
for record in existing:
if record.external_id not in seen_ids:
record.stale = True
summary.append(
{
"source": result.source,
"label": SOURCE_LABELS.get(result.source, result.source),
"ok": True,
"error": None,
"count": len(result.items),
}
)
session.commit()
return {
"refreshed_at": utcnow().isoformat(),
"date": today.isoformat(),
"sources": summary,
"total": sum(entry["count"] for entry in summary),
}
# --------------------------------------------------------------------------
# Queries
# --------------------------------------------------------------------------
def list_tasks(
session: Session,
*,
include_done: bool = False,
include_stale: bool = False,
sources: Iterable[str] | None = None,
) -> list[TaskRecord]:
query = select(TaskRecord)
if not include_done:
query = query.where(TaskRecord.is_done.is_(False))
if not include_stale:
query = query.where(TaskRecord.stale.is_(False))
if sources:
query = query.where(TaskRecord.source.in_(list(sources)))
records = list(session.scalars(query))
records.sort(key=_sort_key)
return records
def _sort_key(record: TaskRecord) -> tuple:
"""Pinned first, then by deadline (undated last), then by source and title."""
return (
not record.pinned,
record.due_date is None,
record.due_date or date.max,
record.source,
(record.title or "").lower(),
)
def bucket_tasks(records: Iterable[TaskRecord], today: date) -> dict[str, list[TaskRecord]]:
"""Group a task list the way the UI and the report both want it."""
buckets: dict[str, list[TaskRecord]] = {
"overdue": [],
"today": [],
"upcoming": [],
"someday": [],
}
for record in records:
if record.due_date is None:
buckets["someday"].append(record)
elif record.due_date < today:
buckets["overdue"].append(record)
elif record.due_date == today:
buckets["today"].append(record)
else:
buckets["upcoming"].append(record)
return buckets
def completed_on(session: Session, day: date, *, only_here: bool = True) -> list[TaskRecord]:
query = select(TaskRecord).where(TaskRecord.completed_date == day)
if only_here:
query = query.where(TaskRecord.completed_here.is_(True))
return list(session.scalars(query))
# --------------------------------------------------------------------------
# Mutations
# --------------------------------------------------------------------------
def create_local_task(
session: Session,
*,
title: str,
description: str | None = None,
due_date: date | None = None,
priority: str | None = None,
project: str | None = None,
estimated_pomodoros: int | None = None,
) -> TaskRecord:
"""A task that lives only here — the quick "and this too" you type at 9am."""
# A random id, not a timestamp: two tasks added in the same millisecond
# (a double-clicked Add button) would otherwise collide on the unique key.
external_id = f"local-{uuid4().hex}"
record = TaskRecord(
source=LOCAL_SOURCE,
external_id=external_id,
title=title.strip(),
description=description,
due_date=due_date,
priority=priority,
project=project or "My list",
estimated_pomodoros=estimated_pomodoros,
)
session.add(record)
session.commit()
return record
def complete_task(
session: Session, settings: AppSettings, task_id: int, *, done: bool = True
) -> tuple[TaskRecord, str | None]:
"""Mark a task done (or undone) here, and try to mirror it to its source.
Returns the record plus a warning when the write-back failed — the local
state still changes, because the alternative is losing the fact that you
finished something because Jira hiccupped.
"""
record = session.get(TaskRecord, task_id)
if record is None:
raise LookupError(f"no task {task_id}")
warning: str | None = None
if done:
record.is_done = True
record.completed_here = True
record.completed_at = utcnow()
record.completed_date = local_date(settings.timezone)
if record.source != LOCAL_SOURCE:
item = TodoItem(
source=record.source,
external_id=record.external_id,
title=record.title,
raw=record.raw or {},
)
wrote, error = complete_remote(settings, item)
if error:
warning = (
f"Marked done here, but {SOURCE_LABELS.get(record.source, record.source)}"
f" did not accept it: {error}"
)
elif not wrote:
warning = (
f"Marked done here only — write-back is off for "
f"{SOURCE_LABELS.get(record.source, record.source)}."
)
else:
record.is_done = False
record.completed_here = False
record.completed_at = None
record.completed_date = None
session.commit()
return record, warning
def update_task(session: Session, task_id: int, **changes: Any) -> TaskRecord:
record = session.get(TaskRecord, task_id)
if record is None:
raise LookupError(f"no task {task_id}")
for key, value in changes.items():
if value is not None and hasattr(record, key):
setattr(record, key, value)
session.commit()
return record
def delete_task(session: Session, task_id: int) -> None:
record = session.get(TaskRecord, task_id)
if record is None:
return
session.delete(record)
session.commit()
+367
View File
@@ -0,0 +1,367 @@
"""A long-polling Telegram bot, so the chat is a remote control, not just an inbox.
Runs in a daemon thread next to the API. Commands do exactly what the buttons in
the UI do — they call the same services — so starting a pomodoro from your phone
and finishing it on the desktop is one unbroken interval.
"""
from __future__ import annotations
import logging
import threading
import time as time_module
from typing import Any, Callable
import httpx
from app.config import env_settings
from app.db import session_scope
from app.services import (
blocker_service,
pomodoro,
report,
settings_service,
stats,
tasks_service,
)
from app.settings_schema import AppSettings
from app.timeutil import humanize_minutes, local_date
log = logging.getLogger(__name__)
_thread: threading.Thread | None = None
_stop = threading.Event()
_POLL_TIMEOUT = 25
HELP_EN = """<b>Kadence</b>
/start_work [minutes] — start a pomodoro
/break — start a break
/stop — abandon the current interval
/pause · /resume — hold the clock
/status — what is running right now
/today — your todo list, grouped
/done &lt;number&gt; — close task N from /today
/report — today's report, now
/week — the weekly review
/streak — streak and all-time totals
/block — what the blocker is doing
/help — this message"""
HELP_RU = """<b>Kadence</b>
/start_work [минуты] — запустить помидорку
/break — перерыв
/stop — бросить текущий интервал
/pause · /resume — пауза и продолжение
/status — что идёт сейчас
/today — список задач по группам
/done &lt;номер&gt; — закрыть задачу N из /today
/report — отчёт за сегодня
/week — итоги недели
/streak — серия и общая статистика
/block — что делает блокировщик
/help — это сообщение"""
def _api(token: str, method: str, payload: dict[str, Any], timeout: int = 30) -> dict[str, Any]:
url = f"https://api.telegram.org/bot{token}/{method}"
with httpx.Client(timeout=timeout) as client:
resp = client.post(url, json=payload)
if not resp.is_success:
raise RuntimeError(f"{method} -> {resp.status_code}: {resp.text[:200]}")
return resp.json() or {}
def _reply(settings: AppSettings, chat_id: str, text: str, buttons: list[list[str]] | None = None) -> None:
telegram = settings.notifications.telegram
payload: dict[str, Any] = {
"chat_id": chat_id,
"text": text,
"parse_mode": "HTML",
"disable_web_page_preview": True,
}
if buttons:
payload["reply_markup"] = {
"inline_keyboard": [
[{"text": label, "callback_data": label} for label in row] for row in buttons
]
}
try:
_api(telegram.bot_token, "sendMessage", payload)
except Exception as exc: # pragma: no cover
log.warning("telegram reply failed: %s", exc)
# --------------------------------------------------------------------------
# Command handlers
# --------------------------------------------------------------------------
def _cmd_status(session, settings: AppSettings, _args: str) -> str:
ru = settings.notifications.report.language == "ru"
state = pomodoro.state(session, settings)
current = state["current"]
done, goal = state["completed_today"], state["goal"]
header = (
f"🍅 {done}/{goal} " + ("сегодня" if ru else "today")
+ f" · {report.progress_bar(done, goal)}"
)
if current is None:
return header + "\n\n" + ("Таймер стоит." if ru else "The timer is idle.")
remaining = humanize_minutes(current["remaining_seconds"] / 60)
kind = {
"work": "работа" if ru else "work",
"short_break": "короткий перерыв" if ru else "short break",
"long_break": "длинный перерыв" if ru else "long break",
}[current["kind"]]
task = f"\n📌 {report._esc(current['task']['title'])}" if current.get("task") else ""
status = ("на паузе" if ru else "paused") if current["status"] == "paused" else ("идёт" if ru else "running")
return f"{header}\n\n▶️ <b>{kind}</b> ({status}) — {remaining} " + ("осталось" if ru else "left") + task
def _cmd_start_work(session, settings: AppSettings, args: str) -> str:
ru = settings.notifications.report.language == "ru"
minutes: int | None = None
if args.strip().isdigit():
minutes = max(1, min(240, int(args.strip())))
row = pomodoro.start(session, settings, kind="work", minutes=minutes)
length = humanize_minutes(row.planned_seconds / 60)
return (
f"▶️ Помидорка на {length} пошла. Удачи!" if ru else f"▶️ {length} pomodoro started. Go."
)
def _cmd_break(session, settings: AppSettings, _args: str) -> str:
ru = settings.notifications.report.language == "ru"
kind = pomodoro.next_kind(session, settings)
if kind == "work":
kind = "short_break"
row = pomodoro.start(session, settings, kind=kind)
return (
f"☕ Перерыв {humanize_minutes(row.planned_seconds / 60)}."
if ru
else f"☕ {humanize_minutes(row.planned_seconds / 60)} break."
)
def _cmd_stop(session, settings: AppSettings, args: str) -> str:
ru = settings.notifications.report.language == "ru"
try:
pomodoro.abandon(session, settings, reason=args.strip() or "stopped from Telegram")
except pomodoro.TimerError as exc:
return str(exc)
return "⏹ Остановлено." if ru else "⏹ Stopped."
def _cmd_pause(session, settings: AppSettings, _args: str) -> str:
ru = settings.notifications.report.language == "ru"
try:
pomodoro.pause(session, settings)
except pomodoro.TimerError as exc:
return str(exc)
return "⏸ Пауза." if ru else "⏸ Paused."
def _cmd_resume(session, settings: AppSettings, _args: str) -> str:
ru = settings.notifications.report.language == "ru"
try:
pomodoro.resume(session)
except pomodoro.TimerError as exc:
return str(exc)
return "▶️ Продолжаем." if ru else "▶️ Resumed."
def _cmd_today(session, settings: AppSettings, _args: str) -> str:
"""The todo list, numbered so `/done 3` has something to refer to."""
ru = settings.notifications.report.language == "ru"
today = local_date(settings.timezone)
tasks = tasks_service.list_tasks(session)
buckets = tasks_service.bucket_tasks(tasks, today)
ordered = buckets["overdue"] + buckets["today"] + buckets["upcoming"] + buckets["someday"]
if not ordered:
return "✨ Задач нет." if ru else "✨ Nothing on the list."
_INDEX[settings.notifications.telegram.chat_id] = [task.id for task in ordered]
limit = settings.notifications.report.max_tasks_listed
lines = [f"<b>{'📋 Задачи' if ru else '📋 Your tasks'}</b>"]
marks = {"overdue": "🔴", "today": "🟡", "upcoming": "🔵", "someday": "⚪"}
position = 0
for bucket_name in ("overdue", "today", "upcoming", "someday"):
for task in buckets[bucket_name]:
position += 1
if position > limit:
continue
due = f" ({task.due_date.isoformat()})" if task.due_date else ""
lines.append(
f"{marks[bucket_name]} <b>{position}.</b> {report._esc(task.title)}{due}"
)
if position > limit:
lines.append(("… и ещё " if ru else "… and ") + f"{position - limit}")
lines.append("")
lines.append("<i>/done N</i>")
return "\n".join(lines)
_INDEX: dict[str, list[int]] = {}
def _cmd_done(session, settings: AppSettings, args: str) -> str:
ru = settings.notifications.report.language == "ru"
chat_id = settings.notifications.telegram.chat_id
index = _INDEX.get(chat_id)
if not args.strip().isdigit():
return "Укажи номер: /done 3" if ru else "Give me a number: /done 3"
if not index:
return "Сначала /today." if ru else "Run /today first."
position = int(args.strip())
if not 1 <= position <= len(index):
return "Нет такого номера." if ru else "No task with that number."
record, warning = tasks_service.complete_task(session, settings, index[position - 1])
text = f"✅ {report._esc(record.title)}"
return text + (f"\n⚠️ {report._esc(warning)}" if warning else "")
def _cmd_report(session, settings: AppSettings, _args: str) -> str:
today = local_date(settings.timezone)
_, payload = report.get_or_build_daily(session, settings, today, rebuild=True)
return report.render_daily_telegram(payload, settings)
def _cmd_week(session, settings: AppSettings, _args: str) -> str:
today = local_date(settings.timezone)
payload = report.build_weekly(session, settings, today)
return report.render_weekly_telegram(payload, settings)
def _cmd_streak(session, settings: AppSettings, _args: str) -> str:
ru = settings.notifications.report.language == "ru"
streak = stats.streak(session, settings)
lifetime = stats.lifetime(session, settings)
return "\n".join(
[
f"🔥 <b>{'Серия' if ru else 'Streak'}</b>: {streak['current']} "
+ ("дн." if ru else "days"),
f"🏆 {'Рекорд' if ru else 'Best'}: {streak['best']} · "
f"{'дней с целью' if ru else 'goal streak'}: {streak['goal_streak']}",
"",
f"🍅 {'Всего помидорок' if ru else 'All-time pomodoros'}: {lifetime['pomodoros']}",
f"⏱ {'Всего фокуса' if ru else 'All-time focus'}: {lifetime['focus_hours']}h",
f"✅ {'Закрыто задач' if ru else 'Tasks closed'}: {lifetime['tasks_completed']}",
f"🚫 {'Заблокировано попыток' if ru else 'Blocked attempts'}: {lifetime['blocked_attempts']}",
]
)
def _cmd_block(session, settings: AppSettings, _args: str) -> str:
ru = settings.notifications.report.language == "ru"
policy = blocker_service.policy(session, settings)
events = blocker_service.events_for_day(session, settings)
state = ("включён" if ru else "ON") if policy["active"] else ("выключен" if ru else "OFF")
lines = [
f"🛡 <b>{'Блокировщик' if ru else 'Blocker'}</b>: {state} ({report._esc(policy['reason'])})",
f"{'Сайтов' if ru else 'Domains'}: {len(policy['all_domains'])} · "
f"{'приложений' if ru else 'apps'}: {len(policy['all_apps'])}",
f"{'Попыток сегодня' if ru else 'Attempts today'}: {events['total']}",
]
for entry in events["by_target"][:5]:
lines.append(f"• {report._esc(entry['target'])} × {entry['count']}")
return "\n".join(lines)
def _cmd_help(_session, settings: AppSettings, _args: str) -> str:
return HELP_RU if settings.notifications.report.language == "ru" else HELP_EN
COMMANDS: dict[str, Callable] = {
"start": _cmd_help,
"help": _cmd_help,
"status": _cmd_status,
"start_work": _cmd_start_work,
"work": _cmd_start_work,
"break": _cmd_break,
"stop": _cmd_stop,
"pause": _cmd_pause,
"resume": _cmd_resume,
"today": _cmd_today,
"tasks": _cmd_today,
"done": _cmd_done,
"report": _cmd_report,
"week": _cmd_week,
"streak": _cmd_streak,
"stats": _cmd_streak,
"block": _cmd_block,
}
def handle_text(text: str) -> str | None:
"""Run one command and return the reply, or None when it is not for us."""
text = (text or "").strip()
if not text.startswith("/"):
return None
head, _, args = text[1:].partition(" ")
name = head.split("@", 1)[0].lower()
handler = COMMANDS.get(name)
if handler is None:
return None
with session_scope() as session:
settings = settings_service.load_settings(session)
try:
return handler(session, settings, args)
except Exception as exc: # pragma: no cover - a bad command must not kill the bot
log.exception("command /%s failed", name)
return f"⚠️ {exc}"
# --------------------------------------------------------------------------
# Polling loop
# --------------------------------------------------------------------------
def _loop() -> None:
offset: int | None = None
while not _stop.is_set():
try:
with session_scope() as session:
settings = settings_service.load_settings(session)
telegram = settings.notifications.telegram
if not (telegram.enabled and telegram.commands_enabled and telegram.bot_token):
_stop.wait(30)
continue
payload: dict[str, Any] = {"timeout": _POLL_TIMEOUT}
if offset is not None:
payload["offset"] = offset
data = _api(
telegram.bot_token, "getUpdates", payload, timeout=_POLL_TIMEOUT + 10
)
for update in data.get("result", []):
offset = update["update_id"] + 1
message = update.get("message") or update.get("edited_message") or {}
chat_id = str((message.get("chat") or {}).get("id", ""))
if telegram.chat_id and chat_id and chat_id != str(telegram.chat_id):
continue # only the configured chat may drive the timer
reply = handle_text(message.get("text", ""))
if reply:
with session_scope() as session:
fresh = settings_service.load_settings(session)
_reply(fresh, chat_id or telegram.chat_id, reply)
except Exception as exc: # pragma: no cover
log.warning("telegram polling hiccup: %s", exc)
_stop.wait(10)
else:
time_module.sleep(0.3)
def start() -> None:
"""Start the polling thread if the environment allows it."""
global _thread
if not env_settings().telegram_bot_enabled or _thread is not None:
return
_stop.clear()
_thread = threading.Thread(target=_loop, name="telegram-bot", daemon=True)
_thread.start()
log.info("telegram command bot started")
def shutdown() -> None:
global _thread
_stop.set()
_thread = None