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

+3
View File
@@ -0,0 +1,3 @@
"""kadence backend: pomodoro engine, todo aggregation, reports."""
__version__ = "0.1.0"
+20
View File
@@ -0,0 +1,20 @@
"""HTTP layer. Routers are thin; the thinking happens in `app.services`."""
from fastapi import APIRouter
from app.api import (
routes_blocker,
routes_reports,
routes_settings,
routes_tasks,
routes_timer,
)
api_router = APIRouter()
api_router.include_router(routes_timer.router)
api_router.include_router(routes_tasks.router)
api_router.include_router(routes_blocker.router)
api_router.include_router(routes_reports.router)
api_router.include_router(routes_settings.router)
__all__ = ["api_router"]
+37
View File
@@ -0,0 +1,37 @@
"""Shared dependencies: the database session and the optional shared secret."""
from __future__ import annotations
from typing import Annotated
from fastapi import Depends, Header, HTTPException, status
from sqlalchemy.orm import Session
from app.config import env_settings
from app.db import get_session
from app.services.settings_service import load_settings
from app.settings_schema import AppSettings
DbSession = Annotated[Session, Depends(get_session)]
def require_token(x_api_token: Annotated[str | None, Header()] = None) -> None:
"""Guard every API call when `API_TOKEN` is set.
Off by default: on a laptop the backend is bound to localhost and a token is
friction. Set it the moment the container is reachable from anywhere else.
"""
expected = env_settings().api_token
if not expected:
return
if x_api_token != expected:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Bad or missing X-API-Token."
)
def get_settings(session: DbSession) -> AppSettings:
return load_settings(session)
Settings = Annotated[AppSettings, Depends(get_settings)]
+58
View File
@@ -0,0 +1,58 @@
"""Blocker endpoints.
`GET /api/blocker/policy` is the one the desktop app polls every few seconds;
everything else is for the UI.
"""
from __future__ import annotations
from datetime import date
from fastapi import APIRouter, Query
from app.api.deps import DbSession, Settings
from app.api.schemas import BlockEventRequest
from app.services import blocker_service
from app.services.notify import notify
router = APIRouter(prefix="/api/blocker", tags=["blocker"])
@router.get("/policy")
def policy(session: DbSession, settings: Settings) -> dict:
return blocker_service.policy(session, settings)
@router.get("/events")
def events(
session: DbSession,
settings: Settings,
day: date | None = Query(default=None),
) -> dict:
return blocker_service.events_for_day(session, settings, day)
@router.post("/events")
def record(body: BlockEventRequest, session: DbSession, settings: Settings) -> dict:
"""The desktop app reports every attempt it turned away.
These are what make the daily report fun ("you reached for youtube 12
times"), so they are recorded even when the blocker only warned.
"""
event = blocker_service.record_event(
session,
settings,
kind=body.kind,
target=body.target,
action=body.action,
details=body.details,
)
if settings.blocker.notify_on_block and body.action == "killed":
notify(settings, f"🛑 Closed <b>{body.target}</b> — you are in a work interval.")
return {"id": event.id, "recorded": True}
@router.get("/defaults")
def defaults() -> dict:
"""Starter lists, so the blocker page is not an empty text box."""
return {"domains": blocker_service.DEFAULT_DOMAINS, "apps": blocker_service.DEFAULT_APPS}
+100
View File
@@ -0,0 +1,100 @@
"""Report and statistics endpoints."""
from __future__ import annotations
from datetime import date, timedelta
from fastapi import APIRouter, Query
from app.api.deps import DbSession, Settings
from app.api.schemas import SendReportRequest, TestNotificationRequest
from app.services import notify as notify_service
from app.services import report, scheduler, stats
from app.timeutil import ensure_utc, local_date
router = APIRouter(prefix="/api", tags=["reports"])
@router.get("/reports/daily")
def daily(
session: DbSession,
settings: Settings,
day: date | None = Query(default=None),
rebuild: bool = Query(default=True),
) -> dict:
target = day or local_date(settings.timezone)
row, payload = report.get_or_build_daily(session, settings, target, rebuild=rebuild)
return {
"report": payload,
"sent_at": ensure_utc(row.sent_at).isoformat() if row.sent_at else None,
"telegram_preview": report.render_daily_telegram(payload, settings),
}
@router.post("/reports/daily/send")
def send_daily(body: SendReportRequest, session: DbSession, settings: Settings) -> dict:
target = body.report_date or local_date(settings.timezone)
return report.send_daily(session, settings, target, force=body.force)
@router.get("/reports/weekly")
def weekly(
session: DbSession,
settings: Settings,
end: date | None = Query(default=None),
) -> dict:
target = end or local_date(settings.timezone)
payload = report.build_weekly(session, settings, target)
return {"report": payload, "telegram_preview": report.render_weekly_telegram(payload, settings)}
@router.post("/reports/weekly/send")
def send_weekly(session: DbSession, settings: Settings) -> dict:
payload = report.build_weekly(session, settings, local_date(settings.timezone))
text = report.render_weekly_telegram(payload, settings)
deliveries = notify_service.notify(settings, text, urgent=True)
return {"sent": any(d.ok for d in deliveries), "deliveries": [d.as_dict() for d in deliveries]}
@router.post("/reports/briefing/send")
def send_briefing(session: DbSession, settings: Settings) -> dict:
text = report.render_morning_briefing(session, settings)
deliveries = notify_service.notify(settings, text, urgent=True)
return {
"sent": any(d.ok for d in deliveries),
"deliveries": [d.as_dict() for d in deliveries],
"text": text,
}
@router.get("/stats/summary")
def summary(session: DbSession, settings: Settings, days: int = Query(default=30, ge=1, le=365)) -> dict:
today = local_date(settings.timezone)
return {
"today": stats.day_metrics(session, settings, today),
"streak": stats.streak(session, settings),
"lifetime": stats.lifetime(session, settings),
"average_7d": stats.averages(session, settings, 7, until=today),
"range": stats.range_metrics(session, settings, today - timedelta(days=days - 1), today),
"heatmap": stats.heatmap(session, settings, weeks=12),
}
@router.get("/stats/day")
def day_stats(session: DbSession, settings: Settings, day: date | None = Query(default=None)) -> dict:
return stats.day_metrics(session, settings, day or local_date(settings.timezone))
@router.post("/notifications/test")
def test_notification(body: TestNotificationRequest, settings: Settings) -> dict:
text = body.text or (
"🍅 <b>Kadence</b>\nNotifications are wired up correctly."
)
deliveries = notify_service.notify(settings, text, urgent=True)
return {"deliveries": [d.as_dict() for d in deliveries]}
@router.get("/scheduler/jobs")
def scheduler_jobs() -> dict:
"""What the background jobs are, and when they next fire."""
return {"jobs": scheduler.describe()}
+54
View File
@@ -0,0 +1,54 @@
"""Settings endpoints.
The browser never sees a real token: `GET` masks every secret, and a `PUT` that
sends the mask back untouched keeps the stored value.
"""
from __future__ import annotations
import json
from typing import Any
from fastapi import APIRouter, Body, HTTPException
from pydantic import ValidationError
from app.api.deps import DbSession, Settings
from app.services import blocker_service, settings_service
from app.settings_schema import AppSettings
router = APIRouter(prefix="/api/settings", tags=["settings"])
@router.get("")
def get_settings(settings: Settings) -> dict:
return {
"settings": settings_service.mask_secrets(settings.model_dump(mode="json")),
"defaults": {
"blocked_domains": blocker_service.DEFAULT_DOMAINS,
"blocked_apps": blocker_service.DEFAULT_APPS,
},
}
@router.put("")
def put_settings(session: DbSession, patch: dict[str, Any] = Body(...)) -> dict:
try:
updated = settings_service.apply_patch(session, patch)
except ValidationError as exc:
# `exc.errors()` can carry exception objects in `ctx`, which JSON cannot
# take; pydantic's own `json()` renders them as text.
raise HTTPException(status_code=422, detail=json.loads(exc.json())) from exc
return {"settings": settings_service.mask_secrets(updated.model_dump(mode="json"))}
@router.post("/reset")
def reset_settings(session: DbSession) -> dict:
"""Back to defaults, re-seeded from the environment."""
updated = settings_service.save_settings(session, settings_service.settings_from_env())
return {"settings": settings_service.mask_secrets(updated.model_dump(mode="json"))}
@router.get("/schema")
def settings_schema() -> dict:
"""The JSON Schema, so a client can render the settings form generically."""
return AppSettings.model_json_schema()
+121
View File
@@ -0,0 +1,121 @@
"""Task endpoints: the aggregated list, refreshing it, and acting on it."""
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Query
from app.api.deps import DbSession, Settings
from app.api.schemas import TaskCreateRequest, TaskDoneRequest, TaskUpdateRequest
from app.models import TaskRecord
from app.services import tasks_service
from app.sources.registry import SOURCE_LABELS, check_source
from app.timeutil import ensure_utc, local_date
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
def task_dict(record: TaskRecord, today) -> dict:
return {
"id": record.id,
"source": record.source,
"source_label": SOURCE_LABELS.get(record.source, record.source),
"external_id": record.external_id,
"title": record.title,
"description": record.description,
"url": record.url,
"project": record.project,
"status": record.status,
"priority": record.priority,
"due_date": record.due_date.isoformat() if record.due_date else None,
"start_date": record.start_date.isoformat() if record.start_date else None,
"labels": record.labels or [],
"is_done": record.is_done,
"completed_at": ensure_utc(record.completed_at).isoformat() if record.completed_at else None,
"pinned": record.pinned,
"stale": record.stale,
"estimated_pomodoros": record.estimated_pomodoros,
"days_overdue": (today - record.due_date).days if record.due_date and record.due_date < today else 0,
"bucket": _bucket_of(record, today),
}
def _bucket_of(record: TaskRecord, today) -> str:
if record.due_date is None:
return "someday"
if record.due_date < today:
return "overdue"
if record.due_date == today:
return "today"
return "upcoming"
@router.get("")
def list_tasks(
session: DbSession,
settings: Settings,
include_done: bool = Query(default=False),
include_stale: bool = Query(default=False),
source: list[str] | None = Query(default=None),
) -> dict:
today = local_date(settings.timezone)
records = tasks_service.list_tasks(
session, include_done=include_done, include_stale=include_stale, sources=source
)
buckets = tasks_service.bucket_tasks(records, today)
return {
"date": today.isoformat(),
"tasks": [task_dict(record, today) for record in records],
"counts": {name: len(items) for name, items in buckets.items()},
"sources": sorted({record.source for record in records}),
}
@router.post("/refresh")
def refresh(session: DbSession, settings: Settings) -> dict:
"""Pull every enabled source now. Failures come back per source, not as a 500."""
return tasks_service.refresh_tasks(session, settings)
@router.post("")
def create(body: TaskCreateRequest, session: DbSession, settings: Settings) -> dict:
record = tasks_service.create_local_task(
session,
title=body.title,
description=body.description,
due_date=body.due_date,
priority=body.priority,
project=body.project,
estimated_pomodoros=body.estimated_pomodoros,
)
return task_dict(record, local_date(settings.timezone))
@router.patch("/{task_id}")
def update(task_id: int, body: TaskUpdateRequest, session: DbSession, settings: Settings) -> dict:
try:
record = tasks_service.update_task(session, task_id, **body.model_dump(exclude_unset=True))
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return task_dict(record, local_date(settings.timezone))
@router.post("/{task_id}/done")
def mark_done(task_id: int, body: TaskDoneRequest, session: DbSession, settings: Settings) -> dict:
try:
record, warning = tasks_service.complete_task(session, settings, task_id, done=body.done)
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return {"task": task_dict(record, local_date(settings.timezone)), "warning": warning}
@router.delete("/{task_id}")
def delete(task_id: int, session: DbSession) -> dict:
tasks_service.delete_task(session, task_id)
return {"deleted": task_id}
@router.post("/sources/{name}/test")
def test_source(name: str, settings: Settings) -> dict:
"""What the settings page's "Test connection" button calls."""
ok, detail = check_source(settings, name)
return {"source": name, "ok": ok, "detail": detail}
+155
View File
@@ -0,0 +1,155 @@
"""The timer endpoints. Thin: every decision lives in `services.pomodoro`."""
from __future__ import annotations
from datetime import date
from fastapi import APIRouter, HTTPException, Query
from app.api.deps import DbSession, Settings
from app.api.schemas import (
AbandonRequest,
AttachTaskRequest,
CompleteTimerRequest,
ExtendRequest,
StartTimerRequest,
)
from app.services import pomodoro, report, stats
from app.services.notify import count_sent, notify, record_sent
from app.timeutil import local_date
router = APIRouter(prefix="/api/timer", tags=["timer"])
@router.get("/state")
def get_state(session: DbSession, settings: Settings) -> dict:
return pomodoro.state(session, settings)
@router.post("/start")
def start(body: StartTimerRequest, session: DbSession, settings: Settings) -> dict:
try:
pomodoro.start(
session,
settings,
kind=body.kind,
minutes=body.minutes,
task_id=body.task_id,
note=body.note,
)
except pomodoro.TimerError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return pomodoro.state(session, settings)
@router.post("/pause")
def pause(session: DbSession, settings: Settings) -> dict:
try:
pomodoro.pause(session, settings)
except pomodoro.TimerError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return pomodoro.state(session, settings)
@router.post("/resume")
def resume(session: DbSession, settings: Settings) -> dict:
try:
pomodoro.resume(session)
except pomodoro.TimerError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return pomodoro.state(session, settings)
@router.post("/complete")
def complete(body: CompleteTimerRequest, session: DbSession, settings: Settings) -> dict:
try:
finished, follow_on = pomodoro.complete(session, settings, auto_next=body.auto_next)
except pomodoro.TimerError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
celebration = _maybe_celebrate(session, settings) if finished.is_work else None
state = pomodoro.state(session, settings)
state["finished"] = pomodoro.session_dict(finished)
state["auto_started"] = pomodoro.session_dict(follow_on)
state["celebration"] = celebration
return state
@router.post("/abandon")
def abandon(body: AbandonRequest, session: DbSession, settings: Settings) -> dict:
try:
pomodoro.abandon(session, settings, reason=body.reason)
except pomodoro.TimerError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return pomodoro.state(session, settings)
@router.post("/interrupt")
def interrupt(session: DbSession, settings: Settings) -> dict:
try:
pomodoro.add_interruption(session)
except pomodoro.TimerError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return pomodoro.state(session, settings)
@router.post("/extend")
def extend(body: ExtendRequest, session: DbSession, settings: Settings) -> dict:
try:
pomodoro.extend(session, body.minutes)
except pomodoro.TimerError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return pomodoro.state(session, settings)
@router.post("/task")
def attach_task(body: AttachTaskRequest, session: DbSession, settings: Settings) -> dict:
try:
pomodoro.attach_task(session, body.task_id)
except pomodoro.TimerError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return pomodoro.state(session, settings)
@router.get("/sessions")
def sessions(
session: DbSession,
settings: Settings,
day: date | None = Query(default=None, description="Local date; defaults to today."),
) -> dict:
target = day or local_date(settings.timezone)
rows = pomodoro.sessions_for_day(session, settings, target)
return {
"date": target.isoformat(),
"sessions": [pomodoro.session_dict(row) for row in rows],
"metrics": stats.day_metrics(session, settings, target),
}
def _maybe_celebrate(session, settings) -> str | None:
"""Cheer in Telegram every N pomodoros — the cheapest dopamine there is."""
every = settings.notifications.telegram.celebrate_every_n_pomodoros
if not (settings.notifications.telegram.enabled and every):
return None
done = pomodoro.completed_work_today(session, settings)
if done == 0 or done % every != 0:
return None
today = local_date(settings.timezone)
kind = f"celebrate:{done}"
if count_sent(session, kind, today):
return None
ru = settings.notifications.report.language == "ru"
goal = settings.pomodoro.daily_goal_pomodoros
bar = report.progress_bar(done, goal)
text = (
f"🎉 <b>{done}</b> помидорок готово!\n{bar} {done}/{goal}\nДержи темп."
if ru
else f"🎉 <b>{done}</b> pomodoros done!\n{bar} {done}/{goal}\nKeep the rhythm."
)
deliveries = notify(settings, text)
if any(delivery.ok for delivery in deliveries):
record_sent(session, kind, today, {"pomodoros": done})
return text
+79
View File
@@ -0,0 +1,79 @@
"""Request bodies. Responses are plain dicts built by the services."""
from __future__ import annotations
from datetime import date
from typing import Any, Literal
from pydantic import BaseModel, Field
class StartTimerRequest(BaseModel):
kind: Literal["work", "short_break", "long_break"] | None = None
minutes: int | None = Field(default=None, ge=1, le=240)
task_id: int | None = None
note: str | None = None
class AbandonRequest(BaseModel):
reason: str | None = None
class ExtendRequest(BaseModel):
minutes: int = Field(default=5, ge=1, le=120)
class AttachTaskRequest(BaseModel):
task_id: int | None = None
class CompleteTimerRequest(BaseModel):
auto_next: bool = True
class TaskCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=500)
description: str | None = None
due_date: date | None = None
priority: str | None = None
project: str | None = None
estimated_pomodoros: int | None = Field(default=None, ge=1, le=50)
class TaskUpdateRequest(BaseModel):
title: str | None = None
description: str | None = None
due_date: date | None = None
priority: str | None = None
project: str | None = None
estimated_pomodoros: int | None = None
pinned: bool | None = None
class TaskDoneRequest(BaseModel):
done: bool = True
class BlockEventRequest(BaseModel):
kind: Literal["domain", "app"] = "domain"
target: str = Field(min_length=1, max_length=255)
action: Literal["blocked", "killed", "warned"] = "blocked"
details: str | None = None
class SendReportRequest(BaseModel):
report_date: date | None = None
force: bool = True
class TestNotificationRequest(BaseModel):
text: str | None = None
class SettingsPatch(BaseModel):
"""A partial settings document; anything omitted keeps its stored value."""
model_config = {"extra": "allow"}
def as_dict(self) -> dict[str, Any]:
return self.model_dump(exclude_unset=True)
+76
View File
@@ -0,0 +1,76 @@
"""`kadence` — run the server, or poke it from a terminal/cron."""
from __future__ import annotations
import argparse
import json
from datetime import date
from app.db import init_db, session_scope
from app.services import report, settings_service, stats, tasks_service
from app.timeutil import local_date
def main() -> int:
parser = argparse.ArgumentParser(prog="kadence")
sub = parser.add_subparsers(dest="command", required=True)
serve = sub.add_parser("serve", help="run the API (and the web UI)")
serve.add_argument("--host", default="0.0.0.0")
serve.add_argument("--port", type=int, default=8000)
serve.add_argument("--reload", action="store_true")
sub.add_parser("refresh", help="pull every todo source once")
sub.add_parser("briefing", help="send the morning briefing now")
report_cmd = sub.add_parser("report", help="build and send today's report")
report_cmd.add_argument("--date", type=date.fromisoformat, default=None)
report_cmd.add_argument("--print", action="store_true", help="print, do not send")
sub.add_parser("stats", help="print a summary as JSON")
args = parser.parse_args()
if args.command == "serve":
import uvicorn
uvicorn.run("app.main:app", host=args.host, port=args.port, reload=args.reload)
return 0
init_db()
with session_scope() as session:
settings = settings_service.load_settings(session)
if args.command == "refresh":
print(json.dumps(tasks_service.refresh_tasks(session, settings), indent=2))
elif args.command == "briefing":
from app.services.notify import notify
text = report.render_morning_briefing(session, settings)
print(text)
print([d.as_dict() for d in notify(settings, text, urgent=True)])
elif args.command == "report":
day = args.date or local_date(settings.timezone)
if args.print:
_, payload = report.get_or_build_daily(session, settings, day, rebuild=True)
print(report.render_daily_telegram(payload, settings))
else:
result = report.send_daily(session, settings, day, force=True)
print(result.get("text", ""))
print([d for d in result.get("deliveries", [])])
elif args.command == "stats":
today = local_date(settings.timezone)
print(
json.dumps(
{
"today": stats.day_metrics(session, settings, today),
"streak": stats.streak(session, settings),
"lifetime": stats.lifetime(session, settings),
},
indent=2,
ensure_ascii=False,
)
)
return 0
if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())
+92
View File
@@ -0,0 +1,92 @@
"""Process-level configuration: where the database is, who may call us, and the
seed values for the integrations.
Two layers of configuration exist in this app and they do different jobs:
* **This module** — environment variables. Infrastructure plus *secrets*
(API tokens), so they can live in a `.env` file or a Docker secret and never
touch the database or the browser.
* **`app.settings_schema.AppSettings`** — the user-editable settings stored in
the database and edited in the UI (pomodoro lengths, blocked sites, report
times...). On first boot the secrets below are copied into it as defaults.
So: `JIRA_API_TOKEN` in the environment gets you a working Jira source without
ever opening the settings page, and anything you later change in the UI wins.
"""
from __future__ import annotations
from functools import lru_cache
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class EnvSettings(BaseSettings):
model_config = SettingsConfigDict(
env_file=(".env", "../.env"),
env_file_encoding="utf-8",
extra="ignore",
)
# ---- infrastructure ----------------------------------------------------
database_url: str = Field(
default="sqlite:///./kadence.db",
description="SQLAlchemy URL. Docker compose points this at Postgres.",
)
cors_origins: str = Field(
default="*",
description="Comma-separated list of allowed browser origins, or '*'.",
)
timezone: str = Field(
default="Europe/Moscow",
description="Fallback timezone for 'today' and for scheduled jobs.",
)
scheduler_enabled: bool = Field(
default=True,
description="Run the background jobs (daily report, nudges, refresh).",
)
telegram_bot_enabled: bool = Field(
default=True,
description="Run the Telegram command bot when a token is configured.",
)
api_token: str = Field(
default="",
description=(
"Optional shared secret. When set, every /api call must send it as "
"`X-API-Token` (the desktop app and the web UI both do)."
),
)
# ---- seeds for the editable settings -----------------------------------
jira_base_url: str = ""
jira_email: str = ""
jira_api_token: str = ""
jira_extra_jql: str = ""
jira_start_date_field: str = "customfield_10015"
google_client_id: str = ""
google_client_secret: str = ""
google_refresh_token: str = ""
google_tasks_lists: str = ""
todoist_api_token: str = ""
todoist_projects: str = ""
laundry_session: str = ""
laundry_base_url: str = "https://4ka.inbicst.ru"
telegram_bot_token: str = ""
telegram_chat_id: str = ""
slack_webhook_url: str = ""
@property
def cors_origin_list(self) -> list[str]:
if self.cors_origins.strip() == "*":
return ["*"]
return [item.strip() for item in self.cors_origins.split(",") if item.strip()]
@lru_cache
def env_settings() -> EnvSettings:
return EnvSettings()
+62
View File
@@ -0,0 +1,62 @@
"""Database engine and session plumbing.
Synchronous SQLAlchemy on purpose: the background scheduler, the Telegram
polling bot and the request handlers all touch the same tables, and one boring
threadsafe sessionmaker is far easier to reason about than three event loops.
FastAPI runs plain `def` endpoints in a threadpool, so nothing blocks.
"""
from __future__ import annotations
from collections.abc import Iterator
from contextlib import contextmanager
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.config import env_settings
class Base(DeclarativeBase):
pass
def _engine_kwargs(url: str) -> dict:
if url.startswith("sqlite"):
# One file, many threads (scheduler + bot + requests).
return {"connect_args": {"check_same_thread": False}}
return {"pool_pre_ping": True, "pool_size": 5, "max_overflow": 10}
engine = create_engine(env_settings().database_url, **_engine_kwargs(env_settings().database_url))
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
def init_db() -> None:
"""Create any missing tables. The schema is additive, so this is enough."""
from app import models # noqa: F401 (register the mappers)
Base.metadata.create_all(bind=engine)
@contextmanager
def session_scope() -> Iterator[Session]:
"""A transactional session for background jobs and scripts."""
session = SessionLocal()
try:
yield session
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close()
def get_session() -> Iterator[Session]:
"""FastAPI dependency: one session per request, committed by the endpoint."""
session = SessionLocal()
try:
yield session
finally:
session.close()
+106
View File
@@ -0,0 +1,106 @@
"""The ASGI application.
On start it creates the schema, seeds the settings from the environment, starts
the background jobs and the Telegram bot, and — when a built frontend is present
in `static/` — serves the web UI from the same origin, which is what the Docker
image does.
"""
from __future__ import annotations
import logging
import os
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from app import __version__
from app.api import api_router
from app.api.deps import require_token
from app.config import env_settings
from app.db import init_db, session_scope
from app.services import scheduler, settings_service, telegram_bot
logging.basicConfig(
level=os.environ.get("LOG_LEVEL", "INFO").upper(),
format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
)
log = logging.getLogger("kadence")
STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
with session_scope() as session:
settings = settings_service.load_settings(session)
log.info("settings loaded (timezone=%s)", settings.timezone)
scheduler.start(settings)
if settings.notifications.telegram.enabled:
telegram_bot.start()
# The bot has to come up later too, if Telegram is switched on in the UI.
settings_service.on_change(
lambda new: telegram_bot.start() if new.notifications.telegram.enabled else None
)
try:
yield
finally:
scheduler.shutdown()
telegram_bot.shutdown()
app = FastAPI(
title="Kadence",
version=__version__,
summary="Pomodoro timer, todo aggregation, blocking policy and daily reports.",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=env_settings().cors_origin_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router, dependencies=[Depends(require_token)])
@app.get("/api/health", tags=["meta"])
def health() -> dict:
return {"status": "ok", "version": __version__}
@app.get("/api/info", tags=["meta"])
def info() -> dict:
"""What a client needs to know before it has a token: who we are, what is on."""
env = env_settings()
return {
"version": __version__,
"auth_required": bool(env.api_token),
"scheduler": env.scheduler_enabled,
"jobs": scheduler.describe(),
}
# ---- the web UI, when it has been built into ./static ----------------------
if STATIC_DIR.is_dir():
app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets")
@app.get("/{path:path}", include_in_schema=False)
def spa(path: str):
"""Serve the single-page app, letting the client router own the URL."""
candidate = STATIC_DIR / path
if path and candidate.is_file():
return FileResponse(candidate)
index = STATIC_DIR / "index.html"
if index.is_file():
return FileResponse(index)
return JSONResponse({"detail": "Not found"}, status_code=404)
+183
View File
@@ -0,0 +1,183 @@
"""Database tables.
The shape follows the three things the app records: **settings** (one JSON
document), **what you did** (pomodoro sessions, task completions) and **what you
were tempted by** (block events). Reports are derived from those and cached per
day so a re-send shows the same numbers.
"""
from __future__ import annotations
from datetime import date, datetime, timezone
from typing import Any
from sqlalchemy import (
JSON,
Boolean,
Date,
DateTime,
Float,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base
def utcnow() -> datetime:
return datetime.now(timezone.utc)
class SettingsRow(Base):
"""The single settings document (`id = 1`), validated by `AppSettings`."""
__tablename__ = "settings"
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
data: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, onupdate=utcnow
)
class PomodoroSession(Base):
"""One interval of the timer — work or break — as it actually happened.
`planned_seconds` is what the settings asked for, `actual_seconds` is what
the clock recorded, and they differ whenever you finish early or abandon.
"""
__tablename__ = "pomodoro_sessions"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
kind: Mapped[str] = mapped_column(String(16), index=True) # work|short_break|long_break
status: Mapped[str] = mapped_column(String(16), index=True, default="running")
# running | paused | completed | abandoned
started_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, index=True
)
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
local_date: Mapped[date] = mapped_column(Date, index=True)
"""The day this interval belongs to in the user's timezone — reports group on it."""
planned_seconds: Mapped[int] = mapped_column(Integer, default=1500)
actual_seconds: Mapped[int] = mapped_column(Integer, default=0)
paused_seconds: Mapped[int] = mapped_column(Integer, default=0)
paused_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
interruptions: Mapped[int] = mapped_column(Integer, default=0)
cycle_index: Mapped[int] = mapped_column(Integer, default=1)
"""Position in the current long-break cycle (1..cycles_before_long_break)."""
task_source: Mapped[str | None] = mapped_column(String(32))
task_external_id: Mapped[str | None] = mapped_column(String(128))
task_title: Mapped[str | None] = mapped_column(Text)
note: Mapped[str | None] = mapped_column(Text)
abandon_reason: Mapped[str | None] = mapped_column(Text)
block_events: Mapped[list["BlockEvent"]] = relationship(back_populates="session")
@property
def is_work(self) -> bool:
return self.kind == "work"
class TaskRecord(Base):
"""A todo item, wherever it came from.
External tasks are cached here on every refresh so the UI is instant and the
daily report still works when Jira is down. Locally created tasks live here
too, with `source = "local"` — they are the only ones we own outright.
"""
__tablename__ = "tasks"
__table_args__ = (
UniqueConstraint("source", "external_id", name="uq_task_source_external"),
Index("ix_task_source_done", "source", "is_done"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
source: Mapped[str] = mapped_column(String(32), index=True)
external_id: Mapped[str] = mapped_column(String(128))
title: Mapped[str] = mapped_column(Text)
description: Mapped[str | None] = mapped_column(Text)
url: Mapped[str | None] = mapped_column(Text)
project: Mapped[str | None] = mapped_column(Text)
status: Mapped[str | None] = mapped_column(String(64))
priority: Mapped[str | None] = mapped_column(String(32))
due_date: Mapped[date | None] = mapped_column(Date, index=True)
start_date: Mapped[date | None] = mapped_column(Date)
labels: Mapped[list[str]] = mapped_column(JSON, default=list)
raw: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
is_done: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
completed_date: Mapped[date | None] = mapped_column(Date, index=True)
completed_here: Mapped[bool] = mapped_column(Boolean, default=False)
"""True when *this app* closed it — what the daily report counts."""
estimated_pomodoros: Mapped[int | None] = mapped_column(Integer)
pinned: Mapped[bool] = mapped_column(Boolean, default=False)
"""Shown at the top of the timer's task picker."""
first_seen_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow
)
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
stale: Mapped[bool] = mapped_column(Boolean, default=False)
"""Set when a refresh no longer returns the task (closed elsewhere, or filtered)."""
class BlockEvent(Base):
"""A blocked site or app you reached for anyway. Pure gold for the report."""
__tablename__ = "block_events"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
kind: Mapped[str] = mapped_column(String(16), index=True) # domain | app
target: Mapped[str] = mapped_column(String(255), index=True)
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
local_date: Mapped[date] = mapped_column(Date, index=True)
action: Mapped[str] = mapped_column(String(16), default="blocked") # blocked|killed|warned
details: Mapped[str | None] = mapped_column(Text)
session_id: Mapped[int | None] = mapped_column(ForeignKey("pomodoro_sessions.id"))
session: Mapped[PomodoroSession | None] = relationship(back_populates="block_events")
class DailyReport(Base):
"""A generated report, kept so re-sends and the web view agree."""
__tablename__ = "daily_reports"
__table_args__ = (UniqueConstraint("report_date", name="uq_report_date"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
report_date: Mapped[date] = mapped_column(Date, index=True)
payload: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
generated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
sent_channel: Mapped[str | None] = mapped_column(String(32))
score: Mapped[float] = mapped_column(Float, default=0.0)
class EventLog(Base):
"""Small append-only log used for "did we already send this?" questions.
Nudges, celebrations and briefings all need to be idempotent per day, and a
row here is cheaper than a column per message type.
"""
__tablename__ = "event_log"
__table_args__ = (Index("ix_eventlog_kind_date", "kind", "local_date"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
kind: Mapped[str] = mapped_column(String(48), index=True)
local_date: Mapped[date] = mapped_column(Date, index=True)
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
payload: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
+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
+300
View File
@@ -0,0 +1,300 @@
"""The user-editable settings, as one validated tree.
Everything the UI can change lives here: timer lengths, which todo sources are
on, what the blocker blocks, when Telegram gets poked and in what tone. It is
stored as a single JSON row (see `app.models.SettingsRow`), which keeps
"add a new option" to a one-line change instead of a migration.
Defaults are deliberately a complete, working configuration: a fresh install is
a classic 25/5/15 pomodoro with no integrations and nothing blocked.
"""
from __future__ import annotations
from datetime import time
from typing import Literal
from pydantic import BaseModel, Field, field_validator
# --------------------------------------------------------------------------
# Timer
# --------------------------------------------------------------------------
class PomodoroSettings(BaseModel):
"""Lengths and automation of the timer itself. All durations in minutes."""
work_minutes: int = Field(default=25, ge=1, le=240)
short_break_minutes: int = Field(default=5, ge=1, le=120)
long_break_minutes: int = Field(default=15, ge=1, le=240)
cycles_before_long_break: int = Field(default=4, ge=1, le=12)
auto_start_breaks: bool = True
"""Roll straight into the break when a work interval ends."""
auto_start_work: bool = False
"""Roll straight into the next work interval when a break ends."""
daily_goal_pomodoros: int = Field(default=8, ge=1, le=40)
"""The bar the daily report measures you against."""
strict_mode: bool = False
"""Abandoning a running work interval requires a reason, and is counted."""
allow_pause: bool = True
"""When false the only ways out of a work interval are finishing or abandoning."""
count_interruptions: bool = True
tick_sound: bool = False
alarm_sound: bool = True
alarm_volume: int = Field(default=70, ge=0, le=100)
desktop_notifications: bool = True
keep_on_top_during_work: bool = False
"""Desktop app: float the window above everything while a work interval runs."""
# --------------------------------------------------------------------------
# Todo sources
# --------------------------------------------------------------------------
class JiraSourceSettings(BaseModel):
"""Jira Cloud, authenticated with account email + API token (Basic auth)."""
enabled: bool = False
base_url: str = ""
email: str = ""
api_token: str = ""
extra_jql: str = ""
"""ANDed onto every query, e.g. `assignee = currentUser()`."""
start_date_field: str = "customfield_10015"
"""Jira's "Start date"; its id differs per site (see /rest/api/3/field)."""
include_all_open: bool = True
"""Fetch every open issue, not only the ones due today or overdue."""
max_issues: int = Field(default=200, ge=1, le=2000)
writeback_done: bool = True
"""Completing a Jira task here transitions the issue to Done in Jira."""
done_transition_name: str = "Done"
"""Preferred transition name when writing back; falls back to any 'done' one."""
class GoogleTasksSourceSettings(BaseModel):
"""Google Tasks, via an OAuth refresh token (see jira-helper's README)."""
enabled: bool = False
client_id: str = ""
client_secret: str = ""
refresh_token: str = ""
lists: list[str] = Field(default_factory=list)
"""Titles or ids to limit to. Empty = every list in the account."""
include_undated: bool = True
"""Keep tasks with no due date (jira-helper drops them; here they are todos)."""
writeback_done: bool = True
class TodoistSourceSettings(BaseModel):
"""Todoist, via a personal API token."""
enabled: bool = False
api_token: str = ""
projects: list[str] = Field(default_factory=list)
"""Names or ids to limit to. Empty = every project."""
filter_query: str = ""
"""Override the server-side filter. Empty = all open tasks."""
writeback_done: bool = True
class LaundrySourceSettings(BaseModel):
"""Campus laundry bookings — not a todo, but a thing that owns your time."""
enabled: bool = False
base_url: str = "https://4ka.inbicst.ru"
session_cookie: str = ""
"""The `sessionid` cookie, or a whole `name=value; ...` cookie string."""
class SourcesSettings(BaseModel):
jira: JiraSourceSettings = Field(default_factory=JiraSourceSettings)
google_tasks: GoogleTasksSourceSettings = Field(
default_factory=GoogleTasksSourceSettings
)
todoist: TodoistSourceSettings = Field(default_factory=TodoistSourceSettings)
laundry: LaundrySourceSettings = Field(default_factory=LaundrySourceSettings)
refresh_interval_minutes: int = Field(default=15, ge=1, le=1440)
"""How often the background job re-pulls every source."""
request_timeout_seconds: int = Field(default=30, ge=5, le=120)
# --------------------------------------------------------------------------
# Blocker (enforced by the desktop app)
# --------------------------------------------------------------------------
class BlockWindow(BaseModel):
"""A recurring time window in which blocking is on regardless of the timer."""
label: str = ""
start: time = time(9, 0)
end: time = time(18, 0)
days: list[int] = Field(default_factory=lambda: [0, 1, 2, 3, 4])
"""ISO weekdays, Monday = 0 .. Sunday = 6."""
class BlockerSettings(BaseModel):
enabled: bool = False
mode: Literal["during_work", "always", "scheduled"] = "during_work"
"""`during_work` blocks only while a work interval runs — the useful default."""
block_during_breaks: bool = False
blocked_domains: list[str] = Field(default_factory=list)
"""Bare hostnames, e.g. `youtube.com`. `www.` and subdomains are added for you."""
allowed_domains: list[str] = Field(default_factory=list)
"""Exceptions that survive a wildcard entry, e.g. `music.youtube.com`."""
blocked_apps: list[str] = Field(default_factory=list)
"""Process names, e.g. `steam`, `discord`, `Telegram`."""
kill_blocked_apps: bool = True
"""Terminate a blocked app when it appears; otherwise only warn and log."""
schedule: list[BlockWindow] = Field(default_factory=list)
strict: bool = False
"""The desktop app refuses to switch blocking off while a work interval runs."""
redirect_ip: str = "127.0.0.1"
app_poll_seconds: int = Field(default=5, ge=1, le=60)
notify_on_block: bool = True
# --------------------------------------------------------------------------
# Notifications and reports
# --------------------------------------------------------------------------
class QuietHours(BaseModel):
enabled: bool = False
start: time = time(23, 0)
end: time = time(8, 0)
class TelegramSettings(BaseModel):
"""Everything about the bot that nags, congratulates and reports."""
enabled: bool = False
bot_token: str = ""
chat_id: str = ""
commands_enabled: bool = True
"""Run a long-polling bot so /today, /report, /stats work in the chat."""
daily_report: bool = True
daily_report_time: time = time(21, 0)
morning_briefing: bool = True
"""A "here is your day" message with the todo list and yesterday's streak."""
morning_briefing_time: time = time(9, 0)
weekly_report: bool = True
weekly_report_weekday: int = Field(default=6, ge=0, le=6)
"""ISO weekday for the weekly wrap-up, Monday = 0 (default Sunday)."""
celebrate_every_n_pomodoros: int = Field(default=4, ge=0, le=50)
"""Cheer in the chat every N completed pomodoros today. 0 = never."""
nudge_enabled: bool = True
nudge_after_idle_minutes: int = Field(default=90, ge=10, le=600)
"""Poke you when you have not run a pomodoro for this long during the day."""
nudge_window_start: time = time(10, 0)
nudge_window_end: time = time(20, 0)
nudge_max_per_day: int = Field(default=4, ge=0, le=24)
goal_warning: bool = True
"""Warn in the evening when the daily goal is still out of reach."""
quiet_hours: QuietHours = Field(default_factory=QuietHours)
class SlackSettings(BaseModel):
enabled: bool = False
webhook_url: str = ""
class ReportSettings(BaseModel):
tone: Literal["friendly", "coach", "drill_sergeant", "dry"] = "friendly"
language: Literal["en", "ru"] = "en"
include_funny_stats: bool = True
include_task_list: bool = True
include_blocked_sites: bool = True
include_achievements: bool = True
include_comparison: bool = True
"""Compare today against your 7-day average."""
max_tasks_listed: int = Field(default=15, ge=1, le=100)
class NotificationsSettings(BaseModel):
telegram: TelegramSettings = Field(default_factory=TelegramSettings)
slack: SlackSettings = Field(default_factory=SlackSettings)
report: ReportSettings = Field(default_factory=ReportSettings)
# --------------------------------------------------------------------------
# Root
# --------------------------------------------------------------------------
class AppearanceSettings(BaseModel):
theme: Literal["light", "dark", "system"] = "system"
accent: str = "#6750A4"
"""Material 3 seed colour; the whole palette is derived from it."""
compact: bool = False
class AppSettings(BaseModel):
"""The single settings document stored in the database."""
timezone: str = "Europe/Moscow"
week_starts_monday: bool = True
pomodoro: PomodoroSettings = Field(default_factory=PomodoroSettings)
sources: SourcesSettings = Field(default_factory=SourcesSettings)
blocker: BlockerSettings = Field(default_factory=BlockerSettings)
notifications: NotificationsSettings = Field(default_factory=NotificationsSettings)
appearance: AppearanceSettings = Field(default_factory=AppearanceSettings)
@field_validator("timezone")
@classmethod
def _known_timezone(cls, value: str) -> str:
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
try:
ZoneInfo(value)
except (ZoneInfoNotFoundError, ValueError) as exc: # pragma: no cover
raise ValueError(f"unknown timezone: {value!r}") from exc
return value
# Keys whose values are never sent to a browser in full; the API replaces them
# with a masked placeholder and the UI sends the placeholder back unchanged.
SECRET_FIELDS = frozenset(
{
"api_token",
"client_secret",
"refresh_token",
"session_cookie",
"bot_token",
"webhook_url",
}
)
SECRET_MASK = "••••••••"
+12
View File
@@ -0,0 +1,12 @@
"""Todo sources.
Each module here turns one external service into `TodoItem`s and, where the
service allows it, closes a task back in that service. The auth schemes and the
pagination quirks are the ones worked out in `~/code/jira-helper`; what is new
is that these fetch **everything open**, not just what is due today, because a
pomodoro app needs something to point the timer at.
"""
from app.sources.base import SourceError, SourceResult, TodoItem
__all__ = ["SourceError", "SourceResult", "TodoItem"]
+70
View File
@@ -0,0 +1,70 @@
"""The contract every todo source implements."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date
from typing import Any, Protocol
class SourceError(RuntimeError):
"""A source could not be read. Carries a message meant for the user."""
@dataclass(slots=True)
class TodoItem:
"""One todo, normalised across Jira / Google Tasks / Todoist / local."""
source: str
external_id: str
title: str
description: str | None = None
url: str | None = None
project: str | None = None
status: str | None = None
priority: str | None = None
due_date: date | None = None
start_date: date | None = None
labels: list[str] = field(default_factory=list)
is_done: bool = False
raw: dict[str, Any] = field(default_factory=dict)
def days_overdue(self, today: date) -> int:
"""Days past the due date, or 0 when due today / not dated."""
if self.due_date is None:
return 0
return max((today - self.due_date).days, 0)
@dataclass(slots=True)
class SourceResult:
"""What one source returned, including the failure if it had one.
A source that is down costs you that source, never the whole page — the same
rule jira-helper applies to its message sections.
"""
source: str
items: list[TodoItem] = field(default_factory=list)
ok: bool = True
error: str | None = None
@classmethod
def failed(cls, source: str, error: str) -> "SourceResult":
return cls(source=source, items=[], ok=False, error=error)
class TodoSource(Protocol):
"""A configured, ready-to-call source."""
name: str
def fetch(self) -> list[TodoItem]:
"""Every open todo this source knows about."""
def complete(self, item: TodoItem) -> bool:
"""Close the task in the external system. False = not supported."""
def check(self) -> str:
"""Verify credentials; return a short human description of the account."""
+175
View File
@@ -0,0 +1,175 @@
"""Google Tasks as a todo source.
OAuth 2.0: the long-lived refresh token from the settings is exchanged for an
access token per run (cached for the life of the object). Obtain the refresh
token with jira-helper's `uv run google-tasks-auth` — the credentials are
interchangeable, so an existing setup works here unchanged.
Unlike jira-helper, undated tasks are kept by default: "someday" items are
still things you can spend a pomodoro on.
"""
from __future__ import annotations
from datetime import date
from typing import Any, Iterator
import httpx
from app.settings_schema import GoogleTasksSourceSettings
from app.sources.base import SourceError, TodoItem
_TOKEN_URL = "https://oauth2.googleapis.com/token"
_API_BASE = "https://tasks.googleapis.com/tasks/v1"
_PAGE_SIZE = 100
class GoogleTasksSource:
name = "google_tasks"
def __init__(self, config: GoogleTasksSourceSettings, *, timeout: int = 30) -> None:
if not (config.client_id and config.client_secret and config.refresh_token):
raise SourceError(
"Google Tasks needs a client id, client secret and refresh token."
)
self._config = config
self._client = httpx.Client(timeout=timeout)
self._access_token: str | None = None
# ---- plumbing ----------------------------------------------------------
def _token(self) -> str:
if self._access_token:
return self._access_token
try:
resp = self._client.post(
_TOKEN_URL,
data={
"client_id": self._config.client_id,
"client_secret": self._config.client_secret,
"refresh_token": self._config.refresh_token,
"grant_type": "refresh_token",
},
)
except httpx.HTTPError as exc:
raise SourceError(f"Google is unreachable: {exc}") from exc
if not resp.is_success:
raise SourceError(
"Could not refresh the Google access token "
f"({resp.status_code}). Re-run the Google auth flow for a fresh "
"refresh token."
)
token = (resp.json() or {}).get("access_token")
if not token:
raise SourceError("Google's token response carried no access_token.")
self._access_token = token
return token
def _get(self, path: str, params: dict[str, Any]) -> dict[str, Any]:
try:
resp = self._client.get(
f"{_API_BASE}{path}",
params=params,
headers={"Authorization": f"Bearer {self._token()}"},
)
except httpx.HTTPError as exc:
raise SourceError(f"Google is unreachable: {exc}") from exc
if not resp.is_success:
raise SourceError(f"Google GET {path} -> {resp.status_code}: {resp.text[:300]}")
return resp.json() or {}
def _paginate(self, path: str, params: dict[str, Any]) -> Iterator[dict[str, Any]]:
params = dict(params)
while True:
data = self._get(path, params)
yield from data.get("items", [])
token = data.get("nextPageToken")
if not token:
return
params["pageToken"] = token
def _task_lists(self) -> list[dict[str, Any]]:
lists = list(self._paginate("/users/@me/lists", {"maxResults": _PAGE_SIZE}))
if not self._config.lists:
return lists
wanted = set(self._config.lists)
return [
task_list
for task_list in lists
if wanted & {task_list.get("id", ""), task_list.get("title", "")}
]
# ---- source API --------------------------------------------------------
def fetch(self) -> list[TodoItem]:
items: list[TodoItem] = []
for task_list in self._task_lists():
list_id = task_list.get("id")
if not list_id:
continue
list_title = task_list.get("title") or "(untitled list)"
for raw in self._paginate(
f"/lists/{list_id}/tasks",
{
"maxResults": _PAGE_SIZE,
"showCompleted": "false",
"showHidden": "false",
"showDeleted": "false",
},
):
if raw.get("status") == "completed":
continue
due = _parse_due(raw.get("due"))
if due is None and not self._config.include_undated:
continue
task_id = raw.get("id") or ""
items.append(
TodoItem(
source=self.name,
# The list id is part of every write path, so it has to
# survive the round-trip through our database.
external_id=f"{list_id}:{task_id}",
title=(raw.get("title") or "").strip() or "(no title)",
description=(raw.get("notes") or "").strip() or None,
url=raw.get("webViewLink"),
project=list_title,
due_date=due,
raw={"list_id": list_id, "task_id": task_id},
)
)
return items
def check(self) -> str:
return f"{len(self._task_lists())} Google task list(s) visible"
def complete(self, item: TodoItem) -> bool:
"""PATCH the task's status — Google Tasks has no dedicated close verb."""
if not self._config.writeback_done:
return False
list_id, _, task_id = item.external_id.partition(":")
if not (list_id and task_id):
raise SourceError(f"Unusable Google task id: {item.external_id!r}")
try:
resp = self._client.patch(
f"{_API_BASE}/lists/{list_id}/tasks/{task_id}",
json={"status": "completed"},
headers={"Authorization": f"Bearer {self._token()}"},
)
except httpx.HTTPError as exc:
raise SourceError(f"Google is unreachable: {exc}") from exc
if not resp.is_success:
raise SourceError(f"Google could not complete the task: {resp.status_code}")
return True
def close(self) -> None:
self._client.close()
def _parse_due(raw: Any) -> date | None:
"""Google returns RFC-3339, but only the date part is ever meaningful."""
if not raw:
return None
try:
return date.fromisoformat(str(raw)[:10])
except ValueError:
return None
+184
View File
@@ -0,0 +1,184 @@
"""Jira Cloud as a todo source.
Auth is HTTP Basic with an Atlassian account email + API token, and search goes
through the token-paginated `/rest/api/3/search/jql` endpoint — both carried
over from jira-helper's client. The difference is the query: jira-helper asks
"what is due today", this asks "what is open at all", because you need
something to start a pomodoro on even when nothing is due.
Writing back is new here: finishing a Jira-backed pomodoro task transitions the
issue to Done, so the app is not a second place to keep your task list in sync.
"""
from __future__ import annotations
import re
from datetime import date
from typing import Any, Iterator
import httpx
from app.settings_schema import JiraSourceSettings
from app.sources.base import SourceError, TodoItem
_SEARCH_PATH = "/rest/api/3/search/jql"
_BASE_FIELDS = ["summary", "duedate", "status", "assignee", "priority", "project", "labels"]
_OPEN_CONDITION = "statusCategory != Done"
_CUSTOM_FIELD_RE = re.compile(r"^customfield_(\d+)$")
def start_date_clause(field_id: str) -> str:
"""`customfield_10015` has to be written `cf[10015]` in JQL; names get quoted."""
match = _CUSTOM_FIELD_RE.match(field_id)
return f"cf[{match.group(1)}]" if match else f'"{field_id}"'
class JiraSource:
name = "jira"
def __init__(self, config: JiraSourceSettings, *, timeout: int = 30) -> None:
if not (config.base_url and config.email and config.api_token):
raise SourceError("Jira needs a base URL, an account email and an API token.")
self._config = config
self._base_url = config.base_url.rstrip("/")
self._client = httpx.Client(
base_url=self._base_url,
auth=(config.email, config.api_token),
headers={"Accept": "application/json", "Content-Type": "application/json"},
timeout=timeout,
)
# ---- plumbing ----------------------------------------------------------
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
try:
resp = self._client.request(method, path, **kwargs)
except httpx.HTTPError as exc:
raise SourceError(f"Jira is unreachable: {exc}") from exc
if resp.status_code in (401, 403):
raise SourceError(
"Jira rejected the credentials (check the account email and the "
"API token — a token, never your password)."
)
if not resp.is_success:
raise SourceError(f"Jira {method} {path} -> {resp.status_code}: {resp.text[:300]}")
if resp.status_code == 204 or not resp.content:
return None
return resp.json()
def _fields(self) -> list[str]:
return [*_BASE_FIELDS, self._config.start_date_field]
def _jql(self) -> str:
"""Open issues, newest deadlines first, narrowed by the user's extra JQL."""
jql = _OPEN_CONDITION
if not self._config.include_all_open:
clause = start_date_clause(self._config.start_date_field)
jql = (
f"({_OPEN_CONDITION}) AND "
f"(duedate <= startOfDay('+7d') OR {clause} <= startOfDay())"
)
if self._config.extra_jql.strip():
jql = f"({jql}) AND ({self._config.extra_jql.strip()})"
return f"{jql} ORDER BY duedate ASC, priority DESC, updated DESC"
def _search(self, jql: str) -> Iterator[dict[str, Any]]:
payload: dict[str, Any] = {
"jql": jql,
"maxResults": 100,
"fields": self._fields(),
}
seen = 0
while True:
data = self._request("POST", _SEARCH_PATH, json=payload) or {}
for issue in data.get("issues", []):
yield issue
seen += 1
if seen >= self._config.max_issues:
return
token = data.get("nextPageToken")
if not token:
return
payload["nextPageToken"] = token
# ---- source API --------------------------------------------------------
def fetch(self) -> list[TodoItem]:
return [self._to_item(issue) for issue in self._search(self._jql())]
def check(self) -> str:
me = self._request("GET", "/rest/api/3/myself") or {}
return f"{me.get('displayName', 'unknown')} on {self._base_url}"
def complete(self, item: TodoItem) -> bool:
"""Transition the issue into a Done-category status.
Jira has no generic "close" verb: every project defines its own workflow,
so we ask the issue which transitions it offers and pick the one named in
the settings, else any transition landing in the Done category.
"""
if not self._config.writeback_done:
return False
key = item.external_id
data = self._request("GET", f"/rest/api/3/issue/{key}/transitions") or {}
transitions = data.get("transitions", [])
wanted = self._config.done_transition_name.strip().lower()
chosen = next(
(t for t in transitions if (t.get("name") or "").lower() == wanted),
None,
) or next(
(
t
for t in transitions
if ((t.get("to") or {}).get("statusCategory") or {}).get("key") == "done"
),
None,
)
if chosen is None:
raise SourceError(
f"{key} has no transition to a Done status"
+ (f" named {self._config.done_transition_name!r}" if wanted else "")
+ ". Available: "
+ ", ".join(t.get("name", "?") for t in transitions)
)
self._request(
"POST",
f"/rest/api/3/issue/{key}/transitions",
json={"transition": {"id": chosen["id"]}},
)
return True
def close(self) -> None:
self._client.close()
# ---- mapping -----------------------------------------------------------
def _to_item(self, issue: dict[str, Any]) -> TodoItem:
fields = issue.get("fields") or {}
key = issue.get("key", "")
status = (fields.get("status") or {}).get("name")
category = ((fields.get("status") or {}).get("statusCategory") or {}).get("key")
return TodoItem(
source=self.name,
external_id=key,
title=(fields.get("summary") or "").strip() or key,
url=f"{self._base_url}/browse/{key}",
project=((fields.get("project") or {}).get("name")),
status=status,
priority=(fields.get("priority") or {}).get("name"),
due_date=_parse_date(fields.get("duedate")),
start_date=_parse_date(fields.get(self._config.start_date_field)),
labels=list(fields.get("labels") or []),
is_done=category == "done",
raw={"key": key, "statusCategory": category},
)
def _parse_date(raw: Any) -> date | None:
if not raw:
return None
try:
return date.fromisoformat(str(raw)[:10])
except ValueError:
return None
+194
View File
@@ -0,0 +1,194 @@
"""Campus laundry bookings (4ka.inbicst.ru) as time commitments.
The site has no API and only offers Yandex SSO, so authentication *is* the
session cookie you copy out of your browser — the approach jira-helper settled
on. On the booking grid every slot is a `div.period`, and the ones you booked
carry an extra `my` class, which is the only thing that makes "my laundry
today" findable.
These are not todos you tick off: they are blocks of time that will interrupt
your pomodoros, so the UI shows them on the timer page and the daily report
mentions them.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from datetime import date, datetime, time
from typing import Any
from urllib.parse import urlsplit
import httpx
from bs4 import BeautifulSoup
from app.settings_schema import LaundrySourceSettings
from app.sources.base import SourceError, TodoItem
_PROFILE_PATH = "/profile/"
_GRID_PATH = "/"
_SESSION_COOKIE = "sessionid"
_COOKIE_PAIR_RE = re.compile(r"^[A-Za-z0-9_.\-]+=")
_TIME_RANGE_RE = re.compile(r"(\d{1,2}):(\d{2})\s*[-–—]\s*(\d{1,2}):(\d{2})")
_USER_AGENT = "Mozilla/5.0 (compatible; kadence laundry reader)"
_EXPIRED_HINT = (
"the laundry session cookie is no longer valid. Sign in at {base_url} in "
"your browser, copy the fresh `sessionid` cookie and paste it into "
"Settings → Sources → Laundry."
)
def parse_cookie_header(raw: str) -> dict[str, str]:
"""Accept either a bare `sessionid` value or a whole copied cookie string."""
if not _COOKIE_PAIR_RE.match(raw):
return {_SESSION_COOKIE: raw}
cookies: dict[str, str] = {}
for part in raw.split(";"):
name, _, value = part.strip().partition("=")
if name.strip() and value.strip():
cookies[name.strip()] = value.strip()
return cookies
@dataclass(slots=True)
class LaundrySlot:
location: str
machine: str
time_range: str
start: time | None
finished: bool
current: bool
refunded: bool
@property
def is_upcoming(self) -> bool:
return not self.finished and not self.refunded
class LaundrySource:
name = "laundry"
def __init__(self, config: LaundrySourceSettings, *, timeout: int = 30) -> None:
if not config.session_cookie:
raise SourceError("The laundry reader needs a `sessionid` cookie.")
cookies = parse_cookie_header(config.session_cookie)
if _SESSION_COOKIE not in cookies:
raise SourceError(
f"The laundry cookie has no {_SESSION_COOKIE!r} value in it."
)
self._base_url = config.base_url.rstrip("/")
host = urlsplit(self._base_url).hostname or ""
jar = httpx.Cookies()
for key, value in cookies.items():
jar.set(key, value, domain=host, path="/")
self._client = httpx.Client(
base_url=self._base_url,
cookies=jar,
headers={"User-Agent": _USER_AGENT},
timeout=timeout,
follow_redirects=False,
)
def _check_session(self) -> None:
"""An expired cookie silently serves the anonymous page, where none of
the slots are marked as yours — indistinguishable from "nothing booked".
So verify first."""
try:
resp = self._client.get(_PROFILE_PATH)
except httpx.HTTPError as exc:
raise SourceError(f"The laundry site is unreachable: {exc}") from exc
if resp.status_code != 200:
raise SourceError(_EXPIRED_HINT.format(base_url=self._base_url))
def fetch(self) -> list[TodoItem]:
self._check_session()
try:
resp = self._client.get(_GRID_PATH)
except httpx.HTTPError as exc:
raise SourceError(f"The laundry site is unreachable: {exc}") from exc
if not resp.is_success:
raise SourceError(f"Laundry grid -> {resp.status_code}")
_, slots = parse_my_slots(resp.text)
today = date.today()
return [
TodoItem(
source=self.name,
external_id=f"{today.isoformat()}:{slot.machine}:{slot.time_range}",
title=f"{slot.time_range} — {slot.machine} ({slot.location})",
project="Laundry",
status="running now" if slot.current else "booked",
due_date=today,
raw={
"start": slot.start.isoformat() if slot.start else None,
"current": slot.current,
},
)
for slot in slots
if slot.is_upcoming
]
def check(self) -> str:
self._check_session()
return f"signed in at {self._base_url}"
def complete(self, item: TodoItem) -> bool:
return False # the washing machine finishes on its own
def close(self) -> None:
self._client.close()
def _text(node: Any) -> str:
return node.get_text(strip=True) if node is not None else ""
def _parse_start(time_range: str) -> time | None:
match = _TIME_RANGE_RE.search(time_range)
if not match:
return None
hour, minute = int(match.group(1)), int(match.group(2))
if not (0 <= hour <= 23 and 0 <= minute <= 59):
return None
return time(hour, minute)
def parse_my_slots(html: str) -> tuple[str, list[LaundrySlot]]:
"""Pull your own slots off a booking grid. Returns `(day_label, slots)`."""
soup = BeautifulSoup(html, "html.parser")
day = soup.select_one(".date-machines")
if day is None:
raise SourceError(
"No booking grid in the response — the layout changed, or the "
"session was not accepted."
)
day_label = (day.get("id") or "").strip()
slots: list[LaundrySlot] = []
for block in day.select(".machines"):
location = _text(block.select_one(".floor-title")) or "?"
for machine in block.select(".machine"):
machine_name = _text(machine.select_one(".machine-title")) or "?"
for period in machine.select(".period.my"):
classes = set(period.get("class") or [])
time_range = _text(period.find("div")) or "?"
slots.append(
LaundrySlot(
location=location,
machine=machine_name,
time_range=time_range,
start=_parse_start(time_range),
finished="finished" in classes,
current="current" in classes,
refunded="refunded" in classes,
)
)
slots.sort(key=lambda slot: (slot.start is None, slot.start or time.min, slot.machine))
return day_label, slots
def slot_is_now(slot: LaundrySlot, moment: datetime | None = None) -> bool:
"""Whether the machine is running at `moment` (used by the timer page)."""
return slot.current
+125
View File
@@ -0,0 +1,125 @@
"""Builds the configured sources and pulls them all, independently.
Independence is the whole point: an expired laundry cookie or a Jira outage
costs you that one source and shows up as a warning beside it, never as an
empty task list.
"""
from __future__ import annotations
import logging
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Callable
from app.settings_schema import AppSettings
from app.sources.base import SourceError, SourceResult, TodoItem
from app.sources.google_tasks import GoogleTasksSource
from app.sources.jira import JiraSource
from app.sources.laundry import LaundrySource
from app.sources.todoist import TodoistSource
log = logging.getLogger(__name__)
SOURCE_LABELS = {
"jira": "Jira",
"todoist": "Todoist",
"google_tasks": "Google Tasks",
"laundry": "Laundry",
"local": "My list",
}
def build_sources(settings: AppSettings) -> dict[str, Any]:
"""Instantiate every enabled source. A misconfigured one is skipped, loudly."""
timeout = settings.sources.request_timeout_seconds
builders: list[tuple[str, Callable[[], Any]]] = [
("jira", lambda: JiraSource(settings.sources.jira, timeout=timeout)),
("todoist", lambda: TodoistSource(settings.sources.todoist, timeout=timeout)),
(
"google_tasks",
lambda: GoogleTasksSource(settings.sources.google_tasks, timeout=timeout),
),
("laundry", lambda: LaundrySource(settings.sources.laundry, timeout=timeout)),
]
enabled = {
"jira": settings.sources.jira.enabled,
"todoist": settings.sources.todoist.enabled,
"google_tasks": settings.sources.google_tasks.enabled,
"laundry": settings.sources.laundry.enabled,
}
sources: dict[str, Any] = {}
for name, build in builders:
if not enabled[name]:
continue
try:
sources[name] = build()
except SourceError as exc:
log.warning("source %s is enabled but unusable: %s", name, exc)
return sources
def _close(source: Any) -> None:
closer = getattr(source, "close", None)
if callable(closer):
try:
closer()
except Exception: # pragma: no cover
log.debug("closing %s failed", type(source).__name__, exc_info=True)
def fetch_all(settings: AppSettings) -> list[SourceResult]:
"""Fetch every enabled source in parallel; never raises."""
sources = build_sources(settings)
if not sources:
return []
def run(name: str, source: Any) -> SourceResult:
try:
items: list[TodoItem] = source.fetch()
return SourceResult(source=name, items=items)
except SourceError as exc:
return SourceResult.failed(name, str(exc))
except Exception as exc: # pragma: no cover - defensive
log.exception("source %s blew up", name)
return SourceResult.failed(name, f"unexpected error: {exc}")
try:
with ThreadPoolExecutor(max_workers=len(sources)) as pool:
futures = [pool.submit(run, name, src) for name, src in sources.items()]
return [future.result() for future in futures]
finally:
for source in sources.values():
_close(source)
def complete_remote(settings: AppSettings, item: TodoItem) -> tuple[bool, str | None]:
"""Close a task in its own system. Returns `(did_write, error)`."""
sources = build_sources(settings)
source = sources.get(item.source)
if source is None:
return False, None
try:
return bool(source.complete(item)), None
except SourceError as exc:
return False, str(exc)
finally:
for built in sources.values():
_close(built)
def check_source(settings: AppSettings, name: str) -> tuple[bool, str]:
"""Verify one source's credentials — what the settings page's Test button calls."""
sources = build_sources(settings)
source = sources.get(name)
if source is None:
return False, f"{SOURCE_LABELS.get(name, name)} is not enabled or not configured."
try:
return True, source.check()
except SourceError as exc:
return False, str(exc)
except Exception as exc: # pragma: no cover
return False, f"unexpected error: {exc}"
finally:
for built in sources.values():
_close(built)
+145
View File
@@ -0,0 +1,145 @@
"""Todoist as a todo source.
A personal API token sent as a Bearer token — no OAuth dance. jira-helper asks
Todoist's own `overdue | today` filter so Todoist's timezone and recurrence
rules decide what is due; here the default is *every* open task (you need
something to point the timer at), with the filter still available as a setting
for people who want the narrower list.
"""
from __future__ import annotations
from datetime import date
from typing import Any, Iterator
import httpx
from app.settings_schema import TodoistSourceSettings
from app.sources.base import SourceError, TodoItem
_API_BASE = "https://api.todoist.com/api/v1"
_TASK_URL = "https://app.todoist.com/app/task/{task_id}"
_PAGE_SIZE = 200
# Todoist stores priority upside down from how its apps label it: 4 is "p1".
_PRIORITY_LABELS = {4: "p1", 3: "p2", 2: "p3"}
class TodoistSource:
name = "todoist"
def __init__(self, config: TodoistSourceSettings, *, timeout: int = 30) -> None:
if not config.api_token:
raise SourceError("Todoist needs an API token (Settings → Integrations).")
self._config = config
self._client = httpx.Client(
base_url=_API_BASE,
headers={
"Accept": "application/json",
"Authorization": f"Bearer {config.api_token}",
},
timeout=timeout,
)
# ---- plumbing ----------------------------------------------------------
def _get(self, path: str, params: dict[str, Any]) -> dict[str, Any]:
try:
resp = self._client.get(path, params=params)
except httpx.HTTPError as exc:
raise SourceError(f"Todoist is unreachable: {exc}") from exc
if resp.status_code in (401, 403):
raise SourceError("Todoist rejected the API token.")
if not resp.is_success:
raise SourceError(f"Todoist GET {path} -> {resp.status_code}: {resp.text[:300]}")
return resp.json() or {}
def _paginate(self, path: str, params: dict[str, Any]) -> Iterator[dict[str, Any]]:
params = dict(params)
while True:
data = self._get(path, params)
yield from data.get("results", [])
cursor = data.get("next_cursor")
if not cursor:
return
params["cursor"] = cursor
def _projects(self) -> dict[str, str]:
return {
project["id"]: project.get("name") or "(untitled project)"
for project in self._paginate("/projects", {"limit": _PAGE_SIZE})
if project.get("id")
}
def _selected(self, project_id: str, project_name: str) -> bool:
if not self._config.projects:
return True
wanted = set(self._config.projects)
return bool(wanted & {project_id, project_name})
# ---- source API --------------------------------------------------------
def fetch(self) -> list[TodoItem]:
projects = self._projects()
query = self._config.filter_query.strip()
if query:
raw_tasks = self._paginate("/tasks/filter", {"query": query, "limit": _PAGE_SIZE})
else:
raw_tasks = self._paginate("/tasks", {"limit": _PAGE_SIZE})
items: list[TodoItem] = []
for raw in raw_tasks:
if raw.get("checked") or raw.get("is_deleted"):
continue
project_id = raw.get("project_id") or ""
project_name = projects.get(project_id, "(unknown project)")
if not self._selected(project_id, project_name):
continue
task_id = str(raw.get("id") or "")
due = raw.get("due") or {}
items.append(
TodoItem(
source=self.name,
external_id=task_id,
title=(raw.get("content") or "").strip() or "(no content)",
description=(raw.get("description") or "").strip() or None,
url=_TASK_URL.format(task_id=task_id) if task_id else None,
project=project_name,
priority=_PRIORITY_LABELS.get(raw.get("priority")),
due_date=_parse_due(due),
labels=list(raw.get("labels") or []),
raw={"recurring": bool(due.get("is_recurring"))},
)
)
return items
def check(self) -> str:
projects = self._projects()
return f"{len(projects)} Todoist project(s) visible"
def complete(self, item: TodoItem) -> bool:
"""`POST /tasks/{id}/close` — the same verb the apps' checkbox uses."""
if not self._config.writeback_done:
return False
try:
resp = self._client.post(f"/tasks/{item.external_id}/close")
except httpx.HTTPError as exc:
raise SourceError(f"Todoist is unreachable: {exc}") from exc
if not resp.is_success:
raise SourceError(f"Todoist could not close the task: {resp.status_code}")
return True
def close(self) -> None:
self._client.close()
def _parse_due(due: Any) -> date | None:
if not isinstance(due, dict):
return None
raw = due.get("date")
if not raw:
return None
try:
return date.fromisoformat(str(raw)[:10])
except ValueError:
return None
+75
View File
@@ -0,0 +1,75 @@
"""Timezone helpers.
Everything is stored in UTC and *reported* in the user's timezone, so there is
exactly one place that knows how to turn "now" into "which day does this count
for" — here.
"""
from __future__ import annotations
from datetime import date, datetime, time, timedelta, timezone
from zoneinfo import ZoneInfo
def tz(name: str) -> ZoneInfo:
try:
return ZoneInfo(name)
except Exception: # pragma: no cover - bad tz falls back rather than crashing
return ZoneInfo("UTC")
def now_utc() -> datetime:
return datetime.now(timezone.utc)
def ensure_utc(moment: datetime | None) -> datetime | None:
"""Attach UTC to a naive datetime.
SQLite has no timezone type, so a column declared `DateTime(timezone=True)`
comes back naive from SQLite and aware from Postgres. Everything we store is
UTC, so normalising on read keeps the arithmetic valid on both.
"""
if moment is None:
return None
return moment if moment.tzinfo is not None else moment.replace(tzinfo=timezone.utc)
def to_local(moment: datetime, tz_name: str) -> datetime:
if moment.tzinfo is None:
moment = moment.replace(tzinfo=timezone.utc)
return moment.astimezone(tz(tz_name))
def local_date(tz_name: str, moment: datetime | None = None) -> date:
"""The day a moment belongs to for the user (the report's unit of grouping)."""
return to_local(moment or now_utc(), tz_name).date()
def local_time(tz_name: str, moment: datetime | None = None) -> time:
return to_local(moment or now_utc(), tz_name).timetz().replace(tzinfo=None)
def day_bounds_utc(day: date, tz_name: str) -> tuple[datetime, datetime]:
"""The UTC half-open interval `[start, end)` covering a local calendar day."""
zone = tz(tz_name)
start = datetime.combine(day, time.min, tzinfo=zone)
end = start + timedelta(days=1)
return start.astimezone(timezone.utc), end.astimezone(timezone.utc)
def in_window(moment: time, start: time, end: time) -> bool:
"""Whether a clock time is inside a window, handling windows that wrap midnight."""
if start <= end:
return start <= moment <= end
return moment >= start or moment <= end
def humanize_minutes(total_minutes: int | float) -> str:
"""`95` -> `1h 35m`, `40` -> `40m`, `0` -> `0m`."""
minutes = round(total_minutes)
hours, rest = divmod(minutes, 60)
if hours and rest:
return f"{hours}h {rest}m"
if hours:
return f"{hours}h"
return f"{rest}m"
+40
View File
@@ -0,0 +1,40 @@
[project]
name = "kadence-backend"
version = "0.1.0"
description = "Pomodoro timer, todo aggregation, website/app blocking and daily Telegram reports."
readme = "../README.md"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.30",
"sqlalchemy>=2.0",
"psycopg[binary]>=3.2",
"pydantic>=2.8",
"pydantic-settings>=2.4",
"httpx>=0.27",
"beautifulsoup4>=4.12",
"apscheduler>=3.10,<4",
"python-dotenv>=1.0",
"tzdata>=2024.1",
]
[project.optional-dependencies]
dev = ["pytest>=8.0", "ruff>=0.6"]
[project.scripts]
kadence = "app.cli:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["app"]
[tool.ruff]
line-length = 90
target-version = "py312"
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
+64
View File
@@ -0,0 +1,64 @@
"""Test fixtures.
The engine is built at import time from the environment, so the database URL has
to be set before anything from `app` is imported — hence the env juggling here
rather than in a fixture.
"""
from __future__ import annotations
import os
import tempfile
import pytest
os.environ.setdefault("DATABASE_URL", "sqlite:///" + tempfile.mktemp(suffix=".db"))
os.environ["SCHEDULER_ENABLED"] = "false"
os.environ["TELEGRAM_BOT_ENABLED"] = "false"
# Keep the seeded settings empty so tests never reach a real Jira/Todoist.
for name in (
"JIRA_BASE_URL",
"JIRA_EMAIL",
"JIRA_API_TOKEN",
"TODOIST_API_TOKEN",
"GOOGLE_CLIENT_ID",
"GOOGLE_CLIENT_SECRET",
"GOOGLE_REFRESH_TOKEN",
"LAUNDRY_SESSION",
"TELEGRAM_BOT_TOKEN",
"TELEGRAM_CHAT_ID",
"SLACK_WEBHOOK_URL",
):
os.environ.pop(name, None)
from app.db import Base, SessionLocal, engine, init_db # noqa: E402
from app.services import settings_service # noqa: E402
from app.settings_schema import AppSettings # noqa: E402
@pytest.fixture()
def session():
Base.metadata.drop_all(bind=engine)
init_db()
db = SessionLocal()
try:
yield db
finally:
db.close()
@pytest.fixture()
def settings(session) -> AppSettings:
return settings_service.load_settings(session)
@pytest.fixture()
def client():
from fastapi.testclient import TestClient
from app.main import app
Base.metadata.drop_all(bind=engine)
init_db()
with TestClient(app) as test_client:
yield test_client
+99
View File
@@ -0,0 +1,99 @@
"""The HTTP surface, driven the way the clients drive it."""
from __future__ import annotations
def test_health_and_info(client):
assert client.get("/api/health").json()["status"] == "ok"
assert "auth_required" in client.get("/api/info").json()
def test_a_whole_pomodoro_through_the_api(client):
client.put("/api/settings", json={"pomodoro": {"daily_goal_pomodoros": 1}})
task = client.post("/api/tasks", json={"title": "Write the API"}).json()
state = client.post("/api/timer/start", json={"kind": "work", "task_id": task["id"]}).json()
assert state["current"]["kind"] == "work"
assert state["current"]["task"]["title"] == "Write the API"
assert client.post("/api/timer/pause").json()["current"]["status"] == "paused"
assert client.post("/api/timer/resume").json()["current"]["status"] == "running"
assert client.post("/api/timer/extend", json={"minutes": 5}).json()["current"]["planned_seconds"] > 0
done = client.post("/api/timer/complete", json={"auto_next": False}).json()
assert done["finished"]["status"] == "completed"
assert done["completed_today"] == 1
marked = client.post(f"/api/tasks/{task['id']}/done", json={"done": True}).json()
assert marked["task"]["is_done"] is True
report = client.get("/api/reports/daily").json()
assert report["report"]["metrics"]["pomodoros"] == 1
assert "Daily report" in report["telegram_preview"]
def test_timer_errors_are_400_not_500(client):
assert client.post("/api/timer/pause").status_code == 400
assert client.post("/api/timer/complete", json={}).status_code == 400
def test_task_crud(client):
created = client.post("/api/tasks", json={"title": "Temporary", "priority": "p2"}).json()
listing = client.get("/api/tasks").json()
assert any(task["id"] == created["id"] for task in listing["tasks"])
patched = client.patch(f"/api/tasks/{created['id']}", json={"pinned": True}).json()
assert patched["pinned"] is True
client.delete(f"/api/tasks/{created['id']}")
assert all(task["id"] != created["id"] for task in client.get("/api/tasks").json()["tasks"])
def test_refresh_reports_per_source_failures_without_failing(client):
client.put(
"/api/settings",
json={"sources": {"todoist": {"enabled": True, "api_token": "definitely-invalid"}}},
)
result = client.post("/api/tasks/refresh").json()
assert result["sources"], "the enabled source should be reported"
assert result["sources"][0]["ok"] is False
assert client.get("/api/tasks").status_code == 200
def test_blocker_policy_and_events(client):
client.put(
"/api/settings",
json={"blocker": {"enabled": True, "blocked_domains": ["youtube.com"]}},
)
assert client.get("/api/blocker/policy").json()["active"] is False
client.post("/api/timer/start", json={"kind": "work"})
policy = client.get("/api/blocker/policy").json()
assert policy["active"] is True
assert "youtube.com" in policy["domains"]
client.post("/api/blocker/events", json={"kind": "domain", "target": "youtube.com"})
events = client.get("/api/blocker/events").json()
assert events["total"] == 1
def test_settings_round_trip_masks_secrets(client):
client.put("/api/settings", json={"sources": {"todoist": {"api_token": "s3cret"}}})
body = client.get("/api/settings").json()
assert body["settings"]["sources"]["todoist"]["api_token"] == "••••••••"
assert body["defaults"]["blocked_domains"]
# Posting the mask back must not wipe the token.
client.put("/api/settings", json=body["settings"])
again = client.get("/api/settings").json()
assert again["settings"]["sources"]["todoist"]["api_token"] == "••••••••"
def test_invalid_settings_are_rejected(client):
assert client.put("/api/settings", json={"pomodoro": {"work_minutes": 0}}).status_code == 422
assert client.put("/api/settings", json={"timezone": "Mars/Olympus"}).status_code == 422
def test_notification_test_endpoint_falls_back_to_the_log(client):
body = client.post("/api/notifications/test", json={}).json()
assert body["deliveries"][0]["channel"] == "log"
+62
View File
@@ -0,0 +1,62 @@
"""Blocking policy: when it is on, and what is on the list."""
from __future__ import annotations
from app.services import blocker_service, pomodoro, settings_service
def test_disabled_blocker_blocks_nothing(session, settings):
pomodoro.start(session, settings, kind="work")
policy = blocker_service.policy(session, settings)
assert policy["active"] is False and policy["domains"] == []
def test_during_work_mode_follows_the_timer(session, settings):
settings.blocker.enabled = True
settings.blocker.blocked_domains = ["youtube.com"]
settings_service.save_settings(session, settings)
assert blocker_service.policy(session, settings)["active"] is False
pomodoro.start(session, settings, kind="work")
policy = blocker_service.policy(session, settings)
assert policy["active"] is True
assert "www.youtube.com" in policy["domains"]
pomodoro.complete(session, settings, auto_next=False)
assert blocker_service.policy(session, settings)["active"] is False
def test_breaks_do_not_block_unless_asked(session, settings):
settings.blocker.enabled = True
settings_service.save_settings(session, settings)
pomodoro.start(session, settings, kind="short_break")
assert blocker_service.policy(session, settings)["active"] is False
settings.blocker.block_during_breaks = True
settings_service.save_settings(session, settings)
assert blocker_service.policy(session, settings)["active"] is True
def test_always_mode_ignores_the_timer(session, settings):
settings.blocker.enabled = True
settings.blocker.mode = "always"
settings_service.save_settings(session, settings)
assert blocker_service.policy(session, settings)["active"] is True
def test_allowlist_wins_over_the_blocklist(session, settings):
settings.blocker.blocked_domains = ["youtube.com"]
settings.blocker.allowed_domains = ["m.youtube.com"]
hosts = blocker_service.expand_domains(settings)
assert "youtube.com" in hosts
assert "m.youtube.com" not in hosts
def test_events_are_grouped_for_the_report(session, settings):
for _ in range(3):
blocker_service.record_event(session, settings, kind="domain", target="YouTube.com")
blocker_service.record_event(session, settings, kind="app", target="steam")
summary = blocker_service.events_for_day(session, settings)
assert summary["total"] == 4
assert summary["by_target"][0] == {"target": "youtube.com", "count": 3}
+93
View File
@@ -0,0 +1,93 @@
"""The timer's state machine."""
from __future__ import annotations
import pytest
from app.models import PomodoroSession
from app.services import pomodoro, settings_service
def test_start_sets_duration_from_settings(session, settings):
row = pomodoro.start(session, settings, kind="work")
assert row.planned_seconds == settings.pomodoro.work_minutes * 60
assert row.status == "running"
assert pomodoro.remaining_seconds(row) <= row.planned_seconds
def test_minutes_override_wins(session, settings):
row = pomodoro.start(session, settings, kind="work", minutes=3)
assert row.planned_seconds == 180
def test_starting_again_abandons_the_previous_interval(session, settings):
first = pomodoro.start(session, settings, kind="work")
pomodoro.start(session, settings, kind="work")
session.refresh(first)
assert first.status == "abandoned"
assert len(session.query(PomodoroSession).all()) == 2
def test_pause_counts_an_interruption_and_freezes_the_clock(session, settings):
pomodoro.start(session, settings, kind="work")
row = pomodoro.pause(session, settings)
assert row.status == "paused"
assert row.interruptions == 1
frozen = pomodoro.elapsed_seconds(row)
assert pomodoro.elapsed_seconds(row) == frozen
def test_pause_can_be_switched_off(session, settings):
settings.pomodoro.allow_pause = False
settings_service.save_settings(session, settings)
pomodoro.start(session, settings, kind="work")
with pytest.raises(pomodoro.TimerError):
pomodoro.pause(session, settings)
def test_long_break_arrives_on_the_configured_cycle(session, settings):
settings.pomodoro.cycles_before_long_break = 2
settings.pomodoro.auto_start_breaks = False
settings_service.save_settings(session, settings)
pomodoro.start(session, settings, kind="work")
pomodoro.complete(session, settings, auto_next=False)
assert pomodoro.next_kind(session, settings) == "short_break"
pomodoro.start(session, settings, kind="short_break")
pomodoro.complete(session, settings, auto_next=False)
pomodoro.start(session, settings, kind="work")
pomodoro.complete(session, settings, auto_next=False)
assert pomodoro.next_kind(session, settings) == "long_break"
def test_auto_start_breaks_rolls_straight_on(session, settings):
settings.pomodoro.auto_start_breaks = True
settings_service.save_settings(session, settings)
pomodoro.start(session, settings, kind="work")
finished, follow_on = pomodoro.complete(session, settings)
assert finished.status == "completed"
assert follow_on is not None and follow_on.kind == "short_break"
def test_strict_mode_demands_a_reason(session, settings):
settings.pomodoro.strict_mode = True
settings_service.save_settings(session, settings)
pomodoro.start(session, settings, kind="work")
with pytest.raises(pomodoro.TimerError):
pomodoro.abandon(session, settings)
row = pomodoro.abandon(session, settings, reason="fire alarm")
assert row.status == "abandoned" and row.abandon_reason == "fire alarm"
def test_reap_closes_intervals_left_running(session, settings):
from datetime import timedelta
from app.models import utcnow
row = pomodoro.start(session, settings, kind="work")
row.started_at = utcnow() - timedelta(hours=9)
session.commit()
assert pomodoro.reap_stale(session, settings) == 1
session.refresh(row)
assert row.status == "abandoned"
+99
View File
@@ -0,0 +1,99 @@
"""Metrics, achievements and the rendered report."""
from __future__ import annotations
from datetime import timedelta
from app.models import utcnow
from app.services import pomodoro, report, settings_service, stats, tasks_service
from app.timeutil import local_date
def _run_pomodoros(session, settings, count: int, *, minutes: int = 25) -> None:
"""Record `count` finished work intervals, backdated so they do not overlap."""
for index in range(count):
row = pomodoro.start(session, settings, kind="work", minutes=minutes)
start = utcnow() - timedelta(minutes=(count - index) * (minutes + 5))
row.started_at = start
row.ended_at = start + timedelta(minutes=minutes)
row.actual_seconds = minutes * 60
row.status = "completed"
session.commit()
def test_day_metrics_counts_what_happened(session, settings):
_run_pomodoros(session, settings, 3)
metrics = stats.day_metrics(session, settings, local_date(settings.timezone))
assert metrics["pomodoros"] == 3
assert metrics["focus_minutes"] == 75
assert metrics["goal"] == settings.pomodoro.daily_goal_pomodoros
assert metrics["first_start"] and metrics["last_end"]
def test_longest_stretch_joins_adjacent_pomodoros(session, settings):
_run_pomodoros(session, settings, 3, minutes=25)
metrics = stats.day_metrics(session, settings, local_date(settings.timezone))
# 25-minute intervals five minutes apart are one unbroken stretch.
assert metrics["longest_stretch_minutes"] >= 75
def test_streak_survives_an_empty_today(session, settings):
today = local_date(settings.timezone)
_run_pomodoros(session, settings, 1)
# Move the work to yesterday and leave today empty.
from app.models import PomodoroSession
for row in session.query(PomodoroSession).all():
row.local_date = today - timedelta(days=1)
session.commit()
streak = stats.streak(session, settings, until=today)
assert streak["current"] == 1
def test_report_payload_has_the_pieces_the_ui_draws(session, settings):
settings.pomodoro.daily_goal_pomodoros = 2
settings_service.save_settings(session, settings)
_run_pomodoros(session, settings, 2)
task = tasks_service.create_local_task(session, title="Ship the thing")
tasks_service.complete_task(session, settings, task.id)
payload = report.build_daily(session, settings, local_date(settings.timezone))
assert payload["metrics"]["pomodoros"] == 2
assert payload["metrics"]["tasks_completed_count"] == 1
assert any(item["code"] == "goal_met" for item in payload["achievements"])
assert payload["fun_facts"]
assert 0 <= payload["score"] <= 100
def test_rendered_report_escapes_borrowed_text(session, settings):
task = tasks_service.create_local_task(session, title="Fix <b>&</b> ship")
tasks_service.complete_task(session, settings, task.id)
payload = report.build_daily(session, settings, local_date(settings.timezone))
text = report.render_daily_telegram(payload, settings)
assert "Fix &lt;b&gt;&amp;&lt;/b&gt; ship" in text
def test_russian_report_renders(session, settings):
settings.notifications.report.language = "ru"
settings.notifications.report.tone = "drill_sergeant"
settings_service.save_settings(session, settings)
_run_pomodoros(session, settings, 1)
payload = report.build_daily(session, settings, local_date(settings.timezone))
text = report.render_daily_telegram(payload, settings)
assert "Отчёт за день" in text
def test_weekly_review_covers_seven_days(session, settings):
_run_pomodoros(session, settings, 2)
payload = report.build_weekly(session, settings, local_date(settings.timezone))
assert len(payload["days"]) == 7
assert payload["pomodoros"] == 2
assert "Weekly review" in report.render_weekly_telegram(payload, settings)
def test_progress_bar_is_proportional():
assert report.progress_bar(0, 8) == "░" * 10
assert report.progress_bar(8, 8) == "█" * 10
assert report.progress_bar(4, 8).count("█") == 5
+72
View File
@@ -0,0 +1,72 @@
"""Settings: deep merge, secret masking, and the environment seed."""
from __future__ import annotations
from app.services import settings_service
from app.settings_schema import SECRET_MASK
def test_patch_merges_without_clobbering_siblings(session):
settings_service.apply_patch(session, {"pomodoro": {"work_minutes": 50}})
updated = settings_service.apply_patch(
session, {"pomodoro": {"daily_goal_pomodoros": 12}}
)
assert updated.pomodoro.work_minutes == 50
assert updated.pomodoro.daily_goal_pomodoros == 12
assert updated.pomodoro.short_break_minutes == 5 # untouched default
def test_secrets_are_masked_on_the_way_out(session):
settings_service.apply_patch(
session,
{"sources": {"todoist": {"enabled": True, "api_token": "super-secret"}}},
)
masked = settings_service.mask_secrets(
settings_service.load_settings(session).model_dump(mode="json")
)
assert masked["sources"]["todoist"]["api_token"] == SECRET_MASK
def test_sending_the_mask_back_keeps_the_stored_secret(session):
settings_service.apply_patch(
session, {"sources": {"todoist": {"api_token": "super-secret"}}}
)
settings_service.apply_patch(
session,
{"sources": {"todoist": {"api_token": SECRET_MASK, "enabled": True}}},
)
settings = settings_service.load_settings(session)
assert settings.sources.todoist.api_token == "super-secret"
assert settings.sources.todoist.enabled is True
def test_a_real_new_secret_replaces_the_old_one(session):
settings_service.apply_patch(session, {"sources": {"todoist": {"api_token": "old"}}})
settings_service.apply_patch(session, {"sources": {"todoist": {"api_token": "new"}}})
assert settings_service.load_settings(session).sources.todoist.api_token == "new"
def test_environment_seeds_the_sources(monkeypatch):
monkeypatch.setenv("JIRA_BASE_URL", "https://example.atlassian.net/")
monkeypatch.setenv("JIRA_EMAIL", "me@example.com")
monkeypatch.setenv("JIRA_API_TOKEN", "token")
monkeypatch.setenv("TODOIST_API_TOKEN", "todoist-token")
monkeypatch.setenv("TODOIST_PROJECTS", "Inbox, Учёба")
from app.config import env_settings
env_settings.cache_clear()
try:
settings = settings_service.settings_from_env()
finally:
env_settings.cache_clear()
assert settings.sources.jira.enabled
assert settings.sources.jira.base_url == "https://example.atlassian.net"
assert settings.sources.todoist.projects == ["Inbox", "Учёба"]
def test_listeners_fire_on_save(session):
seen = []
settings_service.on_change(lambda new: seen.append(new.pomodoro.work_minutes))
settings_service.apply_patch(session, {"pomodoro": {"work_minutes": 33}})
assert 33 in seen
+100
View File
@@ -0,0 +1,100 @@
"""Source mapping: the parts that work without a network."""
from __future__ import annotations
from datetime import date
from app.settings_schema import JiraSourceSettings, LaundrySourceSettings
from app.sources.jira import JiraSource, start_date_clause
from app.sources.laundry import parse_cookie_header, parse_my_slots
JIRA_CONFIG = JiraSourceSettings(
enabled=True,
base_url="https://example.atlassian.net",
email="me@example.com",
api_token="token",
)
def test_start_date_clause_handles_custom_fields_and_names():
assert start_date_clause("customfield_10015") == "cf[10015]"
assert start_date_clause("Start date") == '"Start date"'
def test_jira_jql_includes_the_extra_filter():
source = JiraSource(JIRA_CONFIG.model_copy(update={"extra_jql": "assignee = currentUser()"}))
jql = source._jql()
assert "statusCategory != Done" in jql
assert "assignee = currentUser()" in jql
source.close()
def test_jira_issue_maps_to_a_todo_item():
source = JiraSource(JIRA_CONFIG)
item = source._to_item(
{
"key": "ABC-1",
"fields": {
"summary": "Fix the thing",
"duedate": "2026-10-20",
"customfield_10015": "2026-10-15",
"status": {"name": "In Progress", "statusCategory": {"key": "indeterminate"}},
"priority": {"name": "High"},
"project": {"name": "Platform"},
"labels": ["backend"],
},
}
)
source.close()
assert item.external_id == "ABC-1"
assert item.title == "Fix the thing"
assert item.url == "https://example.atlassian.net/browse/ABC-1"
assert item.due_date == date(2026, 10, 20)
assert item.start_date == date(2026, 10, 15)
assert item.priority == "High"
assert item.is_done is False
assert item.days_overdue(date(2026, 10, 22)) == 2
def test_cookie_header_accepts_both_forms():
assert parse_cookie_header("abc123") == {"sessionid": "abc123"}
parsed = parse_cookie_header("csrftoken=x; sessionid=abc123")
assert parsed["sessionid"] == "abc123"
LAUNDRY_HTML = """
<div class="date-machines" id="Чт, 15 окт.">
<div class="machines">
<div class="floor-title">Этаж 1</div>
<div class="machine">
<div class="machine-title">Машинка 3</div>
<div class="period my finished"><div>09:00-12:00</div></div>
<div class="period my current"><div>18:00-21:00</div></div>
<div class="period"><div>21:00-00:00</div></div>
</div>
</div>
</div>
"""
def test_laundry_grid_yields_only_my_upcoming_slots():
label, slots = parse_my_slots(LAUNDRY_HTML)
assert label == "Чт, 15 окт."
assert len(slots) == 2
upcoming = [slot for slot in slots if slot.is_upcoming]
assert len(upcoming) == 1
assert upcoming[0].time_range == "18:00-21:00"
assert upcoming[0].machine == "Машинка 3"
assert upcoming[0].location == "Этаж 1"
def test_laundry_config_requires_a_session_cookie():
from app.sources.base import SourceError
try:
from app.sources.laundry import LaundrySource
LaundrySource(LaundrySourceSettings(enabled=True, session_cookie=""))
except SourceError:
return
raise AssertionError("expected a SourceError")
+71
View File
@@ -0,0 +1,71 @@
"""The Telegram commands — the chat is a remote control, so it needs testing."""
from __future__ import annotations
from app.services import settings_service, tasks_service, telegram_bot
def test_unknown_input_is_ignored(session):
assert telegram_bot.handle_text("hello there") is None
assert telegram_bot.handle_text("/nonsense") is None
def test_help_lists_the_verbs(session):
reply = telegram_bot.handle_text("/help")
assert "/start_work" in reply and "/report" in reply
def test_start_pause_resume_and_status(session):
assert "started" in telegram_bot.handle_text("/start_work 10").lower()
status = telegram_bot.handle_text("/status")
assert "work" in status.lower() or "focus" in status.lower()
assert "Paused" in telegram_bot.handle_text("/pause")
assert "Resumed" in telegram_bot.handle_text("/resume")
assert "Stopped" in telegram_bot.handle_text("/stop")
def test_stop_without_a_timer_explains_itself(session):
assert "No interval is running" in telegram_bot.handle_text("/stop")
def test_today_numbers_tasks_and_done_closes_them(session):
settings_service.load_settings(session)
first = tasks_service.create_local_task(session, title="Alpha task")
tasks_service.create_local_task(session, title="Beta task")
listing = telegram_bot.handle_text("/today")
assert "Alpha task" in listing and "<b>1.</b>" in listing
reply = telegram_bot.handle_text("/done 1")
assert "Alpha task" in reply
session.expire_all()
assert session.get(type(first), first.id).is_done is True
assert "No task with that number" in telegram_bot.handle_text("/done 99")
def test_done_without_a_listing_asks_for_one(session):
telegram_bot._INDEX.clear()
assert "/today" in telegram_bot.handle_text("/done 1")
def test_report_and_streak_render(session):
assert "Daily report" in telegram_bot.handle_text("/report")
assert "Streak" in telegram_bot.handle_text("/streak")
assert "Weekly review" in telegram_bot.handle_text("/week")
def test_block_command_describes_the_policy(session):
settings = settings_service.load_settings(session)
settings.blocker.enabled = True
settings.blocker.blocked_domains = ["youtube.com"]
settings_service.save_settings(session, settings)
reply = telegram_bot.handle_text("/block")
assert "Blocker" in reply and "Domains" in reply
def test_russian_replies_when_configured(session):
settings = settings_service.load_settings(session)
settings.notifications.report.language = "ru"
settings_service.save_settings(session, settings)
assert "помидорку" in telegram_bot.handle_text("/help")