init
This commit is contained in:
121 files changed
+17641
No files matched your search
@@ -0,0 +1,3 @@
|
||||
"""kadence backend: pomodoro engine, todo aggregation, reports."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -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"]
|
||||
@@ -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)]
|
||||
@@ -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}
|
||||
@@ -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()}
|
||||
@@ -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()
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
"""Domain services: settings, timer, task aggregation, reports, notifications."""
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
]
|
||||
@@ -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))
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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 <number> — 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 <номер> — закрыть задачу 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
|
||||
@@ -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 = "••••••••"
|
||||
@@ -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"]
|
||||
@@ -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."""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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}
|
||||
@@ -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"
|
||||
@@ -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 <b>&</b> 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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
Reference in new issue
Block a user