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

No files matched your search

+24
View File
@@ -0,0 +1,24 @@
# Everything that must never reach the build context.
.git
.env
*.db
*.sqlite3
backend/.venv
backend/static
backend/tests
backend/**/__pycache__
**/__pycache__
*.pyc
.pytest_cache
.ruff_cache
frontend/node_modules
frontend/dist
# The desktop app is native: it runs on the host, not in a container.
desktop/
docs/
*.md
!README.md
+64
View File
@@ -0,0 +1,64 @@
# Kadence — copy to `.env` and fill in what you use.
#
# Everything here is optional except the database (which compose sets for you):
# the app boots with a working 25/5/15 timer and no integrations, and every
# value below can also be set later in Settings → the UI writes to the same
# place. These are *seeds*: they fill the settings document on first boot.
#
# The variable names are deliberately the ones from ~/code/jira-helper, so you
# can copy that .env straight across.
# ---- infrastructure --------------------------------------------------------
APP_PORT=8000
TIMEZONE=Europe/Moscow
POSTGRES_USER=kadence
POSTGRES_PASSWORD=change-me
POSTGRES_DB=kadence
# Allowed browser origins; leave * if the backend is only on your machine.
CORS_ORIGINS=*
# Set this the moment the backend is reachable from anywhere but localhost.
# Every /api call then needs it as an `X-API-Token` header (the UI asks once,
# the desktop app has a field for it in Settings).
API_TOKEN=
SCHEDULER_ENABLED=true
TELEGRAM_BOT_ENABLED=true
LOG_LEVEL=INFO
# ---- Jira (required for the Jira source) -----------------------------------
JIRA_BASE_URL=https://your-site.atlassian.net
JIRA_EMAIL=you@example.com
JIRA_API_TOKEN=
# Only your own issues: assignee = currentUser()
JIRA_EXTRA_JQL=
# Jira's "Start date" field id; differs per site (see /rest/api/3/field).
JIRA_START_DATE_FIELD=customfield_10015
# ---- Google Tasks (optional) -----------------------------------------------
# Get a refresh token with jira-helper's `uv run google-tasks-auth`.
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REFRESH_TOKEN=
# Comma-separated list titles or ids; blank = every list.
GOOGLE_TASKS_LISTS=
# ---- Todoist (optional) ----------------------------------------------------
# app.todoist.com → Settings → Integrations → Developer → API token.
TODOIST_API_TOKEN=
TODOIST_PROJECTS=
# ---- Laundry bookings (optional) -------------------------------------------
# The `sessionid` cookie from 4ka.inbicst.ru — see jira-helper's README.
LAUNDRY_SESSION=
LAUNDRY_BASE_URL=https://4ka.inbicst.ru
# ---- Telegram (optional, but it is the point) ------------------------------
# @BotFather gives you the token; message the bot once and read the chat id
# from https://api.telegram.org/bot<TOKEN>/getUpdates
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID=
# ---- Slack (optional fallback) ---------------------------------------------
SLACK_WEBHOOK_URL=
+25
View File
@@ -0,0 +1,25 @@
# Python
__pycache__/
*.py[cod]
.venv/
.pytest_cache/
.ruff_cache/
*.db
*.sqlite3
# Node
node_modules/
dist/
# Built UI copied into the backend image
backend/static/
# Secrets and local state
.env
*.local
# Editors / OS
.idea/
.vscode/
.DS_Store
tsconfig.tsbuildinfo
+52
View File
@@ -0,0 +1,52 @@
# Kadence — one image that serves the API and the web UI.
#
# Stage 1 builds the React/Material front end; stage 2 installs the Python
# backend and copies those built files into `static/`, which FastAPI serves from
# the same origin. One container, one port, no CORS to configure.
# ---- stage 1: the web UI ---------------------------------------------------
FROM node:22-alpine AS web
WORKDIR /web
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm ci --no-audit --no-fund || npm install --no-audit --no-fund
COPY frontend/ ./
RUN npm run build
# ---- stage 2: the backend --------------------------------------------------
FROM python:3.12-slim AS runtime
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1 \
TZ=Europe/Moscow
WORKDIR /app
# Build deps for psycopg are already covered by the binary wheel; curl is here
# for the healthcheck only.
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl tzdata \
&& rm -rf /var/lib/apt/lists/*
COPY backend/pyproject.toml ./
COPY backend/app ./app
RUN pip install --no-cache-dir \
"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"
COPY --from=web /web/dist ./static
# The app writes nothing outside the database, so it can run unprivileged.
RUN useradd --create-home --uid 10001 kadence && chown -R kadence:kadence /app
USER kadence
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD curl -fsS http://localhost:8000/api/health || exit 1
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+42
View File
@@ -0,0 +1,42 @@
# Kadence — the handful of commands you actually run.
.DEFAULT_GOAL := help
SHELL := /bin/bash
help: ## Show this help
@grep -hE '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-18s\033[0m %s\n", $$1, $$2}'
up: ## Start the backend + web UI (http://localhost:8000)
docker compose up -d --build
down: ## Stop everything
docker compose down
logs: ## Follow the backend log
docker compose logs -f app
dev: ## Backend with reload + Vite dev server (http://localhost:5173)
docker compose -f docker-compose.yml -f docker-compose.dev.yml up
test: ## Run the backend test suite
cd backend && PYTHONPATH=$$PWD .venv/bin/python -m pytest -q
backend-venv: ## Create the backend virtualenv for local work
cd backend && python3 -m venv .venv && .venv/bin/pip install -e ".[dev]"
web-build: ## Build the web UI into frontend/dist
cd frontend && npm install && npm run build
desktop: ## Run the native desktop app against localhost:8000
cd desktop && python3 -m kadence_desktop
desktop-install: ## Install the launcher entry and icon for your user
./desktop/packaging/install-desktop.sh
helper-install: ## Install the privileged blocking helper (asks for sudo)
sudo ./desktop/helper/install.sh
report: ## Build and send today's report right now
cd backend && PYTHONPATH=$$PWD .venv/bin/python -m app.cli report
.PHONY: help up down logs dev test backend-venv web-build desktop desktop-install helper-install report
+233
View File
@@ -0,0 +1,233 @@
# Kadence
**Find the rhythm of your work day.** A pomodoro timer that knows what you have
to do, stands between you and the distractions, and tells you in Telegram how
the day went.
Three pieces, one backend:
| Piece | What it is | Where it runs |
| --- | --- | --- |
| **Backend** | FastAPI + Postgres: the clock, the task mirror, the blocking policy, the reports, the Telegram bot | Docker |
| **Web app** | React + Material UI — the whole thing in a browser, on any device | Docker (same container) |
| **Desktop app** | A **native Qt/QML** app: tray, D-Bus, real notifications, and the part that actually blocks websites and apps | Your machine |
The server owns the clock. Start a pomodoro on your phone's browser, watch it
count down in the tray, finish it from the desktop — it is one interval.
---
## What it does
### A fully configurable pomodoro timer
Focus / short break / long break lengths, how many cycles before a long break,
auto-start of breaks and of the next interval, a daily goal, pausing (or not),
strict mode (abandoning a focus interval costs you a written reason),
interruption counting, alarm sound and volume. Intervals are recorded as they
really happened — planned vs actual, paused time, interruptions — so the stats
are not a guess.
### Every todo you own, in one list
Sourced from the integrations worked out in [`~/code/jira-helper`](../jira-helper),
and taking **all** of them:
* **Jira** — every open issue across the site (not just what is due today), with
the due date, the "Start date" custom field, the status, the priority, the
project. Finishing a Jira-backed task here **transitions the issue to Done**.
* **Todoist** — every open task, or your own filter query. Finishing closes it in
Todoist.
* **Google Tasks** — every incomplete task, including undated ones. Finishing
marks it completed in Google.
* **Laundry bookings** (`4ka.inbicst.ru`) — the slots you booked today, because
they are blocks of time that will interrupt your pomodoros.
* **Local tasks** — the "and this too" you type at 9am.
Every source is pulled independently: an expired laundry cookie or a Jira outage
costs you that one source and shows up as a warning next to it, never as an
empty list. The list is mirrored into the database on every refresh, so the UI is
instant and the daily report still works when a source is down.
### A focus guard that actually blocks
Rules live on the server (edit them in the web app or the desktop app); the
native desktop app enforces them on your machine:
* **Websites** — `/etc/hosts` is rewritten through a tiny privileged helper under
polkit. No browser extension, no proxy, works for every app on the machine, and
you can audit it with `cat /etc/hosts`.
* **Applications** — `/proc` is watched and blocked apps are closed (or just
reported, if you prefer a warning).
* **When** — only while a focus interval runs (the default), always, or on a
schedule. Breaks can keep the block on or let it go.
* Every attempt that gets turned away is recorded — and teased about in the
evening report.
### A daily report that is worth reading
Pomodoros vs goal, focus and break minutes, the longest unbroken stretch, when
you started and stopped, interruptions, abandoned intervals, tasks closed (with
links), the sites you tried to sneak off to, your streak, a comparison against
your 7-day average, **achievements** (🎯 goal met, 🧘 deep work, 🦉 night owl,
🪃 comeback, 🚀 personal best…) and **fun facts**:
```
🍅 Daily report — 2026-10-18
████████░░ 6/8 pomodoros
⏱ Focus: 2h 35m · breaks: 35m
🧠 longest stretch: 1h 20m · 🕘 from 09:12 to 18:40 · 📵 interruptions: 3
🔥 Streak: 4 days (best 11)
📊 7-day average: 5.4 · yesterday: 7
✅ Tasks closed (3)
• PLAT-184 Fix the flaky importer (Jira)
• Write the design doc (Todoist)
…
🚫 Temptations blocked
• youtube.com × 12
• reddit.com × 4
🏅 Achievements
🧘 Deep work — 80 minutes without breaking the chain.
🛡️ Gatekeeper — The blocker turned you away 16 times. It won.
🍅 155 minutes of focus — that is 7.0 sitcom episodes you did not watch.
⏰ Your peak hour was 10:00. Defend it.
Target hit. Now protect the streak. 💪
```
Four tones (friendly / coach / drill sergeant / dry) and two languages (English,
Russian).
### A Telegram bot that pushes back
Unlike jira-helper's one-way daily message, this one is a remote control:
* **Morning briefing** — your day, grouped: overdue, due today, coming up, plus
your streak and the goal to beat.
* **Daily report** at your hour, **weekly review** on your day.
* **Nudges** — "90 minutes without a pomodoro. You are at 2/8 — one block puts you
back on plan." Windowed, rate-limited, and silent during quiet hours.
* **Cheers** every N pomodoros.
* **Commands**: `/start_work [minutes]`, `/break`, `/pause`, `/resume`, `/stop`,
`/status`, `/today`, `/done 3`, `/report`, `/week`, `/streak`, `/block`.
---
## Quick start
```bash
git clone <this repo> kadence && cd kadence
cp .env.example .env # fill in Jira / Todoist / Telegram (optional)
docker compose up -d --build # → http://localhost:8000
```
That is the backend and the web app. Then the native desktop app:
```bash
# Arch: the only dependency is PyQt6, which is a system package
sudo pacman -S --needed python-pyqt6 qt6-declarative
# (Debian/Ubuntu: sudo apt install python3-pyqt6 python3-pyqt6.qtquick qml6-module-qtquick-controls)
./desktop/packaging/install-desktop.sh # menu entry + icon + `kadence-desktop`
sudo ./desktop/helper/install.sh # the privileged helper, for website blocking
kadence-desktop
```
Point it at the backend in **Settings → Backend** (it defaults to
`http://localhost:8000`).
### Without Docker
```bash
make backend-venv # backend/.venv with the dependencies
cd backend && .venv/bin/python -m app.cli serve --reload
cd frontend && npm install && npm run dev # http://localhost:5173
```
---
## Configuration
Two layers, on purpose:
* **Environment** (`.env`) — infrastructure and **secrets**: the database, the
API token, and the seeds for the integrations. The variable names are
jira-helper's, so you can copy that `.env` across unchanged.
* **Settings** (the UI) — everything else, stored as one validated document in
the database and shared by the web app, the desktop app and the bot. Secrets
are masked (`••••••••`) on the way out and never clobbered on the way back.
Nothing has to be in the environment: a fresh install is a working 25/5/15 timer,
and you can fill everything in from **Settings**.
| Variable | What for |
| --- | --- |
| `DATABASE_URL` | set by compose; SQLite works too (`sqlite:///./kadence.db`) |
| `API_TOKEN` | if set, every `/api` call must send `X-API-Token`. Set it the moment the backend leaves localhost |
| `TIMEZONE` | which day a pomodoro belongs to, and when the jobs fire |
| `JIRA_*`, `TODOIST_*`, `GOOGLE_*`, `LAUNDRY_*` | seeds for the sources |
| `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID` | the bot |
| `SCHEDULER_ENABLED`, `TELEGRAM_BOT_ENABLED` | switch the background jobs / bot off |
---
## How it fits together
```
┌──────────────┐ ┌───────────────────────────────┐ ┌───────────────┐
│ Web app │ │ Backend (FastAPI) │ │ Jira │
│ React + MUI │──/api──│ • timer engine (owns clock) │───────▶│ Todoist │
└──────────────┘ │ • task mirror + write-back │ │ Google Tasks │
│ • blocking policy │ │ Laundry │
┌──────────────┐ │ • reports + achievements │ └───────────────┘
│ Desktop app │──/api──│ • scheduler (briefing, │
│ Qt/QML, D-Bus│ │ report, nudges) │───────▶ Telegram ⇄ you
│ hosts + /proc│ └───────────────────────────────┘
└──────────────┘ │
enforces Postgres
```
* `backend/` — FastAPI app. `app/services/` holds the thinking (timer, tasks,
stats, report, blocker policy, scheduler, bot); `app/sources/` holds one module
per integration; `app/api/` is a thin HTTP layer. 44 tests under `backend/tests`.
* `frontend/` — React + TypeScript + MUI, built by Vite into the backend image and
served from the same origin (so there is no CORS to configure).
* `desktop/` — the native app. See [`desktop/README.md`](desktop/README.md).
---
## Make targets
```
make up # docker compose up --build
make dev # backend with reload + Vite dev server
make test # backend test suite
make desktop # run the native app from the checkout
make desktop-install # menu entry + icon
make helper-install # the privileged blocking helper
make report # build and send today's report right now
```
The backend also has a CLI, handy from cron or a systemd timer:
```bash
python -m app.cli refresh # pull every source once
python -m app.cli briefing # send the morning briefing
python -m app.cli report # build and send today's report
python -m app.cli stats # print the numbers as JSON
```
---
## Security notes
* The desktop app never runs as root. The only privileged code is
`desktop/helper/kadence-helper` (~200 lines): it validates every hostname,
rewrites only the region between its own markers in `/etc/hosts`, writes
atomically, keeps a backup, and refuses anything that does not look like a
hostname. It is authorised by polkit (`auth_admin_keep` by default — one prompt
per session; `install.sh --passwordless` drops that for one user).
* Tokens live in the database and in `.env`, are masked in every API response,
and are never sent to the browser in full.
* Set `API_TOKEN` before exposing the backend beyond localhost; put it behind TLS
if it leaves your network.
+3
View File
@@ -0,0 +1,3 @@
"""kadence backend: pomodoro engine, todo aggregation, reports."""
__version__ = "0.1.0"
+20
View File
@@ -0,0 +1,20 @@
"""HTTP layer. Routers are thin; the thinking happens in `app.services`."""
from fastapi import APIRouter
from app.api import (
routes_blocker,
routes_reports,
routes_settings,
routes_tasks,
routes_timer,
)
api_router = APIRouter()
api_router.include_router(routes_timer.router)
api_router.include_router(routes_tasks.router)
api_router.include_router(routes_blocker.router)
api_router.include_router(routes_reports.router)
api_router.include_router(routes_settings.router)
__all__ = ["api_router"]
+37
View File
@@ -0,0 +1,37 @@
"""Shared dependencies: the database session and the optional shared secret."""
from __future__ import annotations
from typing import Annotated
from fastapi import Depends, Header, HTTPException, status
from sqlalchemy.orm import Session
from app.config import env_settings
from app.db import get_session
from app.services.settings_service import load_settings
from app.settings_schema import AppSettings
DbSession = Annotated[Session, Depends(get_session)]
def require_token(x_api_token: Annotated[str | None, Header()] = None) -> None:
"""Guard every API call when `API_TOKEN` is set.
Off by default: on a laptop the backend is bound to localhost and a token is
friction. Set it the moment the container is reachable from anywhere else.
"""
expected = env_settings().api_token
if not expected:
return
if x_api_token != expected:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Bad or missing X-API-Token."
)
def get_settings(session: DbSession) -> AppSettings:
return load_settings(session)
Settings = Annotated[AppSettings, Depends(get_settings)]
+58
View File
@@ -0,0 +1,58 @@
"""Blocker endpoints.
`GET /api/blocker/policy` is the one the desktop app polls every few seconds;
everything else is for the UI.
"""
from __future__ import annotations
from datetime import date
from fastapi import APIRouter, Query
from app.api.deps import DbSession, Settings
from app.api.schemas import BlockEventRequest
from app.services import blocker_service
from app.services.notify import notify
router = APIRouter(prefix="/api/blocker", tags=["blocker"])
@router.get("/policy")
def policy(session: DbSession, settings: Settings) -> dict:
return blocker_service.policy(session, settings)
@router.get("/events")
def events(
session: DbSession,
settings: Settings,
day: date | None = Query(default=None),
) -> dict:
return blocker_service.events_for_day(session, settings, day)
@router.post("/events")
def record(body: BlockEventRequest, session: DbSession, settings: Settings) -> dict:
"""The desktop app reports every attempt it turned away.
These are what make the daily report fun ("you reached for youtube 12
times"), so they are recorded even when the blocker only warned.
"""
event = blocker_service.record_event(
session,
settings,
kind=body.kind,
target=body.target,
action=body.action,
details=body.details,
)
if settings.blocker.notify_on_block and body.action == "killed":
notify(settings, f"🛑 Closed <b>{body.target}</b> — you are in a work interval.")
return {"id": event.id, "recorded": True}
@router.get("/defaults")
def defaults() -> dict:
"""Starter lists, so the blocker page is not an empty text box."""
return {"domains": blocker_service.DEFAULT_DOMAINS, "apps": blocker_service.DEFAULT_APPS}
+100
View File
@@ -0,0 +1,100 @@
"""Report and statistics endpoints."""
from __future__ import annotations
from datetime import date, timedelta
from fastapi import APIRouter, Query
from app.api.deps import DbSession, Settings
from app.api.schemas import SendReportRequest, TestNotificationRequest
from app.services import notify as notify_service
from app.services import report, scheduler, stats
from app.timeutil import ensure_utc, local_date
router = APIRouter(prefix="/api", tags=["reports"])
@router.get("/reports/daily")
def daily(
session: DbSession,
settings: Settings,
day: date | None = Query(default=None),
rebuild: bool = Query(default=True),
) -> dict:
target = day or local_date(settings.timezone)
row, payload = report.get_or_build_daily(session, settings, target, rebuild=rebuild)
return {
"report": payload,
"sent_at": ensure_utc(row.sent_at).isoformat() if row.sent_at else None,
"telegram_preview": report.render_daily_telegram(payload, settings),
}
@router.post("/reports/daily/send")
def send_daily(body: SendReportRequest, session: DbSession, settings: Settings) -> dict:
target = body.report_date or local_date(settings.timezone)
return report.send_daily(session, settings, target, force=body.force)
@router.get("/reports/weekly")
def weekly(
session: DbSession,
settings: Settings,
end: date | None = Query(default=None),
) -> dict:
target = end or local_date(settings.timezone)
payload = report.build_weekly(session, settings, target)
return {"report": payload, "telegram_preview": report.render_weekly_telegram(payload, settings)}
@router.post("/reports/weekly/send")
def send_weekly(session: DbSession, settings: Settings) -> dict:
payload = report.build_weekly(session, settings, local_date(settings.timezone))
text = report.render_weekly_telegram(payload, settings)
deliveries = notify_service.notify(settings, text, urgent=True)
return {"sent": any(d.ok for d in deliveries), "deliveries": [d.as_dict() for d in deliveries]}
@router.post("/reports/briefing/send")
def send_briefing(session: DbSession, settings: Settings) -> dict:
text = report.render_morning_briefing(session, settings)
deliveries = notify_service.notify(settings, text, urgent=True)
return {
"sent": any(d.ok for d in deliveries),
"deliveries": [d.as_dict() for d in deliveries],
"text": text,
}
@router.get("/stats/summary")
def summary(session: DbSession, settings: Settings, days: int = Query(default=30, ge=1, le=365)) -> dict:
today = local_date(settings.timezone)
return {
"today": stats.day_metrics(session, settings, today),
"streak": stats.streak(session, settings),
"lifetime": stats.lifetime(session, settings),
"average_7d": stats.averages(session, settings, 7, until=today),
"range": stats.range_metrics(session, settings, today - timedelta(days=days - 1), today),
"heatmap": stats.heatmap(session, settings, weeks=12),
}
@router.get("/stats/day")
def day_stats(session: DbSession, settings: Settings, day: date | None = Query(default=None)) -> dict:
return stats.day_metrics(session, settings, day or local_date(settings.timezone))
@router.post("/notifications/test")
def test_notification(body: TestNotificationRequest, settings: Settings) -> dict:
text = body.text or (
"🍅 <b>Kadence</b>\nNotifications are wired up correctly."
)
deliveries = notify_service.notify(settings, text, urgent=True)
return {"deliveries": [d.as_dict() for d in deliveries]}
@router.get("/scheduler/jobs")
def scheduler_jobs() -> dict:
"""What the background jobs are, and when they next fire."""
return {"jobs": scheduler.describe()}
+54
View File
@@ -0,0 +1,54 @@
"""Settings endpoints.
The browser never sees a real token: `GET` masks every secret, and a `PUT` that
sends the mask back untouched keeps the stored value.
"""
from __future__ import annotations
import json
from typing import Any
from fastapi import APIRouter, Body, HTTPException
from pydantic import ValidationError
from app.api.deps import DbSession, Settings
from app.services import blocker_service, settings_service
from app.settings_schema import AppSettings
router = APIRouter(prefix="/api/settings", tags=["settings"])
@router.get("")
def get_settings(settings: Settings) -> dict:
return {
"settings": settings_service.mask_secrets(settings.model_dump(mode="json")),
"defaults": {
"blocked_domains": blocker_service.DEFAULT_DOMAINS,
"blocked_apps": blocker_service.DEFAULT_APPS,
},
}
@router.put("")
def put_settings(session: DbSession, patch: dict[str, Any] = Body(...)) -> dict:
try:
updated = settings_service.apply_patch(session, patch)
except ValidationError as exc:
# `exc.errors()` can carry exception objects in `ctx`, which JSON cannot
# take; pydantic's own `json()` renders them as text.
raise HTTPException(status_code=422, detail=json.loads(exc.json())) from exc
return {"settings": settings_service.mask_secrets(updated.model_dump(mode="json"))}
@router.post("/reset")
def reset_settings(session: DbSession) -> dict:
"""Back to defaults, re-seeded from the environment."""
updated = settings_service.save_settings(session, settings_service.settings_from_env())
return {"settings": settings_service.mask_secrets(updated.model_dump(mode="json"))}
@router.get("/schema")
def settings_schema() -> dict:
"""The JSON Schema, so a client can render the settings form generically."""
return AppSettings.model_json_schema()
+121
View File
@@ -0,0 +1,121 @@
"""Task endpoints: the aggregated list, refreshing it, and acting on it."""
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Query
from app.api.deps import DbSession, Settings
from app.api.schemas import TaskCreateRequest, TaskDoneRequest, TaskUpdateRequest
from app.models import TaskRecord
from app.services import tasks_service
from app.sources.registry import SOURCE_LABELS, check_source
from app.timeutil import ensure_utc, local_date
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
def task_dict(record: TaskRecord, today) -> dict:
return {
"id": record.id,
"source": record.source,
"source_label": SOURCE_LABELS.get(record.source, record.source),
"external_id": record.external_id,
"title": record.title,
"description": record.description,
"url": record.url,
"project": record.project,
"status": record.status,
"priority": record.priority,
"due_date": record.due_date.isoformat() if record.due_date else None,
"start_date": record.start_date.isoformat() if record.start_date else None,
"labels": record.labels or [],
"is_done": record.is_done,
"completed_at": ensure_utc(record.completed_at).isoformat() if record.completed_at else None,
"pinned": record.pinned,
"stale": record.stale,
"estimated_pomodoros": record.estimated_pomodoros,
"days_overdue": (today - record.due_date).days if record.due_date and record.due_date < today else 0,
"bucket": _bucket_of(record, today),
}
def _bucket_of(record: TaskRecord, today) -> str:
if record.due_date is None:
return "someday"
if record.due_date < today:
return "overdue"
if record.due_date == today:
return "today"
return "upcoming"
@router.get("")
def list_tasks(
session: DbSession,
settings: Settings,
include_done: bool = Query(default=False),
include_stale: bool = Query(default=False),
source: list[str] | None = Query(default=None),
) -> dict:
today = local_date(settings.timezone)
records = tasks_service.list_tasks(
session, include_done=include_done, include_stale=include_stale, sources=source
)
buckets = tasks_service.bucket_tasks(records, today)
return {
"date": today.isoformat(),
"tasks": [task_dict(record, today) for record in records],
"counts": {name: len(items) for name, items in buckets.items()},
"sources": sorted({record.source for record in records}),
}
@router.post("/refresh")
def refresh(session: DbSession, settings: Settings) -> dict:
"""Pull every enabled source now. Failures come back per source, not as a 500."""
return tasks_service.refresh_tasks(session, settings)
@router.post("")
def create(body: TaskCreateRequest, session: DbSession, settings: Settings) -> dict:
record = tasks_service.create_local_task(
session,
title=body.title,
description=body.description,
due_date=body.due_date,
priority=body.priority,
project=body.project,
estimated_pomodoros=body.estimated_pomodoros,
)
return task_dict(record, local_date(settings.timezone))
@router.patch("/{task_id}")
def update(task_id: int, body: TaskUpdateRequest, session: DbSession, settings: Settings) -> dict:
try:
record = tasks_service.update_task(session, task_id, **body.model_dump(exclude_unset=True))
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return task_dict(record, local_date(settings.timezone))
@router.post("/{task_id}/done")
def mark_done(task_id: int, body: TaskDoneRequest, session: DbSession, settings: Settings) -> dict:
try:
record, warning = tasks_service.complete_task(session, settings, task_id, done=body.done)
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return {"task": task_dict(record, local_date(settings.timezone)), "warning": warning}
@router.delete("/{task_id}")
def delete(task_id: int, session: DbSession) -> dict:
tasks_service.delete_task(session, task_id)
return {"deleted": task_id}
@router.post("/sources/{name}/test")
def test_source(name: str, settings: Settings) -> dict:
"""What the settings page's "Test connection" button calls."""
ok, detail = check_source(settings, name)
return {"source": name, "ok": ok, "detail": detail}
+155
View File
@@ -0,0 +1,155 @@
"""The timer endpoints. Thin: every decision lives in `services.pomodoro`."""
from __future__ import annotations
from datetime import date
from fastapi import APIRouter, HTTPException, Query
from app.api.deps import DbSession, Settings
from app.api.schemas import (
AbandonRequest,
AttachTaskRequest,
CompleteTimerRequest,
ExtendRequest,
StartTimerRequest,
)
from app.services import pomodoro, report, stats
from app.services.notify import count_sent, notify, record_sent
from app.timeutil import local_date
router = APIRouter(prefix="/api/timer", tags=["timer"])
@router.get("/state")
def get_state(session: DbSession, settings: Settings) -> dict:
return pomodoro.state(session, settings)
@router.post("/start")
def start(body: StartTimerRequest, session: DbSession, settings: Settings) -> dict:
try:
pomodoro.start(
session,
settings,
kind=body.kind,
minutes=body.minutes,
task_id=body.task_id,
note=body.note,
)
except pomodoro.TimerError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return pomodoro.state(session, settings)
@router.post("/pause")
def pause(session: DbSession, settings: Settings) -> dict:
try:
pomodoro.pause(session, settings)
except pomodoro.TimerError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return pomodoro.state(session, settings)
@router.post("/resume")
def resume(session: DbSession, settings: Settings) -> dict:
try:
pomodoro.resume(session)
except pomodoro.TimerError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return pomodoro.state(session, settings)
@router.post("/complete")
def complete(body: CompleteTimerRequest, session: DbSession, settings: Settings) -> dict:
try:
finished, follow_on = pomodoro.complete(session, settings, auto_next=body.auto_next)
except pomodoro.TimerError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
celebration = _maybe_celebrate(session, settings) if finished.is_work else None
state = pomodoro.state(session, settings)
state["finished"] = pomodoro.session_dict(finished)
state["auto_started"] = pomodoro.session_dict(follow_on)
state["celebration"] = celebration
return state
@router.post("/abandon")
def abandon(body: AbandonRequest, session: DbSession, settings: Settings) -> dict:
try:
pomodoro.abandon(session, settings, reason=body.reason)
except pomodoro.TimerError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return pomodoro.state(session, settings)
@router.post("/interrupt")
def interrupt(session: DbSession, settings: Settings) -> dict:
try:
pomodoro.add_interruption(session)
except pomodoro.TimerError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return pomodoro.state(session, settings)
@router.post("/extend")
def extend(body: ExtendRequest, session: DbSession, settings: Settings) -> dict:
try:
pomodoro.extend(session, body.minutes)
except pomodoro.TimerError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return pomodoro.state(session, settings)
@router.post("/task")
def attach_task(body: AttachTaskRequest, session: DbSession, settings: Settings) -> dict:
try:
pomodoro.attach_task(session, body.task_id)
except pomodoro.TimerError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return pomodoro.state(session, settings)
@router.get("/sessions")
def sessions(
session: DbSession,
settings: Settings,
day: date | None = Query(default=None, description="Local date; defaults to today."),
) -> dict:
target = day or local_date(settings.timezone)
rows = pomodoro.sessions_for_day(session, settings, target)
return {
"date": target.isoformat(),
"sessions": [pomodoro.session_dict(row) for row in rows],
"metrics": stats.day_metrics(session, settings, target),
}
def _maybe_celebrate(session, settings) -> str | None:
"""Cheer in Telegram every N pomodoros — the cheapest dopamine there is."""
every = settings.notifications.telegram.celebrate_every_n_pomodoros
if not (settings.notifications.telegram.enabled and every):
return None
done = pomodoro.completed_work_today(session, settings)
if done == 0 or done % every != 0:
return None
today = local_date(settings.timezone)
kind = f"celebrate:{done}"
if count_sent(session, kind, today):
return None
ru = settings.notifications.report.language == "ru"
goal = settings.pomodoro.daily_goal_pomodoros
bar = report.progress_bar(done, goal)
text = (
f"🎉 <b>{done}</b> помидорок готово!\n{bar} {done}/{goal}\nДержи темп."
if ru
else f"🎉 <b>{done}</b> pomodoros done!\n{bar} {done}/{goal}\nKeep the rhythm."
)
deliveries = notify(settings, text)
if any(delivery.ok for delivery in deliveries):
record_sent(session, kind, today, {"pomodoros": done})
return text
+79
View File
@@ -0,0 +1,79 @@
"""Request bodies. Responses are plain dicts built by the services."""
from __future__ import annotations
from datetime import date
from typing import Any, Literal
from pydantic import BaseModel, Field
class StartTimerRequest(BaseModel):
kind: Literal["work", "short_break", "long_break"] | None = None
minutes: int | None = Field(default=None, ge=1, le=240)
task_id: int | None = None
note: str | None = None
class AbandonRequest(BaseModel):
reason: str | None = None
class ExtendRequest(BaseModel):
minutes: int = Field(default=5, ge=1, le=120)
class AttachTaskRequest(BaseModel):
task_id: int | None = None
class CompleteTimerRequest(BaseModel):
auto_next: bool = True
class TaskCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=500)
description: str | None = None
due_date: date | None = None
priority: str | None = None
project: str | None = None
estimated_pomodoros: int | None = Field(default=None, ge=1, le=50)
class TaskUpdateRequest(BaseModel):
title: str | None = None
description: str | None = None
due_date: date | None = None
priority: str | None = None
project: str | None = None
estimated_pomodoros: int | None = None
pinned: bool | None = None
class TaskDoneRequest(BaseModel):
done: bool = True
class BlockEventRequest(BaseModel):
kind: Literal["domain", "app"] = "domain"
target: str = Field(min_length=1, max_length=255)
action: Literal["blocked", "killed", "warned"] = "blocked"
details: str | None = None
class SendReportRequest(BaseModel):
report_date: date | None = None
force: bool = True
class TestNotificationRequest(BaseModel):
text: str | None = None
class SettingsPatch(BaseModel):
"""A partial settings document; anything omitted keeps its stored value."""
model_config = {"extra": "allow"}
def as_dict(self) -> dict[str, Any]:
return self.model_dump(exclude_unset=True)
+76
View File
@@ -0,0 +1,76 @@
"""`kadence` — run the server, or poke it from a terminal/cron."""
from __future__ import annotations
import argparse
import json
from datetime import date
from app.db import init_db, session_scope
from app.services import report, settings_service, stats, tasks_service
from app.timeutil import local_date
def main() -> int:
parser = argparse.ArgumentParser(prog="kadence")
sub = parser.add_subparsers(dest="command", required=True)
serve = sub.add_parser("serve", help="run the API (and the web UI)")
serve.add_argument("--host", default="0.0.0.0")
serve.add_argument("--port", type=int, default=8000)
serve.add_argument("--reload", action="store_true")
sub.add_parser("refresh", help="pull every todo source once")
sub.add_parser("briefing", help="send the morning briefing now")
report_cmd = sub.add_parser("report", help="build and send today's report")
report_cmd.add_argument("--date", type=date.fromisoformat, default=None)
report_cmd.add_argument("--print", action="store_true", help="print, do not send")
sub.add_parser("stats", help="print a summary as JSON")
args = parser.parse_args()
if args.command == "serve":
import uvicorn
uvicorn.run("app.main:app", host=args.host, port=args.port, reload=args.reload)
return 0
init_db()
with session_scope() as session:
settings = settings_service.load_settings(session)
if args.command == "refresh":
print(json.dumps(tasks_service.refresh_tasks(session, settings), indent=2))
elif args.command == "briefing":
from app.services.notify import notify
text = report.render_morning_briefing(session, settings)
print(text)
print([d.as_dict() for d in notify(settings, text, urgent=True)])
elif args.command == "report":
day = args.date or local_date(settings.timezone)
if args.print:
_, payload = report.get_or_build_daily(session, settings, day, rebuild=True)
print(report.render_daily_telegram(payload, settings))
else:
result = report.send_daily(session, settings, day, force=True)
print(result.get("text", ""))
print([d for d in result.get("deliveries", [])])
elif args.command == "stats":
today = local_date(settings.timezone)
print(
json.dumps(
{
"today": stats.day_metrics(session, settings, today),
"streak": stats.streak(session, settings),
"lifetime": stats.lifetime(session, settings),
},
indent=2,
ensure_ascii=False,
)
)
return 0
if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())
+92
View File
@@ -0,0 +1,92 @@
"""Process-level configuration: where the database is, who may call us, and the
seed values for the integrations.
Two layers of configuration exist in this app and they do different jobs:
* **This module** — environment variables. Infrastructure plus *secrets*
(API tokens), so they can live in a `.env` file or a Docker secret and never
touch the database or the browser.
* **`app.settings_schema.AppSettings`** — the user-editable settings stored in
the database and edited in the UI (pomodoro lengths, blocked sites, report
times...). On first boot the secrets below are copied into it as defaults.
So: `JIRA_API_TOKEN` in the environment gets you a working Jira source without
ever opening the settings page, and anything you later change in the UI wins.
"""
from __future__ import annotations
from functools import lru_cache
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class EnvSettings(BaseSettings):
model_config = SettingsConfigDict(
env_file=(".env", "../.env"),
env_file_encoding="utf-8",
extra="ignore",
)
# ---- infrastructure ----------------------------------------------------
database_url: str = Field(
default="sqlite:///./kadence.db",
description="SQLAlchemy URL. Docker compose points this at Postgres.",
)
cors_origins: str = Field(
default="*",
description="Comma-separated list of allowed browser origins, or '*'.",
)
timezone: str = Field(
default="Europe/Moscow",
description="Fallback timezone for 'today' and for scheduled jobs.",
)
scheduler_enabled: bool = Field(
default=True,
description="Run the background jobs (daily report, nudges, refresh).",
)
telegram_bot_enabled: bool = Field(
default=True,
description="Run the Telegram command bot when a token is configured.",
)
api_token: str = Field(
default="",
description=(
"Optional shared secret. When set, every /api call must send it as "
"`X-API-Token` (the desktop app and the web UI both do)."
),
)
# ---- seeds for the editable settings -----------------------------------
jira_base_url: str = ""
jira_email: str = ""
jira_api_token: str = ""
jira_extra_jql: str = ""
jira_start_date_field: str = "customfield_10015"
google_client_id: str = ""
google_client_secret: str = ""
google_refresh_token: str = ""
google_tasks_lists: str = ""
todoist_api_token: str = ""
todoist_projects: str = ""
laundry_session: str = ""
laundry_base_url: str = "https://4ka.inbicst.ru"
telegram_bot_token: str = ""
telegram_chat_id: str = ""
slack_webhook_url: str = ""
@property
def cors_origin_list(self) -> list[str]:
if self.cors_origins.strip() == "*":
return ["*"]
return [item.strip() for item in self.cors_origins.split(",") if item.strip()]
@lru_cache
def env_settings() -> EnvSettings:
return EnvSettings()
+62
View File
@@ -0,0 +1,62 @@
"""Database engine and session plumbing.
Synchronous SQLAlchemy on purpose: the background scheduler, the Telegram
polling bot and the request handlers all touch the same tables, and one boring
threadsafe sessionmaker is far easier to reason about than three event loops.
FastAPI runs plain `def` endpoints in a threadpool, so nothing blocks.
"""
from __future__ import annotations
from collections.abc import Iterator
from contextlib import contextmanager
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.config import env_settings
class Base(DeclarativeBase):
pass
def _engine_kwargs(url: str) -> dict:
if url.startswith("sqlite"):
# One file, many threads (scheduler + bot + requests).
return {"connect_args": {"check_same_thread": False}}
return {"pool_pre_ping": True, "pool_size": 5, "max_overflow": 10}
engine = create_engine(env_settings().database_url, **_engine_kwargs(env_settings().database_url))
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
def init_db() -> None:
"""Create any missing tables. The schema is additive, so this is enough."""
from app import models # noqa: F401 (register the mappers)
Base.metadata.create_all(bind=engine)
@contextmanager
def session_scope() -> Iterator[Session]:
"""A transactional session for background jobs and scripts."""
session = SessionLocal()
try:
yield session
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close()
def get_session() -> Iterator[Session]:
"""FastAPI dependency: one session per request, committed by the endpoint."""
session = SessionLocal()
try:
yield session
finally:
session.close()
+106
View File
@@ -0,0 +1,106 @@
"""The ASGI application.
On start it creates the schema, seeds the settings from the environment, starts
the background jobs and the Telegram bot, and — when a built frontend is present
in `static/` — serves the web UI from the same origin, which is what the Docker
image does.
"""
from __future__ import annotations
import logging
import os
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from app import __version__
from app.api import api_router
from app.api.deps import require_token
from app.config import env_settings
from app.db import init_db, session_scope
from app.services import scheduler, settings_service, telegram_bot
logging.basicConfig(
level=os.environ.get("LOG_LEVEL", "INFO").upper(),
format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
)
log = logging.getLogger("kadence")
STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
with session_scope() as session:
settings = settings_service.load_settings(session)
log.info("settings loaded (timezone=%s)", settings.timezone)
scheduler.start(settings)
if settings.notifications.telegram.enabled:
telegram_bot.start()
# The bot has to come up later too, if Telegram is switched on in the UI.
settings_service.on_change(
lambda new: telegram_bot.start() if new.notifications.telegram.enabled else None
)
try:
yield
finally:
scheduler.shutdown()
telegram_bot.shutdown()
app = FastAPI(
title="Kadence",
version=__version__,
summary="Pomodoro timer, todo aggregation, blocking policy and daily reports.",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=env_settings().cors_origin_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router, dependencies=[Depends(require_token)])
@app.get("/api/health", tags=["meta"])
def health() -> dict:
return {"status": "ok", "version": __version__}
@app.get("/api/info", tags=["meta"])
def info() -> dict:
"""What a client needs to know before it has a token: who we are, what is on."""
env = env_settings()
return {
"version": __version__,
"auth_required": bool(env.api_token),
"scheduler": env.scheduler_enabled,
"jobs": scheduler.describe(),
}
# ---- the web UI, when it has been built into ./static ----------------------
if STATIC_DIR.is_dir():
app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets")
@app.get("/{path:path}", include_in_schema=False)
def spa(path: str):
"""Serve the single-page app, letting the client router own the URL."""
candidate = STATIC_DIR / path
if path and candidate.is_file():
return FileResponse(candidate)
index = STATIC_DIR / "index.html"
if index.is_file():
return FileResponse(index)
return JSONResponse({"detail": "Not found"}, status_code=404)
+183
View File
@@ -0,0 +1,183 @@
"""Database tables.
The shape follows the three things the app records: **settings** (one JSON
document), **what you did** (pomodoro sessions, task completions) and **what you
were tempted by** (block events). Reports are derived from those and cached per
day so a re-send shows the same numbers.
"""
from __future__ import annotations
from datetime import date, datetime, timezone
from typing import Any
from sqlalchemy import (
JSON,
Boolean,
Date,
DateTime,
Float,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base
def utcnow() -> datetime:
return datetime.now(timezone.utc)
class SettingsRow(Base):
"""The single settings document (`id = 1`), validated by `AppSettings`."""
__tablename__ = "settings"
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
data: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, onupdate=utcnow
)
class PomodoroSession(Base):
"""One interval of the timer — work or break — as it actually happened.
`planned_seconds` is what the settings asked for, `actual_seconds` is what
the clock recorded, and they differ whenever you finish early or abandon.
"""
__tablename__ = "pomodoro_sessions"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
kind: Mapped[str] = mapped_column(String(16), index=True) # work|short_break|long_break
status: Mapped[str] = mapped_column(String(16), index=True, default="running")
# running | paused | completed | abandoned
started_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, index=True
)
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
local_date: Mapped[date] = mapped_column(Date, index=True)
"""The day this interval belongs to in the user's timezone — reports group on it."""
planned_seconds: Mapped[int] = mapped_column(Integer, default=1500)
actual_seconds: Mapped[int] = mapped_column(Integer, default=0)
paused_seconds: Mapped[int] = mapped_column(Integer, default=0)
paused_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
interruptions: Mapped[int] = mapped_column(Integer, default=0)
cycle_index: Mapped[int] = mapped_column(Integer, default=1)
"""Position in the current long-break cycle (1..cycles_before_long_break)."""
task_source: Mapped[str | None] = mapped_column(String(32))
task_external_id: Mapped[str | None] = mapped_column(String(128))
task_title: Mapped[str | None] = mapped_column(Text)
note: Mapped[str | None] = mapped_column(Text)
abandon_reason: Mapped[str | None] = mapped_column(Text)
block_events: Mapped[list["BlockEvent"]] = relationship(back_populates="session")
@property
def is_work(self) -> bool:
return self.kind == "work"
class TaskRecord(Base):
"""A todo item, wherever it came from.
External tasks are cached here on every refresh so the UI is instant and the
daily report still works when Jira is down. Locally created tasks live here
too, with `source = "local"` — they are the only ones we own outright.
"""
__tablename__ = "tasks"
__table_args__ = (
UniqueConstraint("source", "external_id", name="uq_task_source_external"),
Index("ix_task_source_done", "source", "is_done"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
source: Mapped[str] = mapped_column(String(32), index=True)
external_id: Mapped[str] = mapped_column(String(128))
title: Mapped[str] = mapped_column(Text)
description: Mapped[str | None] = mapped_column(Text)
url: Mapped[str | None] = mapped_column(Text)
project: Mapped[str | None] = mapped_column(Text)
status: Mapped[str | None] = mapped_column(String(64))
priority: Mapped[str | None] = mapped_column(String(32))
due_date: Mapped[date | None] = mapped_column(Date, index=True)
start_date: Mapped[date | None] = mapped_column(Date)
labels: Mapped[list[str]] = mapped_column(JSON, default=list)
raw: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
is_done: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
completed_date: Mapped[date | None] = mapped_column(Date, index=True)
completed_here: Mapped[bool] = mapped_column(Boolean, default=False)
"""True when *this app* closed it — what the daily report counts."""
estimated_pomodoros: Mapped[int | None] = mapped_column(Integer)
pinned: Mapped[bool] = mapped_column(Boolean, default=False)
"""Shown at the top of the timer's task picker."""
first_seen_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow
)
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
stale: Mapped[bool] = mapped_column(Boolean, default=False)
"""Set when a refresh no longer returns the task (closed elsewhere, or filtered)."""
class BlockEvent(Base):
"""A blocked site or app you reached for anyway. Pure gold for the report."""
__tablename__ = "block_events"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
kind: Mapped[str] = mapped_column(String(16), index=True) # domain | app
target: Mapped[str] = mapped_column(String(255), index=True)
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
local_date: Mapped[date] = mapped_column(Date, index=True)
action: Mapped[str] = mapped_column(String(16), default="blocked") # blocked|killed|warned
details: Mapped[str | None] = mapped_column(Text)
session_id: Mapped[int | None] = mapped_column(ForeignKey("pomodoro_sessions.id"))
session: Mapped[PomodoroSession | None] = relationship(back_populates="block_events")
class DailyReport(Base):
"""A generated report, kept so re-sends and the web view agree."""
__tablename__ = "daily_reports"
__table_args__ = (UniqueConstraint("report_date", name="uq_report_date"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
report_date: Mapped[date] = mapped_column(Date, index=True)
payload: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
generated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
sent_channel: Mapped[str | None] = mapped_column(String(32))
score: Mapped[float] = mapped_column(Float, default=0.0)
class EventLog(Base):
"""Small append-only log used for "did we already send this?" questions.
Nudges, celebrations and briefings all need to be idempotent per day, and a
row here is cheaper than a column per message type.
"""
__tablename__ = "event_log"
__table_args__ = (Index("ix_eventlog_kind_date", "kind", "local_date"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
kind: Mapped[str] = mapped_column(String(48), index=True)
local_date: Mapped[date] = mapped_column(Date, index=True)
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
payload: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
+1
View File
@@ -0,0 +1 @@
"""Domain services: settings, timer, task aggregation, reports, notifications."""
+320
View File
@@ -0,0 +1,320 @@
"""Badges and fun facts — the part of the report you actually read.
Rules are pure functions of a day's metrics plus a little history, so they are
trivial to extend: add a `Rule` to `RULES` and it shows up in the web report and
the Telegram message at once.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import date
from typing import Any, Callable
Metrics = dict[str, Any]
Context = dict[str, Any]
def _plural(count: int, singular: str, plural: str | None = None) -> str:
""""1 pomodoro" / "6 pomodoros" — the English side; Russian gets its own text."""
return f"{count} {singular if count == 1 else (plural or singular + 's')}"
def _ru_pomodoros(count: int) -> str:
if count % 10 == 1 and count % 100 != 11:
return f"{count} помидорка"
if count % 10 in (2, 3, 4) and count % 100 not in (12, 13, 14):
return f"{count} помидорки"
return f"{count} помидорок"
@dataclass(frozen=True, slots=True)
class Achievement:
code: str
emoji: str
title_en: str
title_ru: str
detail_en: str = ""
detail_ru: str = ""
def render(self, language: str) -> dict[str, str]:
ru = language == "ru"
return {
"code": self.code,
"emoji": self.emoji,
"title": self.title_ru if ru else self.title_en,
"detail": (self.detail_ru if ru else self.detail_en),
}
@dataclass(frozen=True, slots=True)
class Rule:
code: str
emoji: str
title_en: str
title_ru: str
test: Callable[[Metrics, Context], bool]
detail: Callable[[Metrics, Context], tuple[str, str]] | None = None
def _nothing(_m: Metrics, _c: Context) -> tuple[str, str]:
return "", ""
RULES: list[Rule] = [
Rule(
"goal_met",
"🎯",
"Daily goal met",
"Дневная цель взята",
lambda m, c: m["goal_met"],
lambda m, c: (
f"{m['pomodoros']}/{m['goal']} pomodoros.",
f"{m['pomodoros']}/{m['goal']} помидорок.",
),
),
Rule(
"double_goal",
"🔥",
"Double the goal",
"Двойная норма",
lambda m, c: m["goal"] > 0 and m["pomodoros"] >= 2 * m["goal"],
),
Rule(
"early_bird",
"🌅",
"Early bird",
"Ранняя пташка",
lambda m, c: bool(m["first_start"]) and m["first_start"] < "08:00",
lambda m, c: (
f"First pomodoro at {m['first_start']}.",
f"Первая помидорка в {m['first_start']}.",
),
),
Rule(
"night_owl",
"🦉",
"Night owl",
"Ночная сова",
lambda m, c: bool(m["last_end"]) and m["last_end"] > "23:00",
),
Rule(
"deep_work",
"🧘",
"Deep work",
"Глубокая работа",
lambda m, c: m["longest_stretch_minutes"] >= 90,
lambda m, c: (
f"{m['longest_stretch_minutes']} minutes without breaking the chain.",
f"{m['longest_stretch_minutes']} минут без разрыва цепочки.",
),
),
Rule(
"flawless",
"💎",
"Flawless",
"Безупречно",
lambda m, c: m["pomodoros"] >= 4 and m["abandoned"] == 0 and m["interruptions"] == 0,
),
Rule(
"task_crusher",
"✅",
"Task crusher",
"Крушитель задач",
lambda m, c: m["tasks_completed_count"] >= 5,
lambda m, c: (
_plural(m["tasks_completed_count"], "task") + " closed.",
f"{m['tasks_completed_count']} задач закрыто.",
),
),
Rule(
"gatekeeper",
"🛡️",
"Gatekeeper",
"Страж",
lambda m, c: m["blocked_attempts"] >= 5,
lambda m, c: (
f"The blocker turned you away {m['blocked_attempts']} times. It won.",
f"Блокировщик развернул тебя {m['blocked_attempts']} раз. И выиграл.",
),
),
Rule(
"well_rested",
"☕",
"Took the breaks",
"Отдыхал как надо",
lambda m, c: m["pomodoros"] >= 4 and m["breaks_taken"] >= m["pomodoros"] - 1,
),
Rule(
"streak_3",
"📆",
"3-day streak",
"Серия 3 дня",
lambda m, c: c.get("streak", {}).get("current", 0) >= 3,
lambda m, c: (
f"{c['streak']['current']} days in a row with work logged.",
f"{c['streak']['current']} дней подряд с работой.",
),
),
Rule(
"streak_7",
"🏆",
"Week-long streak",
"Неделя подряд",
lambda m, c: c.get("streak", {}).get("current", 0) >= 7,
),
Rule(
"personal_best",
"🚀",
"Personal best",
"Личный рекорд",
lambda m, c: m["pomodoros"] > 0 and m["pomodoros"] > c.get("previous_best", 0),
lambda m, c: (
"Your best day ever: " + _plural(m["pomodoros"], "pomodoro") + ".",
f"Лучший день: {_ru_pomodoros(m['pomodoros'])}.",
),
),
Rule(
"comeback",
"🪃",
"Comeback",
"Возвращение",
lambda m, c: m["pomodoros"] >= 4 and c.get("yesterday_pomodoros", 0) == 0,
),
]
def evaluate(metrics: Metrics, context: Context, language: str = "en") -> list[dict[str, str]]:
earned: list[dict[str, str]] = []
for rule in RULES:
try:
if not rule.test(metrics, context):
continue
except Exception: # pragma: no cover - a bad rule must not sink the report
logging.getLogger(__name__).debug("rule %s failed", rule.code, exc_info=True)
continue
detail_en, detail_ru = (rule.detail or _nothing)(metrics, context)
earned.append(
Achievement(
rule.code, rule.emoji, rule.title_en, rule.title_ru, detail_en, detail_ru
).render(language)
)
return earned
# --------------------------------------------------------------------------
# Fun facts
# --------------------------------------------------------------------------
def fun_facts(metrics: Metrics, context: Context, language: str = "en") -> list[str]:
"""One-liners that make the numbers land. Only the true ones are returned."""
ru = language == "ru"
facts: list[str] = []
focus = metrics["focus_minutes"]
pomodoros = metrics["pomodoros"]
if focus:
episodes = round(focus / 22, 1)
facts.append(
f"🍅 {focus} минут фокуса — это {episodes} серий ситкома, которые ты не посмотрел."
if ru
else f"🍅 {focus} minutes of focus — that is {episodes} sitcom episodes you did not watch."
)
if metrics["blocked_attempts"]:
attempts = metrics["blocked_attempts"]
top = metrics["blocked_top"][0]["target"] if metrics["blocked_top"] else "?"
facts.append(
f"🚫 {attempts} попыток сбежать, чаще всего в {top}. Не вышло."
if ru
else f"🚫 {attempts} escape attempts, mostly to {top}. None made it through."
)
if metrics["interruptions"]:
facts.append(
f"📵 {metrics['interruptions']} прерываний. Каждое стоит ~23 минуты концентрации — по науке."
if ru
else (
"📵 " + _plural(metrics["interruptions"], "interruption")
+ ". Research prices each one at ~23 minutes of refocusing."
)
)
if metrics["peak_hour"] is not None and pomodoros >= 2:
hour = metrics["peak_hour"]
facts.append(
f"⏰ Пик продуктивности — {hour:02d}:00. Защищай это время."
if ru
else f"⏰ Your peak hour was {hour:02d}:00. Defend it."
)
if metrics["wasted_minutes"] >= 10:
facts.append(
f"💨 {metrics['wasted_minutes']} минут ушло в брошенные помидорки."
if ru
else f"💨 {metrics['wasted_minutes']} minutes went into pomodoros you dropped."
)
if pomodoros and metrics["breaks_taken"] < pomodoros - 1:
skipped = pomodoros - 1 - metrics["breaks_taken"]
facts.append(
f"⚠️ Пропущено перерывов: {skipped}. Мозг это запомнит."
if ru
else "⚠️ You skipped " + _plural(skipped, "break") + ". Your brain keeps score."
)
average = context.get("average_7d", {}).get("pomodoros", 0)
if average and pomodoros:
delta = round(100 * (pomodoros - average) / average)
if abs(delta) >= 10:
better = delta > 0
facts.append(
(f"📈 На {abs(delta)}% {'выше' if better else 'ниже'} твоего среднего за неделю.")
if ru
else (
f"📈 {abs(delta)}% {'above' if better else 'below'} your 7-day average."
)
)
lifetime_hours = context.get("lifetime", {}).get("focus_hours", 0)
if lifetime_hours >= 1:
facts.append(
f"🏛️ Всего в приложении: {lifetime_hours} часов фокуса."
if ru
else f"🏛️ All-time focus logged here: {lifetime_hours} hours."
)
return facts
def verdict(metrics: Metrics, tone: str, language: str = "en") -> str:
"""The closing line. Four tones, because motivation is a personal taste."""
ru = language == "ru"
pct = metrics["goal_pct"]
table = {
"friendly": [
(100, "Goal met. Enjoy the evening — you earned it. 🎉", "Цель взята. Отдыхай, заслужил. 🎉"),
(60, "Solid day. Tomorrow you close the gap. 🙂", "Хороший день. Завтра добьёшь остаток. 🙂"),
(1, "Something beats nothing. Start tomorrow with one pomodoro.", "Что-то лучше, чем ничего. Завтра начни с одной помидорки."),
(0, "Nothing logged today. No drama — just start the timer tomorrow.", "Сегодня пусто. Без драмы — просто запусти таймер завтра."),
],
"coach": [
(100, "Target hit. Now protect the streak. 💪", "Цель закрыта. Теперь держи серию. 💪"),
(60, "Close. One more block would have done it. Plan it in.", "Почти. Ещё один блок — и всё. Запланируй."),
(1, "You started, which is the hard part. Scale it tomorrow.", "Ты начал — это самое сложное. Завтра увеличивай."),
(0, "Zero. Pick the smallest task and run one 10-minute timer tomorrow.", "Ноль. Завтра — самая мелкая задача и таймер на 10 минут."),
],
"drill_sergeant": [
(100, "Acceptable. Do it again tomorrow. 🫡", "Принято. Завтра повторить. 🫡"),
(60, "Half-measures. The goal was the goal.", "Полумеры. Цель была целью."),
(1, "That was a warm-up, not a day of work.", "Это была разминка, а не рабочий день."),
(0, "Nothing. Zero. The timer was right there.", "Ничего. Ноль. Таймер был прямо здесь."),
],
"dry": [
(100, "Goal reached.", "Цель достигнута."),
(60, "Goal partially reached.", "Цель достигнута частично."),
(1, "Minimal activity recorded.", "Зафиксирована минимальная активность."),
(0, "No activity recorded.", "Активность не зафиксирована."),
],
}
rows = table.get(tone, table["friendly"])
for threshold, text_en, text_ru in rows:
if pct >= threshold:
return text_ru if ru else text_en
return ""
def previous_best(day_counts: dict[date, int], until: date) -> int:
return max((count for day, count in day_counts.items() if day < until), default=0)
+165
View File
@@ -0,0 +1,165 @@
"""Blocking policy.
The backend decides *whether* blocking should be on right now and *what* is on
the list; the desktop app is the thing that actually enforces it (it is the only
piece running on your machine with the privileges to rewrite `/etc/hosts` or
kill a process). Keeping the policy here means the web UI can edit the rules,
and a second machine running the desktop app obeys the same list.
"""
from __future__ import annotations
from datetime import date, datetime
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models import BlockEvent
from app.services import pomodoro
from app.settings_schema import AppSettings
from app.timeutil import ensure_utc, in_window, local_date, local_time
def expand_domains(settings: AppSettings) -> list[str]:
"""Every hostname the desktop app should point at the redirect IP.
A rule like `youtube.com` is meant as "that site", so `www.` and the common
subdomains come along; anything in `allowed_domains` is removed again, which
is how `youtube.com` blocked + `music.youtube.com` allowed works.
"""
blocker = settings.blocker
allowed = {entry.strip().lower().lstrip(".") for entry in blocker.allowed_domains if entry.strip()}
hosts: list[str] = []
for raw in blocker.blocked_domains:
domain = raw.strip().lower().lstrip(".")
if not domain or domain in allowed:
continue
for candidate in (domain, f"www.{domain}", f"m.{domain}"):
if candidate not in allowed and candidate not in hosts:
hosts.append(candidate)
return hosts
def schedule_active(settings: AppSettings, moment: datetime | None = None) -> bool:
now_time = local_time(settings.timezone, moment)
weekday = (moment or datetime.now()).weekday()
for window in settings.blocker.schedule:
if weekday in window.days and in_window(now_time, window.start, window.end):
return True
return False
def policy(session: Session, settings: AppSettings) -> dict[str, Any]:
"""What the desktop app polls: should I block, and what."""
blocker = settings.blocker
current = pomodoro.current_session(session)
active = False
reason = "disabled"
if blocker.enabled:
if blocker.mode == "always":
active, reason = True, "always on"
elif blocker.mode == "scheduled":
active = schedule_active(settings)
reason = "inside a blocking window" if active else "outside every window"
else: # during_work
if current is not None and current.kind == "work" and current.status in ("running", "paused"):
active, reason = True, "a work interval is running"
elif (
current is not None
and current.kind != "work"
and blocker.block_during_breaks
and current.status in ("running", "paused")
):
active, reason = True, "blocking stays on through breaks"
else:
reason = "no work interval is running"
return {
"active": active,
"reason": reason,
"mode": blocker.mode,
"enabled": blocker.enabled,
"strict": blocker.strict,
"redirect_ip": blocker.redirect_ip,
"domains": expand_domains(settings) if active else [],
"apps": [app.strip() for app in blocker.blocked_apps if app.strip()] if active else [],
"kill_apps": blocker.kill_blocked_apps,
"notify_on_block": blocker.notify_on_block,
"poll_seconds": blocker.app_poll_seconds,
"session_id": current.id if current else None,
"all_domains": expand_domains(settings),
"all_apps": [app.strip() for app in blocker.blocked_apps if app.strip()],
}
def record_event(
session: Session,
settings: AppSettings,
*,
kind: str,
target: str,
action: str = "blocked",
details: str | None = None,
) -> BlockEvent:
current = pomodoro.current_session(session)
event = BlockEvent(
kind=kind,
target=target.strip().lower(),
action=action,
details=details,
local_date=local_date(settings.timezone),
session_id=current.id if current else None,
)
session.add(event)
session.commit()
return event
def events_for_day(session: Session, settings: AppSettings, day: date | None = None) -> dict[str, Any]:
day = day or local_date(settings.timezone)
rows = list(session.scalars(select(BlockEvent).where(BlockEvent.local_date == day)))
by_target = dict(
session.execute(
select(BlockEvent.target, func.count(BlockEvent.id))
.where(BlockEvent.local_date == day)
.group_by(BlockEvent.target)
.order_by(func.count(BlockEvent.id).desc())
).all()
)
return {
"date": day.isoformat(),
"total": len(rows),
"by_target": [{"target": target, "count": count} for target, count in by_target.items()],
"events": [
{
"id": row.id,
"kind": row.kind,
"target": row.target,
"action": row.action,
"at": ensure_utc(row.at).isoformat(),
"details": row.details,
}
for row in sorted(rows, key=lambda row: ensure_utc(row.at), reverse=True)[:100]
],
}
DEFAULT_DOMAINS = [
"youtube.com",
"twitter.com",
"x.com",
"instagram.com",
"tiktok.com",
"reddit.com",
"facebook.com",
"vk.com",
"twitch.tv",
"netflix.com",
"9gag.com",
"pikabu.ru",
]
DEFAULT_APPS = ["steam", "discord", "telegram-desktop", "spotify"]
+169
View File
@@ -0,0 +1,169 @@
"""Outbound messages: Telegram first, Slack next, the log as a last resort.
The chunking, the HTML parse mode and the "Telegram > Slack > console" order are
jira-helper's, because they are the right answers. What is added here is quiet
hours and a record of what was sent, so the nudges cannot turn into spam.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import date
import httpx
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import EventLog
from app.settings_schema import AppSettings
from app.timeutil import in_window, local_date, local_time
log = logging.getLogger(__name__)
# Telegram rejects messages longer than 4096 characters; leave headroom.
_TELEGRAM_MAX_CHARS = 4000
_TIMEOUT = 20
class NotifyError(RuntimeError):
pass
@dataclass(slots=True)
class Delivery:
channel: str
ok: bool
detail: str = ""
def as_dict(self) -> dict[str, object]:
return {"channel": self.channel, "ok": self.ok, "detail": self.detail}
def _chunk(text: str, size: int = _TELEGRAM_MAX_CHARS) -> list[str]:
"""Split on line boundaries so a message never exceeds Telegram's limit."""
chunks: list[str] = []
current = ""
for line in text.split("\n"):
candidate = f"{current}\n{line}" if current else line
if len(candidate) > size and current:
chunks.append(current)
current = line
else:
current = candidate
if current:
chunks.append(current)
return chunks or [""]
def send_telegram(
settings: AppSettings, text: str, *, reply_markup: dict | None = None
) -> Delivery:
telegram = settings.notifications.telegram
if not (telegram.enabled and telegram.bot_token and telegram.chat_id):
return Delivery("telegram", False, "Telegram is not configured.")
url = f"https://api.telegram.org/bot{telegram.bot_token}/sendMessage"
parts = _chunk(text)
try:
with httpx.Client(timeout=_TIMEOUT) as client:
for index, part in enumerate(parts):
payload = {
"chat_id": telegram.chat_id,
"text": part,
"parse_mode": "HTML",
"disable_web_page_preview": True,
}
# Buttons belong on the last chunk, where the message ends.
if reply_markup and index == len(parts) - 1:
payload["reply_markup"] = reply_markup
resp = client.post(url, json=payload)
if not resp.is_success:
return Delivery(
"telegram", False, f"{resp.status_code}: {resp.text[:200]}"
)
except httpx.HTTPError as exc:
return Delivery("telegram", False, str(exc))
return Delivery("telegram", True, f"{len(parts)} message(s)")
def send_slack(settings: AppSettings, text: str) -> Delivery:
slack = settings.notifications.slack
if not (slack.enabled and slack.webhook_url):
return Delivery("slack", False, "Slack is not configured.")
try:
with httpx.Client(timeout=_TIMEOUT) as client:
resp = client.post(slack.webhook_url, json={"text": _strip_html(text)})
if not resp.is_success:
return Delivery("slack", False, f"{resp.status_code}: {resp.text[:200]}")
except httpx.HTTPError as exc:
return Delivery("slack", False, str(exc))
return Delivery("slack", True, "ok")
def _strip_html(text: str) -> str:
import re
return re.sub(r"<[^>]+>", "", text)
def in_quiet_hours(settings: AppSettings) -> bool:
quiet = settings.notifications.telegram.quiet_hours
if not quiet.enabled:
return False
return in_window(local_time(settings.timezone), quiet.start, quiet.end)
def notify(
settings: AppSettings,
text: str,
*,
urgent: bool = False,
reply_markup: dict | None = None,
) -> list[Delivery]:
"""Send to every configured channel. `urgent` ignores quiet hours."""
if not urgent and in_quiet_hours(settings):
return [Delivery("quiet-hours", False, "suppressed by quiet hours")]
deliveries: list[Delivery] = []
telegram = send_telegram(settings, text, reply_markup=reply_markup)
if telegram.ok or telegram.detail != "Telegram is not configured.":
deliveries.append(telegram)
slack = send_slack(settings, text)
if slack.ok or slack.detail != "Slack is not configured.":
deliveries.append(slack)
if not deliveries:
log.info("notification (no channel configured):\n%s", _strip_html(text))
deliveries.append(Delivery("log", True, "written to the server log"))
return deliveries
# --------------------------------------------------------------------------
# "Did we already send this today?"
# --------------------------------------------------------------------------
def already_sent(session: Session, kind: str, day: date) -> bool:
return (
session.scalar(
select(EventLog.id).where(EventLog.kind == kind, EventLog.local_date == day)
)
is not None
)
def count_sent(session: Session, kind: str, day: date) -> int:
return len(
list(
session.scalars(
select(EventLog.id).where(EventLog.kind == kind, EventLog.local_date == day)
)
)
)
def record_sent(session: Session, kind: str, day: date, payload: dict | None = None) -> None:
session.add(EventLog(kind=kind, local_date=day, payload=payload or {}))
session.commit()
def today(settings: AppSettings) -> date:
return local_date(settings.timezone)
+354
View File
@@ -0,0 +1,354 @@
"""The timer engine.
The server owns the clock. A browser tab or the desktop window only renders
`remaining_seconds`, computed from `started_at` — so reloading the page, closing
the laptop lid or switching from the web UI to the desktop app all leave the
same interval running, and the history is never a guess.
One interval is one row. Work and breaks are both intervals, which is what
makes "you actually rested 3 of the 7 breaks you were owed" a thing the report
can say.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime, timedelta
from typing import Any, Literal
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models import PomodoroSession, TaskRecord, utcnow
from app.settings_schema import AppSettings
from app.timeutil import day_bounds_utc, ensure_utc, local_date
Kind = Literal["work", "short_break", "long_break"]
ACTIVE_STATUSES = ("running", "paused")
class TimerError(RuntimeError):
"""An operation that does not fit the timer's current state."""
# --------------------------------------------------------------------------
# Reading state
# --------------------------------------------------------------------------
def current_session(session: Session) -> PomodoroSession | None:
return session.scalar(
select(PomodoroSession)
.where(PomodoroSession.status.in_(ACTIVE_STATUSES))
.order_by(PomodoroSession.started_at.desc())
)
def elapsed_seconds(row: PomodoroSession, now: datetime | None = None) -> int:
"""Wall-clock seconds spent inside the interval, excluding paused time."""
now = now or utcnow()
if row.status == "paused" and row.paused_at is not None:
end = ensure_utc(row.paused_at)
elif row.ended_at is not None:
end = ensure_utc(row.ended_at)
else:
end = now
started = ensure_utc(row.started_at)
return max(int((end - started).total_seconds()) - row.paused_seconds, 0)
def remaining_seconds(row: PomodoroSession, now: datetime | None = None) -> int:
return max(row.planned_seconds - elapsed_seconds(row, now), 0)
def duration_for(settings: AppSettings, kind: Kind) -> int:
minutes = {
"work": settings.pomodoro.work_minutes,
"short_break": settings.pomodoro.short_break_minutes,
"long_break": settings.pomodoro.long_break_minutes,
}[kind]
return minutes * 60
def completed_work_today(session: Session, settings: AppSettings, day: date | None = None) -> int:
day = day or local_date(settings.timezone)
return int(
session.scalar(
select(func.count(PomodoroSession.id)).where(
PomodoroSession.local_date == day,
PomodoroSession.kind == "work",
PomodoroSession.status == "completed",
)
)
or 0
)
def next_kind(session: Session, settings: AppSettings) -> Kind:
"""What the timer should run next, following the long-break cycle.
After every `cycles_before_long_break` completed work intervals today the
next break is the long one. The count resets with the day, so a long break
is never "owed" from yesterday.
"""
last = session.scalar(
select(PomodoroSession)
.where(PomodoroSession.status.in_(("completed", "abandoned")))
.order_by(PomodoroSession.started_at.desc())
)
if last is None or last.kind != "work" or last.status == "abandoned":
return "work"
done = completed_work_today(session, settings)
if done and done % settings.pomodoro.cycles_before_long_break == 0:
return "long_break"
return "short_break"
# --------------------------------------------------------------------------
# Transitions
# --------------------------------------------------------------------------
def start(
session: Session,
settings: AppSettings,
*,
kind: Kind | None = None,
minutes: int | None = None,
task_id: int | None = None,
note: str | None = None,
) -> PomodoroSession:
"""Begin an interval, abandoning whatever was still running."""
running = current_session(session)
if running is not None:
_finish(session, settings, running, status="abandoned", reason="superseded")
kind = kind or next_kind(session, settings)
planned = (minutes * 60) if minutes else duration_for(settings, kind)
task_source = task_external_id = task_title = None
if task_id is not None:
task = session.get(TaskRecord, task_id)
if task is None:
raise TimerError(f"no task {task_id}")
task_source, task_external_id, task_title = task.source, task.external_id, task.title
done_today = completed_work_today(session, settings)
row = PomodoroSession(
kind=kind,
status="running",
started_at=utcnow(),
local_date=local_date(settings.timezone),
planned_seconds=planned,
cycle_index=(done_today % settings.pomodoro.cycles_before_long_break) + 1,
task_source=task_source,
task_external_id=task_external_id,
task_title=task_title,
note=note,
)
session.add(row)
session.commit()
return row
def pause(session: Session, settings: AppSettings) -> PomodoroSession:
row = _require_active(session)
if not settings.pomodoro.allow_pause:
raise TimerError("Pausing is switched off in your settings.")
if row.status == "paused":
return row
row.status = "paused"
row.paused_at = utcnow()
if settings.pomodoro.count_interruptions and row.is_work:
row.interruptions += 1
session.commit()
return row
def resume(session: Session) -> PomodoroSession:
row = _require_active(session)
if row.status != "paused" or row.paused_at is None:
return row
row.paused_seconds += max(int((utcnow() - ensure_utc(row.paused_at)).total_seconds()), 0)
row.paused_at = None
row.status = "running"
session.commit()
return row
def complete(
session: Session, settings: AppSettings, *, auto_next: bool = True
) -> tuple[PomodoroSession, PomodoroSession | None]:
"""Finish the current interval and, if configured, roll into the next one."""
row = _require_active(session)
finished = _finish(session, settings, row, status="completed")
follow_on: PomodoroSession | None = None
if auto_next:
wants_break = finished.is_work and settings.pomodoro.auto_start_breaks
wants_work = (not finished.is_work) and settings.pomodoro.auto_start_work
if wants_break or wants_work:
follow_on = start(session, settings, kind=next_kind(session, settings))
return finished, follow_on
def abandon(
session: Session, settings: AppSettings, *, reason: str | None = None
) -> PomodoroSession:
row = _require_active(session)
if settings.pomodoro.strict_mode and row.is_work and not (reason or "").strip():
raise TimerError("Strict mode: give a reason for dropping this pomodoro.")
return _finish(session, settings, row, status="abandoned", reason=reason)
def add_interruption(session: Session) -> PomodoroSession:
""""The phone rang" — counted without stopping the clock."""
row = _require_active(session)
row.interruptions += 1
session.commit()
return row
def extend(session: Session, minutes: int) -> PomodoroSession:
"""Add time to the running interval ("just five more minutes")."""
row = _require_active(session)
row.planned_seconds = max(row.planned_seconds + minutes * 60, 60)
session.commit()
return row
def attach_task(session: Session, task_id: int | None) -> PomodoroSession:
row = _require_active(session)
if task_id is None:
row.task_source = row.task_external_id = row.task_title = None
else:
task = session.get(TaskRecord, task_id)
if task is None:
raise TimerError(f"no task {task_id}")
row.task_source, row.task_external_id, row.task_title = (
task.source,
task.external_id,
task.title,
)
session.commit()
return row
def _require_active(session: Session) -> PomodoroSession:
row = current_session(session)
if row is None:
raise TimerError("No interval is running.")
return row
def _finish(
session: Session,
settings: AppSettings,
row: PomodoroSession,
*,
status: str,
reason: str | None = None,
) -> PomodoroSession:
now = utcnow()
if row.status == "paused" and row.paused_at is not None:
row.paused_seconds += max(int((now - ensure_utc(row.paused_at)).total_seconds()), 0)
row.paused_at = None
row.actual_seconds = elapsed_seconds(row, now)
row.ended_at = now
row.status = status
if reason:
row.abandon_reason = reason
session.commit()
return row
# --------------------------------------------------------------------------
# Serialisation
# --------------------------------------------------------------------------
@dataclass(slots=True)
class TimerState:
"""What the clients render. Everything derived, nothing stored twice."""
session: dict[str, Any] | None
next_kind: str
completed_today: int
goal: int
settings_version: str
def session_dict(row: PomodoroSession | None) -> dict[str, Any] | None:
if row is None:
return None
return {
"id": row.id,
"kind": row.kind,
"status": row.status,
"started_at": ensure_utc(row.started_at).isoformat(),
"ended_at": ensure_utc(row.ended_at).isoformat() if row.ended_at else None,
"planned_seconds": row.planned_seconds,
"elapsed_seconds": elapsed_seconds(row),
"remaining_seconds": remaining_seconds(row),
"paused_seconds": row.paused_seconds,
"interruptions": row.interruptions,
"cycle_index": row.cycle_index,
"task": (
{
"source": row.task_source,
"external_id": row.task_external_id,
"title": row.task_title,
}
if row.task_title
else None
),
"note": row.note,
}
def state(session: Session, settings: AppSettings) -> dict[str, Any]:
row = current_session(session)
done = completed_work_today(session, settings)
return {
"current": session_dict(row),
"next_kind": next_kind(session, settings),
"completed_today": done,
"goal": settings.pomodoro.daily_goal_pomodoros,
"cycle_position": (done % settings.pomodoro.cycles_before_long_break) + 1,
"cycles_before_long_break": settings.pomodoro.cycles_before_long_break,
"durations": {
"work": settings.pomodoro.work_minutes,
"short_break": settings.pomodoro.short_break_minutes,
"long_break": settings.pomodoro.long_break_minutes,
},
"server_time": utcnow().isoformat(),
}
def sessions_for_day(session: Session, settings: AppSettings, day: date) -> list[PomodoroSession]:
start_utc, end_utc = day_bounds_utc(day, settings.timezone)
return list(
session.scalars(
select(PomodoroSession)
.where(PomodoroSession.started_at >= start_utc, PomodoroSession.started_at < end_utc)
.order_by(PomodoroSession.started_at.asc())
)
)
def reap_stale(session: Session, settings: AppSettings, *, max_overrun_hours: int = 6) -> int:
"""Close intervals left running by a crash or a machine that went to sleep.
Without this one forgotten tab would report a 14-hour pomodoro and poison
every average in the stats.
"""
cutoff = utcnow() - timedelta(hours=max_overrun_hours)
stale = session.scalars(
select(PomodoroSession).where(
PomodoroSession.status.in_(ACTIVE_STATUSES),
PomodoroSession.started_at < cutoff,
)
).all()
for row in stale:
row.status = "abandoned"
row.ended_at = ensure_utc(row.started_at) + timedelta(seconds=row.planned_seconds)
row.actual_seconds = row.planned_seconds
row.abandon_reason = "auto-closed: left running"
if stale:
session.commit()
return len(stale)
+441
View File
@@ -0,0 +1,441 @@
"""Reports: the daily wrap-up, the morning briefing, the weekly review.
A report is built once, stored as JSON, and then *rendered* — to HTML for
Telegram or to a structure the web UI draws cards from. Because both come from
the same payload, the message in your chat and the page in the browser can
never tell you different numbers.
"""
from __future__ import annotations
import html
from datetime import date, timedelta
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models import DailyReport, PomodoroSession, utcnow
from app.services import achievements as ach
from app.services import notify, stats, tasks_service
from app.settings_schema import AppSettings
from app.sources.registry import SOURCE_LABELS
from app.timeutil import humanize_minutes, local_date
BAR_FULL = "█"
BAR_EMPTY = "░"
def _days(count: int, ru: bool) -> str:
""""1 day" / "3 days" / "1 день" / "3 дня" — plurals, cheaply."""
if ru:
if count % 10 == 1 and count % 100 != 11:
return f"{count} день"
if count % 10 in (2, 3, 4) and count % 100 not in (12, 13, 14):
return f"{count} дня"
return f"{count} дней"
return f"{count} day" if count == 1 else f"{count} days"
def _esc(value: Any) -> str:
"""Escape borrowed text for Telegram's HTML parse mode.
Without this one task summary containing `&` makes Telegram reject the
entire message — jira-helper learned that the hard way.
"""
return html.escape(str(value), quote=False)
def progress_bar(value: int, total: int, width: int = 10) -> str:
if total <= 0:
return BAR_EMPTY * width
filled = min(width, round(width * value / total))
return BAR_FULL * filled + BAR_EMPTY * (width - filled)
# --------------------------------------------------------------------------
# Daily report
# --------------------------------------------------------------------------
def build_daily(session: Session, settings: AppSettings, day: date) -> dict[str, Any]:
"""Assemble everything the daily report shows, as a plain dict."""
metrics = stats.day_metrics(session, settings, day)
streak = stats.streak(session, settings, until=day)
average_7d = stats.averages(session, settings, 7, until=day)
lifetime = stats.lifetime(session, settings)
day_counts = dict(
session.execute(
select(PomodoroSession.local_date, func.count(PomodoroSession.id))
.where(
PomodoroSession.kind == "work",
PomodoroSession.status == "completed",
)
.group_by(PomodoroSession.local_date)
).all()
)
yesterday = day - timedelta(days=1)
context = {
"streak": streak,
"average_7d": average_7d,
"lifetime": lifetime,
"previous_best": ach.previous_best(day_counts, day),
"yesterday_pomodoros": day_counts.get(yesterday, 0),
}
report_settings = settings.notifications.report
language = report_settings.language
open_tasks = tasks_service.list_tasks(session)
buckets = tasks_service.bucket_tasks(open_tasks, day)
payload: dict[str, Any] = {
"date": day.isoformat(),
"generated_at": utcnow().isoformat(),
"timezone": settings.timezone,
"metrics": metrics,
"streak": streak,
"average_7d": average_7d,
"lifetime": lifetime,
"comparison": {
"yesterday": day_counts.get(yesterday, 0),
"previous_best": context["previous_best"],
},
"achievements": (
ach.evaluate(metrics, context, language)
if report_settings.include_achievements
else []
),
"fun_facts": (
ach.fun_facts(metrics, context, language)
if report_settings.include_funny_stats
else []
),
"verdict": ach.verdict(metrics, report_settings.tone, language),
"open_tasks": {
"overdue": len(buckets["overdue"]),
"today": len(buckets["today"]),
"upcoming": len(buckets["upcoming"]),
"someday": len(buckets["someday"]),
},
"score": _score(metrics, streak),
}
return payload
def _score(metrics: dict[str, Any], streak: dict[str, int]) -> float:
"""A single 0-100 number for the dashboard's big dial.
Goal progress is most of it; finishing tasks, resisting the blocker and
keeping a streak nudge it up, dropped pomodoros pull it down.
"""
score = min(metrics["goal_pct"], 120) * 0.6
score += min(metrics["tasks_completed_count"], 6) * 3
score += min(streak.get("current", 0), 7) * 1.5
score += min(metrics["longest_stretch_minutes"] / 30, 4) * 2
score -= metrics["abandoned"] * 3
score -= max(metrics["interruptions"] - 2, 0) * 1.5
return round(max(0.0, min(100.0, score)), 1)
def render_daily_telegram(payload: dict[str, Any], settings: AppSettings) -> str:
"""The daily report as Telegram HTML."""
report_settings = settings.notifications.report
ru = report_settings.language == "ru"
metrics = payload["metrics"]
lines: list[str] = []
header = "🍅 Отчёт за день" if ru else "🍅 Daily report"
lines.append(f"<b>{header} — {payload['date']}</b>")
lines.append("")
bar = progress_bar(metrics["pomodoros"], metrics["goal"])
goal_line = (
f"{bar} <b>{metrics['pomodoros']}/{metrics['goal']}</b> "
+ ("помидорок" if ru else "pomodoros")
)
lines.append(goal_line)
lines.append(
("⏱ Фокус: " if ru else "⏱ Focus: ")
+ f"<b>{humanize_minutes(metrics['focus_minutes'])}</b>"
+ (" · перерывы: " if ru else " · breaks: ")
+ humanize_minutes(metrics["break_minutes"])
)
detail_bits: list[str] = []
if metrics["longest_stretch_minutes"]:
detail_bits.append(
("🧠 дольше всего без разрыва: " if ru else "🧠 longest stretch: ")
+ humanize_minutes(metrics["longest_stretch_minutes"])
)
if metrics["first_start"]:
detail_bits.append(
("🕘 с " if ru else "🕘 from ")
+ f"{metrics['first_start']}"
+ (" до " if ru else " to ")
+ f"{metrics['last_end']}"
)
if metrics["abandoned"]:
detail_bits.append(
("💨 брошено: " if ru else "💨 dropped: ") + str(metrics["abandoned"])
)
if metrics["interruptions"]:
detail_bits.append(
("📵 прерываний: " if ru else "📵 interruptions: ") + str(metrics["interruptions"])
)
if detail_bits:
lines.append(" · ".join(detail_bits))
streak = payload["streak"]
if streak.get("current"):
lines.append(
(f"🔥 Серия: {_days(streak['current'], True)} (рекорд {streak['best']})")
if ru
else f"🔥 Streak: {_days(streak['current'], False)} (best {streak['best']})"
)
if report_settings.include_comparison:
avg = payload["average_7d"]["pomodoros"]
if avg:
lines.append(
(f"📊 Среднее за 7 дней: {avg} · вчера: {payload['comparison']['yesterday']}")
if ru
else (
f"📊 7-day average: {avg} · yesterday: {payload['comparison']['yesterday']}"
)
)
tasks = metrics["tasks_completed"]
if report_settings.include_task_list and tasks:
lines.append("")
lines.append(
f"<b>{'✅ Закрытые задачи' if ru else '✅ Tasks closed'} ({len(tasks)})</b>"
)
for task in tasks[: report_settings.max_tasks_listed]:
label = SOURCE_LABELS.get(task["source"], task["source"])
title = _esc(task["title"])
if task.get("url"):
lines.append(f"• <a href=\"{_esc(task['url'])}\">{title}</a> <i>({label})</i>")
else:
lines.append(f"• {title} <i>({label})</i>")
if len(tasks) > report_settings.max_tasks_listed:
rest = len(tasks) - report_settings.max_tasks_listed
lines.append(("… и ещё " if ru else "… and ") + f"{rest}" + ("" if ru else " more"))
if report_settings.include_blocked_sites and metrics["blocked_top"]:
lines.append("")
lines.append(f"<b>{'🚫 Соблазны' if ru else '🚫 Temptations blocked'}</b>")
for entry in metrics["blocked_top"][:5]:
lines.append(f"• {_esc(entry['target'])} × {entry['count']}")
if payload["achievements"]:
lines.append("")
lines.append(f"<b>{'🏅 Достижения' if ru else '🏅 Achievements'}</b>")
for item in payload["achievements"]:
detail = f" — {_esc(item['detail'])}" if item.get("detail") else ""
lines.append(f"{item['emoji']} <b>{_esc(item['title'])}</b>{detail}")
if payload["fun_facts"]:
lines.append("")
for fact in payload["fun_facts"]:
lines.append(_esc(fact))
open_tasks = payload["open_tasks"]
if open_tasks["overdue"] or open_tasks["today"]:
lines.append("")
lines.append(
(f"📋 Осталось: {open_tasks['overdue']} просрочено, {open_tasks['today']} на сегодня")
if ru
else (
f"📋 Still open: {open_tasks['overdue']} overdue, "
f"{open_tasks['today']} due today"
)
)
if payload["verdict"]:
lines.append("")
lines.append(f"<i>{_esc(payload['verdict'])}</i>")
return "\n".join(lines)
def get_or_build_daily(
session: Session, settings: AppSettings, day: date, *, rebuild: bool = False
) -> tuple[DailyReport, dict[str, Any]]:
row = session.scalar(select(DailyReport).where(DailyReport.report_date == day))
if row is not None and not rebuild:
return row, row.payload
payload = build_daily(session, settings, day)
if row is None:
row = DailyReport(report_date=day, payload=payload, score=payload["score"])
session.add(row)
else:
row.payload = payload
row.score = payload["score"]
row.generated_at = utcnow()
session.commit()
return row, payload
def send_daily(
session: Session, settings: AppSettings, day: date, *, force: bool = False
) -> dict[str, Any]:
"""Build (or reuse) the day's report and push it to the channels."""
row, payload = get_or_build_daily(session, settings, day, rebuild=True)
if row.sent_at is not None and not force:
return {"sent": False, "reason": "already sent", "payload": payload}
text = render_daily_telegram(payload, settings)
deliveries = notify.notify(settings, text, urgent=True)
ok = any(delivery.ok for delivery in deliveries)
if ok:
row.sent_at = utcnow()
row.sent_channel = ",".join(d.channel for d in deliveries if d.ok)
session.commit()
return {
"sent": ok,
"deliveries": [d.as_dict() for d in deliveries],
"payload": payload,
"text": text,
}
# --------------------------------------------------------------------------
# Morning briefing
# --------------------------------------------------------------------------
def render_morning_briefing(session: Session, settings: AppSettings) -> str:
""""Here is your day" — the todo list, the streak, and the goal to beat."""
ru = settings.notifications.report.language == "ru"
today = local_date(settings.timezone)
tasks = tasks_service.list_tasks(session)
buckets = tasks_service.bucket_tasks(tasks, today)
streak = stats.streak(session, settings, until=today - timedelta(days=1))
limit = settings.notifications.report.max_tasks_listed
lines = [f"<b>{'☀️ Доброе утро!' if ru else '☀️ Good morning!'}</b>", ""]
goal = settings.pomodoro.daily_goal_pomodoros
lines.append(
(f"🎯 Цель на сегодня: <b>{goal}</b> помидорок "
f"(~{humanize_minutes(goal * settings.pomodoro.work_minutes)} фокуса)")
if ru
else (
f"🎯 Today's goal: <b>{goal}</b> pomodoros "
f"(~{humanize_minutes(goal * settings.pomodoro.work_minutes)} of focus)"
)
)
if streak.get("current"):
lines.append(
f"🔥 {'Серия' if ru else 'Streak'}: {_days(streak['current'], ru)}"
+ (" — не разрывай" if ru else " — keep it alive")
)
def section(title: str, records: list, emoji: str) -> None:
if not records:
return
lines.append("")
lines.append(f"<b>{emoji} {title} ({len(records)})</b>")
for record in records[:limit]:
label = SOURCE_LABELS.get(record.source, record.source)
title_text = _esc(record.title)
due = ""
if record.due_date and record.due_date < today:
days = (today - record.due_date).days
due = (
f" — <b>{days} дн. просрочки</b>" if ru else f" — <b>{days}d overdue</b>"
)
if record.url:
lines.append(
f"• <a href=\"{_esc(record.url)}\">{title_text}</a> <i>({label})</i>{due}"
)
else:
lines.append(f"• {title_text} <i>({label})</i>{due}")
if len(records) > limit:
lines.append(("… и ещё " if ru else "… and ") + f"{len(records) - limit}")
section("Просрочено" if ru else "Overdue", buckets["overdue"], "🔴")
section("На сегодня" if ru else "Due today", buckets["today"], "🟡")
section("Скоро" if ru else "Coming up", buckets["upcoming"][:5], "🔵")
laundry = [task for task in tasks if task.source == "laundry"]
if laundry:
lines.append("")
lines.append(f"<b>{'🧺 Стирка' if ru else '🧺 Laundry'}</b>")
for slot in laundry:
lines.append(f"• {_esc(slot.title)}")
if not any(buckets.values()):
lines.append("")
lines.append(
"✨ Входящих нет. Отличный день, чтобы поработать над чем-то важным."
if ru
else "✨ Nothing queued. A good day to work on something that matters."
)
lines.append("")
lines.append(
"<i>/start_work — запустить помидорку, /today — список, /report — отчёт</i>"
if ru
else "<i>/start_work to begin, /today for the list, /report for the numbers</i>"
)
return "\n".join(lines)
# --------------------------------------------------------------------------
# Weekly review
# --------------------------------------------------------------------------
def build_weekly(session: Session, settings: AppSettings, end_day: date) -> dict[str, Any]:
start_day = end_day - timedelta(days=6)
days = stats.range_metrics(session, settings, start_day, end_day)
total = sum(day["pomodoros"] for day in days)
focus = sum(day["focus_minutes"] for day in days)
best = max(days, key=lambda day: day["pomodoros"]) if days else None
goal_days = sum(1 for day in days if day["goal_met"])
return {
"start": start_day.isoformat(),
"end": end_day.isoformat(),
"days": days,
"pomodoros": total,
"focus_minutes": focus,
"best_day": best,
"goal_days": goal_days,
"average": round(total / len(days), 1) if days else 0,
"streak": stats.streak(session, settings, until=end_day),
"lifetime": stats.lifetime(session, settings),
}
def render_weekly_telegram(payload: dict[str, Any], settings: AppSettings) -> str:
ru = settings.notifications.report.language == "ru"
lines = [
f"<b>{'📅 Итоги недели' if ru else '📅 Weekly review'} "
f"({payload['start']} → {payload['end']})</b>",
"",
("🍅 Помидорок: " if ru else "🍅 Pomodoros: ")
+ f"<b>{payload['pomodoros']}</b>"
+ (" · в среднем " if ru else " · average ")
+ f"{payload['average']}/"
+ ("день" if ru else "day"),
("⏱ Фокус: " if ru else "⏱ Focus: ") + humanize_minutes(payload["focus_minutes"]),
("🎯 Дней с выполненной целью: " if ru else "🎯 Days the goal was met: ")
+ f"{payload['goal_days']}/7",
]
if payload["best_day"] and payload["best_day"]["pomodoros"]:
best = payload["best_day"]
lines.append(
("🏆 Лучший день: " if ru else "🏆 Best day: ")
+ f"{best['date']} ({best['pomodoros']} 🍅)"
)
lines.append("")
lines.append(f"<b>{'По дням' if ru else 'Day by day'}</b>")
for day in payload["days"]:
bar = progress_bar(day["pomodoros"], settings.pomodoro.daily_goal_pomodoros, width=8)
lines.append(f"<code>{day['date'][5:]} {bar}</code> {day['pomodoros']}")
streak = payload["streak"]
if streak.get("current"):
lines.append("")
lines.append(
f"🔥 {'Серия' if ru else 'Streak'}: {_days(streak['current'], ru)}"
+ f" · {'рекорд' if ru else 'best'}: {streak['best']}"
)
return "\n".join(lines)
+318
View File
@@ -0,0 +1,318 @@
"""Background jobs: the part that makes this an instrument instead of a timer.
Five jobs, all re-planned whenever the settings change:
* refresh every todo source on an interval;
* close intervals a crashed client left running;
* the morning briefing, the daily report and the weekly review, at your times;
* the nudge — "you have not started anything in 90 minutes" — which is the one
that actually changes behaviour.
"""
from __future__ import annotations
import logging
from datetime import timedelta
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.interval import IntervalTrigger
from sqlalchemy import select
from app.config import env_settings
from app.db import session_scope
from app.models import PomodoroSession, utcnow
from app.services import notify, pomodoro, report, settings_service, stats, tasks_service
from app.settings_schema import AppSettings
from app.timeutil import ensure_utc, in_window, local_date, local_time, to_local
log = logging.getLogger(__name__)
_scheduler: BackgroundScheduler | None = None
JOB_REFRESH = "refresh_tasks"
JOB_REAP = "reap_sessions"
JOB_MORNING = "morning_briefing"
JOB_DAILY = "daily_report"
JOB_WEEKLY = "weekly_report"
JOB_NUDGE = "nudge"
# --------------------------------------------------------------------------
# Jobs
# --------------------------------------------------------------------------
def job_refresh_tasks() -> None:
with session_scope() as session:
settings = settings_service.load_settings(session)
try:
result = tasks_service.refresh_tasks(session, settings)
failed = [item for item in result["sources"] if not item["ok"]]
if failed:
log.warning("task refresh: %s", "; ".join(f"{i['label']}: {i['error']}" for i in failed))
except Exception: # pragma: no cover
log.exception("task refresh failed")
def job_reap_sessions() -> None:
with session_scope() as session:
settings = settings_service.load_settings(session)
closed = pomodoro.reap_stale(session, settings)
if closed:
log.info("auto-closed %s abandoned interval(s)", closed)
def job_morning_briefing() -> None:
with session_scope() as session:
settings = settings_service.load_settings(session)
telegram = settings.notifications.telegram
if not (telegram.enabled and telegram.morning_briefing):
return
today = local_date(settings.timezone)
if notify.already_sent(session, JOB_MORNING, today):
return
text = report.render_morning_briefing(session, settings)
deliveries = notify.notify(settings, text, urgent=True)
if any(delivery.ok for delivery in deliveries):
notify.record_sent(session, JOB_MORNING, today)
def job_daily_report() -> None:
with session_scope() as session:
settings = settings_service.load_settings(session)
telegram = settings.notifications.telegram
if not (telegram.enabled and telegram.daily_report):
return
today = local_date(settings.timezone)
report.send_daily(session, settings, today)
def job_weekly_report() -> None:
with session_scope() as session:
settings = settings_service.load_settings(session)
telegram = settings.notifications.telegram
if not (telegram.enabled and telegram.weekly_report):
return
today = local_date(settings.timezone)
if today.weekday() != telegram.weekly_report_weekday:
return
if notify.already_sent(session, JOB_WEEKLY, today):
return
payload = report.build_weekly(session, settings, today)
text = report.render_weekly_telegram(payload, settings)
deliveries = notify.notify(settings, text, urgent=True)
if any(delivery.ok for delivery in deliveries):
notify.record_sent(session, JOB_WEEKLY, today)
def job_nudge() -> None:
"""Poke the user when the day is passing without any work on it."""
with session_scope() as session:
settings = settings_service.load_settings(session)
telegram = settings.notifications.telegram
if not (telegram.enabled and telegram.nudge_enabled):
return
now_local = local_time(settings.timezone)
if not in_window(now_local, telegram.nudge_window_start, telegram.nudge_window_end):
return
if notify.in_quiet_hours(settings):
return
today = local_date(settings.timezone)
if notify.count_sent(session, JOB_NUDGE, today) >= telegram.nudge_max_per_day:
return
if pomodoro.current_session(session) is not None:
return # already working — the last thing they need is a message
idle_for = _idle_minutes(session, settings)
if idle_for is None or idle_for < telegram.nudge_after_idle_minutes:
return
text = _nudge_text(session, settings, idle_for)
deliveries = notify.notify(settings, text)
if any(delivery.ok for delivery in deliveries):
notify.record_sent(session, JOB_NUDGE, today, {"idle_minutes": idle_for})
def _idle_minutes(session, settings: AppSettings) -> int | None:
"""Minutes since the last work interval ended, or since the nudge window opened."""
today = local_date(settings.timezone)
last = session.scalar(
select(PomodoroSession)
.where(
PomodoroSession.kind == "work",
PomodoroSession.local_date == today,
PomodoroSession.status.in_(("completed", "abandoned")),
)
.order_by(PomodoroSession.started_at.desc())
)
now = utcnow()
if last is not None and last.ended_at is not None:
return int((now - ensure_utc(last.ended_at)).total_seconds() // 60)
window_start = settings.notifications.telegram.nudge_window_start
local_now = to_local(now, settings.timezone)
opened = local_now.replace(
hour=window_start.hour, minute=window_start.minute, second=0, microsecond=0
)
if local_now < opened:
return None
return int((local_now - opened).total_seconds() // 60)
def _nudge_text(session, settings: AppSettings, idle_minutes: int) -> str:
ru = settings.notifications.report.language == "ru"
tone = settings.notifications.report.tone
today = local_date(settings.timezone)
done = pomodoro.completed_work_today(session, settings)
goal = settings.pomodoro.daily_goal_pomodoros
streak = stats.streak(session, settings, until=today)
tasks = tasks_service.list_tasks(session)
buckets = tasks_service.bucket_tasks(tasks, today)
candidate = (buckets["overdue"] or buckets["today"] or buckets["upcoming"] or tasks)
suggestion = candidate[0] if candidate else None
openers = {
"friendly": (
f"👋 Прошло {idle_minutes} минут без работы. Запустим одну помидорку?",
f"👋 {idle_minutes} minutes without a pomodoro. Shall we start one?",
),
"coach": (
f"⏳ {idle_minutes} минут простоя. {done}/{goal} сделано — ещё один блок, и догоняешь план.",
f"⏳ {idle_minutes} idle minutes. You are at {done}/{goal} — one block puts you back on plan.",
),
"drill_sergeant": (
f"🫡 {idle_minutes} минут безделья. {done}/{goal}. Таймер. Сейчас.",
f"🫡 {idle_minutes} minutes of nothing. {done}/{goal}. Timer. Now.",
),
"dry": (
f"Простой: {idle_minutes} мин. Выполнено {done} из {goal}.",
f"Idle for {idle_minutes} min. {done} of {goal} pomodoros done.",
),
}
opener_ru, opener_en = openers.get(tone, openers["friendly"])
lines = [opener_ru if ru else opener_en]
if suggestion is not None:
label = suggestion.title
lines.append("")
lines.append(
(f"Предлагаю: <b>{report._esc(label)}</b>")
if ru
else f"Suggested next: <b>{report._esc(label)}</b>"
)
if streak.get("current"):
lines.append(
(f"🔥 Серия {streak['current']} дн. на кону.")
if ru
else f"🔥 A {streak['current']}-day streak is on the line."
)
lines.append("")
lines.append(
"<i>/start_work — запустить таймер</i>" if ru else "<i>/start_work to begin</i>"
)
return "\n".join(lines)
# --------------------------------------------------------------------------
# Wiring
# --------------------------------------------------------------------------
def _plan(scheduler: BackgroundScheduler, settings: AppSettings) -> None:
tz_name = settings.timezone
telegram = settings.notifications.telegram
scheduler.add_job(
job_refresh_tasks,
IntervalTrigger(minutes=settings.sources.refresh_interval_minutes),
id=JOB_REFRESH,
replace_existing=True,
next_run_time=utcnow() + timedelta(seconds=20),
max_instances=1,
coalesce=True,
)
scheduler.add_job(
job_reap_sessions,
IntervalTrigger(minutes=15),
id=JOB_REAP,
replace_existing=True,
max_instances=1,
coalesce=True,
)
scheduler.add_job(
job_morning_briefing,
CronTrigger(
hour=telegram.morning_briefing_time.hour,
minute=telegram.morning_briefing_time.minute,
timezone=tz_name,
),
id=JOB_MORNING,
replace_existing=True,
)
scheduler.add_job(
job_daily_report,
CronTrigger(
hour=telegram.daily_report_time.hour,
minute=telegram.daily_report_time.minute,
timezone=tz_name,
),
id=JOB_DAILY,
replace_existing=True,
)
scheduler.add_job(
job_weekly_report,
CronTrigger(
day_of_week=telegram.weekly_report_weekday,
hour=telegram.daily_report_time.hour,
minute=min(telegram.daily_report_time.minute + 5, 59),
timezone=tz_name,
),
id=JOB_WEEKLY,
replace_existing=True,
)
scheduler.add_job(
job_nudge,
IntervalTrigger(minutes=10),
id=JOB_NUDGE,
replace_existing=True,
max_instances=1,
coalesce=True,
)
def start(settings: AppSettings) -> BackgroundScheduler | None:
"""Start the scheduler and re-plan it on every settings change."""
global _scheduler
if not env_settings().scheduler_enabled:
log.info("scheduler disabled by configuration")
return None
if _scheduler is not None:
return _scheduler
scheduler = BackgroundScheduler(timezone=settings.timezone)
_plan(scheduler, settings)
scheduler.start()
_scheduler = scheduler
settings_service.on_change(lambda new_settings: _plan(scheduler, new_settings))
log.info("scheduler started with %s jobs", len(scheduler.get_jobs()))
return scheduler
def shutdown() -> None:
global _scheduler
if _scheduler is not None:
_scheduler.shutdown(wait=False)
_scheduler = None
def describe() -> list[dict[str, str]]:
if _scheduler is None:
return []
return [
{
"id": job.id,
"next_run": job.next_run_time.isoformat() if job.next_run_time else "",
"trigger": str(job.trigger),
}
for job in _scheduler.get_jobs()
]
+184
View File
@@ -0,0 +1,184 @@
"""Reading and writing the settings document.
Three jobs:
* seed the document from environment variables the first time the app boots, so
a filled-in `.env` (the same variables jira-helper uses) is a working setup;
* hand the API a copy with secrets masked, and merge an edited copy back without
the UI ever having to know a real token;
* notify the rest of the process when something changed, so the scheduler can
re-plan its jobs.
"""
from __future__ import annotations
import threading
from typing import Any, Callable
from sqlalchemy.orm import Session
from app.config import env_settings
from app.models import SettingsRow
from app.settings_schema import SECRET_FIELDS, SECRET_MASK, AppSettings
_SETTINGS_ID = 1
_listeners: list[Callable[[AppSettings], None]] = []
_lock = threading.Lock()
def on_change(listener: Callable[[AppSettings], None]) -> None:
"""Register a callback fired after every successful settings save."""
_listeners.append(listener)
def _csv(value: str) -> list[str]:
return [part.strip() for part in value.split(",") if part.strip()]
def settings_from_env() -> AppSettings:
"""Build the initial document out of the environment.
The variable names are deliberately the ones from `~/code/jira-helper`, so
an existing `.env` can be copied across untouched.
"""
env = env_settings()
settings = AppSettings(timezone=env.timezone)
jira = settings.sources.jira
if env.jira_base_url and env.jira_email and env.jira_api_token:
jira.enabled = True
jira.base_url = env.jira_base_url.rstrip("/")
jira.email = env.jira_email
jira.api_token = env.jira_api_token
jira.extra_jql = env.jira_extra_jql
jira.start_date_field = env.jira_start_date_field or jira.start_date_field
google = settings.sources.google_tasks
if env.google_client_id and env.google_client_secret and env.google_refresh_token:
google.enabled = True
google.client_id = env.google_client_id
google.client_secret = env.google_client_secret
google.refresh_token = env.google_refresh_token
google.lists = _csv(env.google_tasks_lists)
todoist = settings.sources.todoist
if env.todoist_api_token:
todoist.enabled = True
todoist.api_token = env.todoist_api_token
todoist.projects = _csv(env.todoist_projects)
laundry = settings.sources.laundry
if env.laundry_session:
laundry.enabled = True
laundry.session_cookie = env.laundry_session
laundry.base_url = env.laundry_base_url.rstrip("/")
telegram = settings.notifications.telegram
if env.telegram_bot_token and env.telegram_chat_id:
telegram.enabled = True
telegram.bot_token = env.telegram_bot_token
telegram.chat_id = env.telegram_chat_id
if env.slack_webhook_url:
settings.notifications.slack.enabled = True
settings.notifications.slack.webhook_url = env.slack_webhook_url
return settings
def load_settings(session: Session) -> AppSettings:
"""The current settings, creating the row from the environment if absent."""
row = session.get(SettingsRow, _SETTINGS_ID)
if row is None:
settings = settings_from_env()
row = SettingsRow(id=_SETTINGS_ID, data=settings.model_dump(mode="json"))
session.add(row)
session.commit()
return settings
try:
return AppSettings.model_validate(row.data)
except Exception:
# A settings document written by an older build can be missing keys or
# carry ones that no longer exist; defaults fill the gaps rather than
# taking the whole app down.
return AppSettings.model_validate(_merge(AppSettings().model_dump(mode="json"), row.data))
def save_settings(session: Session, settings: AppSettings) -> AppSettings:
row = session.get(SettingsRow, _SETTINGS_ID)
data = settings.model_dump(mode="json")
if row is None:
row = SettingsRow(id=_SETTINGS_ID, data=data)
session.add(row)
else:
row.data = data
session.commit()
with _lock:
listeners = list(_listeners)
for listener in listeners:
try:
listener(settings)
except Exception: # pragma: no cover - a bad listener must not block saving
import logging
logging.getLogger(__name__).exception("settings listener failed")
return settings
# --------------------------------------------------------------------------
# Secret masking
# --------------------------------------------------------------------------
def _walk(node: Any, fn: Callable[[str, Any], Any]) -> Any:
if isinstance(node, dict):
return {key: fn(key, _walk(value, fn)) for key, value in node.items()}
if isinstance(node, list):
return [_walk(item, fn) for item in node]
return node
def mask_secrets(data: dict[str, Any]) -> dict[str, Any]:
"""Replace every configured secret that has a value with the mask."""
def mask(key: str, value: Any) -> Any:
if key in SECRET_FIELDS and isinstance(value, str) and value:
return SECRET_MASK
return value
return _walk(data, mask)
def _merge(base: Any, incoming: Any) -> Any:
"""Deep-merge `incoming` onto `base`, keeping `base` where a key is missing."""
if isinstance(base, dict) and isinstance(incoming, dict):
merged = dict(base)
for key, value in incoming.items():
merged[key] = _merge(base.get(key), value) if key in base else value
return merged
return incoming
def unmask_secrets(stored: dict[str, Any], incoming: dict[str, Any]) -> dict[str, Any]:
"""Put the stored secret back wherever the client sent the mask untouched."""
def restore(node_stored: Any, node_in: Any) -> Any:
if isinstance(node_in, dict):
out: dict[str, Any] = {}
for key, value in node_in.items():
old = node_stored.get(key) if isinstance(node_stored, dict) else None
if key in SECRET_FIELDS and value == SECRET_MASK:
out[key] = old or ""
else:
out[key] = restore(old, value)
return out
return node_in
return restore(stored, incoming)
def apply_patch(session: Session, patch: dict[str, Any]) -> AppSettings:
"""Merge a partial settings document (as the UI sends it) and store it."""
current = load_settings(session).model_dump(mode="json")
patch = unmask_secrets(current, patch)
merged = _merge(current, patch)
return save_settings(session, AppSettings.model_validate(merged))
+284
View File
@@ -0,0 +1,284 @@
"""Turning rows into numbers: a day, a range, streaks, and a heatmap.
Everything the report and the dashboard show comes from here, so the web UI and
the Telegram message can never disagree about how many pomodoros you did.
"""
from __future__ import annotations
from collections import Counter
from datetime import date, datetime, timedelta
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models import BlockEvent, PomodoroSession, TaskRecord
from app.settings_schema import AppSettings
from app.timeutil import local_date, to_local
# Two work intervals closer together than this count as one unbroken stretch.
_STREAK_GAP_SECONDS = 20 * 60
def _focus_blocks(rows: list[PomodoroSession], tz_name: str) -> list[tuple[datetime, datetime]]:
blocks: list[tuple[datetime, datetime]] = []
for row in rows:
if row.kind != "work" or row.status != "completed" or row.ended_at is None:
continue
blocks.append((to_local(row.started_at, tz_name), to_local(row.ended_at, tz_name)))
return sorted(blocks)
def longest_focus_stretch_minutes(rows: list[PomodoroSession], tz_name: str) -> int:
"""The longest run of back-to-back pomodoros, in minutes of actual focus."""
blocks = _focus_blocks(rows, tz_name)
if not blocks:
return 0
best = current = (blocks[0][1] - blocks[0][0]).total_seconds()
previous_end = blocks[0][1]
for start, end in blocks[1:]:
gap = (start - previous_end).total_seconds()
length = (end - start).total_seconds()
current = current + length if gap <= _STREAK_GAP_SECONDS else length
best = max(best, current)
previous_end = end
return int(best // 60)
def day_metrics(session: Session, settings: AppSettings, day: date) -> dict[str, Any]:
"""Everything measurable about one day."""
tz_name = settings.timezone
rows = list(
session.scalars(
select(PomodoroSession)
.where(PomodoroSession.local_date == day)
.order_by(PomodoroSession.started_at.asc())
)
)
work_done = [r for r in rows if r.kind == "work" and r.status == "completed"]
work_dropped = [r for r in rows if r.kind == "work" and r.status == "abandoned"]
breaks_done = [r for r in rows if r.kind != "work" and r.status == "completed"]
focus_seconds = sum(r.actual_seconds for r in work_done)
break_seconds = sum(r.actual_seconds for r in breaks_done)
dropped_seconds = sum(r.actual_seconds for r in work_dropped)
by_hour = Counter(to_local(r.started_at, tz_name).hour for r in work_done)
tasks = list(
session.scalars(
select(TaskRecord).where(
TaskRecord.completed_date == day, TaskRecord.completed_here.is_(True)
)
)
)
blocks = list(
session.scalars(select(BlockEvent).where(BlockEvent.local_date == day))
)
block_counter = Counter(event.target for event in blocks)
pomodoro_time_by_task: Counter[str] = Counter()
for row in work_done:
label = row.task_title or "Unassigned"
pomodoro_time_by_task[label] += row.actual_seconds
first = to_local(work_done[0].started_at, tz_name) if work_done else None
last_row = work_done[-1] if work_done else None
last = to_local(last_row.ended_at or last_row.started_at, tz_name) if last_row else None
goal = settings.pomodoro.daily_goal_pomodoros
completed = len(work_done)
planned_seconds = sum(r.planned_seconds for r in work_done)
return {
"date": day.isoformat(),
"pomodoros": completed,
"goal": goal,
"goal_pct": round(100 * completed / goal) if goal else 0,
"goal_met": completed >= goal,
"focus_minutes": round(focus_seconds / 60),
"break_minutes": round(break_seconds / 60),
"wasted_minutes": round(dropped_seconds / 60),
"abandoned": len(work_dropped),
"abandon_reasons": [r.abandon_reason for r in work_dropped if r.abandon_reason],
"breaks_taken": len(breaks_done),
"breaks_owed": completed,
"interruptions": sum(r.interruptions for r in rows),
"completion_rate": (
round(100 * focus_seconds / planned_seconds) if planned_seconds else 0
),
"longest_stretch_minutes": longest_focus_stretch_minutes(rows, tz_name),
"first_start": first.strftime("%H:%M") if first else None,
"last_end": last.strftime("%H:%M") if last else None,
"by_hour": [{"hour": hour, "count": by_hour.get(hour, 0)} for hour in range(24)],
"peak_hour": (by_hour.most_common(1)[0][0] if by_hour else None),
"tasks_completed": [
{
"id": task.id,
"title": task.title,
"source": task.source,
"project": task.project,
"url": task.url,
}
for task in tasks
],
"tasks_completed_count": len(tasks),
"time_by_task": [
{"title": title, "minutes": round(seconds / 60)}
for title, seconds in pomodoro_time_by_task.most_common(10)
],
"blocked_attempts": sum(block_counter.values()),
"blocked_top": [
{"target": target, "count": count} for target, count in block_counter.most_common(8)
],
"sessions": [
{
"id": row.id,
"kind": row.kind,
"status": row.status,
"started_at": to_local(row.started_at, tz_name).isoformat(),
"minutes": round(row.actual_seconds / 60),
"task": row.task_title,
"interruptions": row.interruptions,
}
for row in rows
],
}
def range_metrics(
session: Session, settings: AppSettings, start: date, end: date
) -> list[dict[str, Any]]:
"""Per-day metrics for `[start, end]`, inclusive, cheaply (one query)."""
rows = list(
session.scalars(
select(PomodoroSession).where(
PomodoroSession.local_date >= start,
PomodoroSession.local_date <= end,
PomodoroSession.kind == "work",
PomodoroSession.status == "completed",
)
)
)
per_day: dict[date, list[PomodoroSession]] = {}
for row in rows:
per_day.setdefault(row.local_date, []).append(row)
out: list[dict[str, Any]] = []
cursor = start
while cursor <= end:
day_rows = per_day.get(cursor, [])
out.append(
{
"date": cursor.isoformat(),
"pomodoros": len(day_rows),
"focus_minutes": round(sum(r.actual_seconds for r in day_rows) / 60),
"goal_met": len(day_rows) >= settings.pomodoro.daily_goal_pomodoros,
}
)
cursor += timedelta(days=1)
return out
def averages(session: Session, settings: AppSettings, days: int, *, until: date) -> dict[str, float]:
"""Mean pomodoros/focus over the `days` days ending the day before `until`."""
start = until - timedelta(days=days)
end = until - timedelta(days=1)
if end < start:
return {"pomodoros": 0.0, "focus_minutes": 0.0}
rows = range_metrics(session, settings, start, end)
if not rows:
return {"pomodoros": 0.0, "focus_minutes": 0.0}
return {
"pomodoros": round(sum(r["pomodoros"] for r in rows) / len(rows), 1),
"focus_minutes": round(sum(r["focus_minutes"] for r in rows) / len(rows), 1),
}
def streak(session: Session, settings: AppSettings, *, until: date | None = None) -> dict[str, int]:
"""Consecutive days ending at `until` with at least one pomodoro, and with the goal met.
Today only breaks a streak once it is over, so a day with nothing on it yet
keeps yesterday's streak alive.
"""
until = until or local_date(settings.timezone)
days = list(
session.scalars(
select(PomodoroSession.local_date)
.where(
PomodoroSession.kind == "work",
PomodoroSession.status == "completed",
PomodoroSession.local_date <= until,
)
.group_by(PomodoroSession.local_date)
)
)
counts = dict(
session.execute(
select(PomodoroSession.local_date, func.count(PomodoroSession.id))
.where(
PomodoroSession.kind == "work",
PomodoroSession.status == "completed",
PomodoroSession.local_date <= until,
)
.group_by(PomodoroSession.local_date)
).all()
)
active = set(days)
def run(from_day: date, predicate) -> int:
length, cursor = 0, from_day
while predicate(cursor):
length += 1
cursor -= timedelta(days=1)
return length
goal = settings.pomodoro.daily_goal_pomodoros
anchor = until if until in active else until - timedelta(days=1)
goal_anchor = (
until if counts.get(until, 0) >= goal else until - timedelta(days=1)
)
return {
"current": run(anchor, lambda day: day in active),
"goal_streak": run(goal_anchor, lambda day: counts.get(day, 0) >= goal),
"best": _best_streak(sorted(active)),
"total_days": len(active),
}
def _best_streak(days: list[date]) -> int:
best = run = 0
previous: date | None = None
for day in days:
run = run + 1 if previous is not None and (day - previous).days == 1 else 1
best = max(best, run)
previous = day
return best
def heatmap(session: Session, settings: AppSettings, weeks: int = 12) -> list[dict[str, Any]]:
"""Per-day counts for the last `weeks` weeks, for the dashboard's calendar."""
end = local_date(settings.timezone)
start = end - timedelta(weeks=weeks)
return range_metrics(session, settings, start, end)
def lifetime(session: Session, settings: AppSettings) -> dict[str, Any]:
total, seconds = session.execute(
select(func.count(PomodoroSession.id), func.coalesce(func.sum(PomodoroSession.actual_seconds), 0)).where(
PomodoroSession.kind == "work", PomodoroSession.status == "completed"
)
).one()
tasks = session.scalar(
select(func.count(TaskRecord.id)).where(TaskRecord.completed_here.is_(True))
)
blocked = session.scalar(select(func.count(BlockEvent.id)))
return {
"pomodoros": int(total or 0),
"focus_minutes": round(int(seconds or 0) / 60),
"focus_hours": round(int(seconds or 0) / 3600, 1),
"tasks_completed": int(tasks or 0),
"blocked_attempts": int(blocked or 0),
}
+275
View File
@@ -0,0 +1,275 @@
"""The task list: refreshing it from every source, and acting on it.
External tasks are mirrored into the `tasks` table on each refresh. The mirror
buys three things a live call cannot: the UI stays instant, the daily report
still works when a source is down, and "I finished this" is recorded locally
even if the write-back to Jira fails.
"""
from __future__ import annotations
import logging
from datetime import date
from typing import Any, Iterable
from uuid import uuid4
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import TaskRecord, utcnow
from app.settings_schema import AppSettings
from app.sources.base import SourceResult, TodoItem
from app.sources.registry import SOURCE_LABELS, complete_remote, fetch_all
from app.timeutil import local_date
log = logging.getLogger(__name__)
LOCAL_SOURCE = "local"
# --------------------------------------------------------------------------
# Refresh
# --------------------------------------------------------------------------
def _upsert(session: Session, item: TodoItem) -> TaskRecord:
record = session.scalar(
select(TaskRecord).where(
TaskRecord.source == item.source,
TaskRecord.external_id == item.external_id,
)
)
if record is None:
record = TaskRecord(source=item.source, external_id=item.external_id)
session.add(record)
record.title = item.title
record.description = item.description
record.url = item.url
record.project = item.project
record.status = item.status
record.priority = item.priority
record.due_date = item.due_date
record.start_date = item.start_date
record.labels = item.labels
record.raw = item.raw
record.last_seen_at = utcnow()
record.stale = False
if item.is_done and not record.is_done:
# Closed in the source, not here: it counts as done but not as "mine".
record.is_done = True
record.completed_at = record.completed_at or utcnow()
return record
def refresh_tasks(session: Session, settings: AppSettings) -> dict[str, Any]:
"""Pull every enabled source and reconcile the mirror.
Tasks a source stops returning are flagged `stale` rather than deleted: the
history behind "you finished 6 things today" has to survive a source that
quietly drops an item.
"""
results: list[SourceResult] = fetch_all(settings)
today = local_date(settings.timezone)
summary: list[dict[str, Any]] = []
for result in results:
if not result.ok:
summary.append(
{
"source": result.source,
"label": SOURCE_LABELS.get(result.source, result.source),
"ok": False,
"error": result.error,
"count": 0,
}
)
continue
seen_ids: set[str] = set()
for item in result.items:
_upsert(session, item)
seen_ids.add(item.external_id)
existing = session.scalars(
select(TaskRecord).where(
TaskRecord.source == result.source, TaskRecord.is_done.is_(False)
)
).all()
for record in existing:
if record.external_id not in seen_ids:
record.stale = True
summary.append(
{
"source": result.source,
"label": SOURCE_LABELS.get(result.source, result.source),
"ok": True,
"error": None,
"count": len(result.items),
}
)
session.commit()
return {
"refreshed_at": utcnow().isoformat(),
"date": today.isoformat(),
"sources": summary,
"total": sum(entry["count"] for entry in summary),
}
# --------------------------------------------------------------------------
# Queries
# --------------------------------------------------------------------------
def list_tasks(
session: Session,
*,
include_done: bool = False,
include_stale: bool = False,
sources: Iterable[str] | None = None,
) -> list[TaskRecord]:
query = select(TaskRecord)
if not include_done:
query = query.where(TaskRecord.is_done.is_(False))
if not include_stale:
query = query.where(TaskRecord.stale.is_(False))
if sources:
query = query.where(TaskRecord.source.in_(list(sources)))
records = list(session.scalars(query))
records.sort(key=_sort_key)
return records
def _sort_key(record: TaskRecord) -> tuple:
"""Pinned first, then by deadline (undated last), then by source and title."""
return (
not record.pinned,
record.due_date is None,
record.due_date or date.max,
record.source,
(record.title or "").lower(),
)
def bucket_tasks(records: Iterable[TaskRecord], today: date) -> dict[str, list[TaskRecord]]:
"""Group a task list the way the UI and the report both want it."""
buckets: dict[str, list[TaskRecord]] = {
"overdue": [],
"today": [],
"upcoming": [],
"someday": [],
}
for record in records:
if record.due_date is None:
buckets["someday"].append(record)
elif record.due_date < today:
buckets["overdue"].append(record)
elif record.due_date == today:
buckets["today"].append(record)
else:
buckets["upcoming"].append(record)
return buckets
def completed_on(session: Session, day: date, *, only_here: bool = True) -> list[TaskRecord]:
query = select(TaskRecord).where(TaskRecord.completed_date == day)
if only_here:
query = query.where(TaskRecord.completed_here.is_(True))
return list(session.scalars(query))
# --------------------------------------------------------------------------
# Mutations
# --------------------------------------------------------------------------
def create_local_task(
session: Session,
*,
title: str,
description: str | None = None,
due_date: date | None = None,
priority: str | None = None,
project: str | None = None,
estimated_pomodoros: int | None = None,
) -> TaskRecord:
"""A task that lives only here — the quick "and this too" you type at 9am."""
# A random id, not a timestamp: two tasks added in the same millisecond
# (a double-clicked Add button) would otherwise collide on the unique key.
external_id = f"local-{uuid4().hex}"
record = TaskRecord(
source=LOCAL_SOURCE,
external_id=external_id,
title=title.strip(),
description=description,
due_date=due_date,
priority=priority,
project=project or "My list",
estimated_pomodoros=estimated_pomodoros,
)
session.add(record)
session.commit()
return record
def complete_task(
session: Session, settings: AppSettings, task_id: int, *, done: bool = True
) -> tuple[TaskRecord, str | None]:
"""Mark a task done (or undone) here, and try to mirror it to its source.
Returns the record plus a warning when the write-back failed — the local
state still changes, because the alternative is losing the fact that you
finished something because Jira hiccupped.
"""
record = session.get(TaskRecord, task_id)
if record is None:
raise LookupError(f"no task {task_id}")
warning: str | None = None
if done:
record.is_done = True
record.completed_here = True
record.completed_at = utcnow()
record.completed_date = local_date(settings.timezone)
if record.source != LOCAL_SOURCE:
item = TodoItem(
source=record.source,
external_id=record.external_id,
title=record.title,
raw=record.raw or {},
)
wrote, error = complete_remote(settings, item)
if error:
warning = (
f"Marked done here, but {SOURCE_LABELS.get(record.source, record.source)}"
f" did not accept it: {error}"
)
elif not wrote:
warning = (
f"Marked done here only — write-back is off for "
f"{SOURCE_LABELS.get(record.source, record.source)}."
)
else:
record.is_done = False
record.completed_here = False
record.completed_at = None
record.completed_date = None
session.commit()
return record, warning
def update_task(session: Session, task_id: int, **changes: Any) -> TaskRecord:
record = session.get(TaskRecord, task_id)
if record is None:
raise LookupError(f"no task {task_id}")
for key, value in changes.items():
if value is not None and hasattr(record, key):
setattr(record, key, value)
session.commit()
return record
def delete_task(session: Session, task_id: int) -> None:
record = session.get(TaskRecord, task_id)
if record is None:
return
session.delete(record)
session.commit()
+367
View File
@@ -0,0 +1,367 @@
"""A long-polling Telegram bot, so the chat is a remote control, not just an inbox.
Runs in a daemon thread next to the API. Commands do exactly what the buttons in
the UI do — they call the same services — so starting a pomodoro from your phone
and finishing it on the desktop is one unbroken interval.
"""
from __future__ import annotations
import logging
import threading
import time as time_module
from typing import Any, Callable
import httpx
from app.config import env_settings
from app.db import session_scope
from app.services import (
blocker_service,
pomodoro,
report,
settings_service,
stats,
tasks_service,
)
from app.settings_schema import AppSettings
from app.timeutil import humanize_minutes, local_date
log = logging.getLogger(__name__)
_thread: threading.Thread | None = None
_stop = threading.Event()
_POLL_TIMEOUT = 25
HELP_EN = """<b>Kadence</b>
/start_work [minutes] — start a pomodoro
/break — start a break
/stop — abandon the current interval
/pause · /resume — hold the clock
/status — what is running right now
/today — your todo list, grouped
/done &lt;number&gt; — close task N from /today
/report — today's report, now
/week — the weekly review
/streak — streak and all-time totals
/block — what the blocker is doing
/help — this message"""
HELP_RU = """<b>Kadence</b>
/start_work [минуты] — запустить помидорку
/break — перерыв
/stop — бросить текущий интервал
/pause · /resume — пауза и продолжение
/status — что идёт сейчас
/today — список задач по группам
/done &lt;номер&gt; — закрыть задачу N из /today
/report — отчёт за сегодня
/week — итоги недели
/streak — серия и общая статистика
/block — что делает блокировщик
/help — это сообщение"""
def _api(token: str, method: str, payload: dict[str, Any], timeout: int = 30) -> dict[str, Any]:
url = f"https://api.telegram.org/bot{token}/{method}"
with httpx.Client(timeout=timeout) as client:
resp = client.post(url, json=payload)
if not resp.is_success:
raise RuntimeError(f"{method} -> {resp.status_code}: {resp.text[:200]}")
return resp.json() or {}
def _reply(settings: AppSettings, chat_id: str, text: str, buttons: list[list[str]] | None = None) -> None:
telegram = settings.notifications.telegram
payload: dict[str, Any] = {
"chat_id": chat_id,
"text": text,
"parse_mode": "HTML",
"disable_web_page_preview": True,
}
if buttons:
payload["reply_markup"] = {
"inline_keyboard": [
[{"text": label, "callback_data": label} for label in row] for row in buttons
]
}
try:
_api(telegram.bot_token, "sendMessage", payload)
except Exception as exc: # pragma: no cover
log.warning("telegram reply failed: %s", exc)
# --------------------------------------------------------------------------
# Command handlers
# --------------------------------------------------------------------------
def _cmd_status(session, settings: AppSettings, _args: str) -> str:
ru = settings.notifications.report.language == "ru"
state = pomodoro.state(session, settings)
current = state["current"]
done, goal = state["completed_today"], state["goal"]
header = (
f"🍅 {done}/{goal} " + ("сегодня" if ru else "today")
+ f" · {report.progress_bar(done, goal)}"
)
if current is None:
return header + "\n\n" + ("Таймер стоит." if ru else "The timer is idle.")
remaining = humanize_minutes(current["remaining_seconds"] / 60)
kind = {
"work": "работа" if ru else "work",
"short_break": "короткий перерыв" if ru else "short break",
"long_break": "длинный перерыв" if ru else "long break",
}[current["kind"]]
task = f"\n📌 {report._esc(current['task']['title'])}" if current.get("task") else ""
status = ("на паузе" if ru else "paused") if current["status"] == "paused" else ("идёт" if ru else "running")
return f"{header}\n\n▶️ <b>{kind}</b> ({status}) — {remaining} " + ("осталось" if ru else "left") + task
def _cmd_start_work(session, settings: AppSettings, args: str) -> str:
ru = settings.notifications.report.language == "ru"
minutes: int | None = None
if args.strip().isdigit():
minutes = max(1, min(240, int(args.strip())))
row = pomodoro.start(session, settings, kind="work", minutes=minutes)
length = humanize_minutes(row.planned_seconds / 60)
return (
f"▶️ Помидорка на {length} пошла. Удачи!" if ru else f"▶️ {length} pomodoro started. Go."
)
def _cmd_break(session, settings: AppSettings, _args: str) -> str:
ru = settings.notifications.report.language == "ru"
kind = pomodoro.next_kind(session, settings)
if kind == "work":
kind = "short_break"
row = pomodoro.start(session, settings, kind=kind)
return (
f"☕ Перерыв {humanize_minutes(row.planned_seconds / 60)}."
if ru
else f"☕ {humanize_minutes(row.planned_seconds / 60)} break."
)
def _cmd_stop(session, settings: AppSettings, args: str) -> str:
ru = settings.notifications.report.language == "ru"
try:
pomodoro.abandon(session, settings, reason=args.strip() or "stopped from Telegram")
except pomodoro.TimerError as exc:
return str(exc)
return "⏹ Остановлено." if ru else "⏹ Stopped."
def _cmd_pause(session, settings: AppSettings, _args: str) -> str:
ru = settings.notifications.report.language == "ru"
try:
pomodoro.pause(session, settings)
except pomodoro.TimerError as exc:
return str(exc)
return "⏸ Пауза." if ru else "⏸ Paused."
def _cmd_resume(session, settings: AppSettings, _args: str) -> str:
ru = settings.notifications.report.language == "ru"
try:
pomodoro.resume(session)
except pomodoro.TimerError as exc:
return str(exc)
return "▶️ Продолжаем." if ru else "▶️ Resumed."
def _cmd_today(session, settings: AppSettings, _args: str) -> str:
"""The todo list, numbered so `/done 3` has something to refer to."""
ru = settings.notifications.report.language == "ru"
today = local_date(settings.timezone)
tasks = tasks_service.list_tasks(session)
buckets = tasks_service.bucket_tasks(tasks, today)
ordered = buckets["overdue"] + buckets["today"] + buckets["upcoming"] + buckets["someday"]
if not ordered:
return "✨ Задач нет." if ru else "✨ Nothing on the list."
_INDEX[settings.notifications.telegram.chat_id] = [task.id for task in ordered]
limit = settings.notifications.report.max_tasks_listed
lines = [f"<b>{'📋 Задачи' if ru else '📋 Your tasks'}</b>"]
marks = {"overdue": "🔴", "today": "🟡", "upcoming": "🔵", "someday": "⚪"}
position = 0
for bucket_name in ("overdue", "today", "upcoming", "someday"):
for task in buckets[bucket_name]:
position += 1
if position > limit:
continue
due = f" ({task.due_date.isoformat()})" if task.due_date else ""
lines.append(
f"{marks[bucket_name]} <b>{position}.</b> {report._esc(task.title)}{due}"
)
if position > limit:
lines.append(("… и ещё " if ru else "… and ") + f"{position - limit}")
lines.append("")
lines.append("<i>/done N</i>")
return "\n".join(lines)
_INDEX: dict[str, list[int]] = {}
def _cmd_done(session, settings: AppSettings, args: str) -> str:
ru = settings.notifications.report.language == "ru"
chat_id = settings.notifications.telegram.chat_id
index = _INDEX.get(chat_id)
if not args.strip().isdigit():
return "Укажи номер: /done 3" if ru else "Give me a number: /done 3"
if not index:
return "Сначала /today." if ru else "Run /today first."
position = int(args.strip())
if not 1 <= position <= len(index):
return "Нет такого номера." if ru else "No task with that number."
record, warning = tasks_service.complete_task(session, settings, index[position - 1])
text = f"✅ {report._esc(record.title)}"
return text + (f"\n⚠️ {report._esc(warning)}" if warning else "")
def _cmd_report(session, settings: AppSettings, _args: str) -> str:
today = local_date(settings.timezone)
_, payload = report.get_or_build_daily(session, settings, today, rebuild=True)
return report.render_daily_telegram(payload, settings)
def _cmd_week(session, settings: AppSettings, _args: str) -> str:
today = local_date(settings.timezone)
payload = report.build_weekly(session, settings, today)
return report.render_weekly_telegram(payload, settings)
def _cmd_streak(session, settings: AppSettings, _args: str) -> str:
ru = settings.notifications.report.language == "ru"
streak = stats.streak(session, settings)
lifetime = stats.lifetime(session, settings)
return "\n".join(
[
f"🔥 <b>{'Серия' if ru else 'Streak'}</b>: {streak['current']} "
+ ("дн." if ru else "days"),
f"🏆 {'Рекорд' if ru else 'Best'}: {streak['best']} · "
f"{'дней с целью' if ru else 'goal streak'}: {streak['goal_streak']}",
"",
f"🍅 {'Всего помидорок' if ru else 'All-time pomodoros'}: {lifetime['pomodoros']}",
f"⏱ {'Всего фокуса' if ru else 'All-time focus'}: {lifetime['focus_hours']}h",
f"✅ {'Закрыто задач' if ru else 'Tasks closed'}: {lifetime['tasks_completed']}",
f"🚫 {'Заблокировано попыток' if ru else 'Blocked attempts'}: {lifetime['blocked_attempts']}",
]
)
def _cmd_block(session, settings: AppSettings, _args: str) -> str:
ru = settings.notifications.report.language == "ru"
policy = blocker_service.policy(session, settings)
events = blocker_service.events_for_day(session, settings)
state = ("включён" if ru else "ON") if policy["active"] else ("выключен" if ru else "OFF")
lines = [
f"🛡 <b>{'Блокировщик' if ru else 'Blocker'}</b>: {state} ({report._esc(policy['reason'])})",
f"{'Сайтов' if ru else 'Domains'}: {len(policy['all_domains'])} · "
f"{'приложений' if ru else 'apps'}: {len(policy['all_apps'])}",
f"{'Попыток сегодня' if ru else 'Attempts today'}: {events['total']}",
]
for entry in events["by_target"][:5]:
lines.append(f"• {report._esc(entry['target'])} × {entry['count']}")
return "\n".join(lines)
def _cmd_help(_session, settings: AppSettings, _args: str) -> str:
return HELP_RU if settings.notifications.report.language == "ru" else HELP_EN
COMMANDS: dict[str, Callable] = {
"start": _cmd_help,
"help": _cmd_help,
"status": _cmd_status,
"start_work": _cmd_start_work,
"work": _cmd_start_work,
"break": _cmd_break,
"stop": _cmd_stop,
"pause": _cmd_pause,
"resume": _cmd_resume,
"today": _cmd_today,
"tasks": _cmd_today,
"done": _cmd_done,
"report": _cmd_report,
"week": _cmd_week,
"streak": _cmd_streak,
"stats": _cmd_streak,
"block": _cmd_block,
}
def handle_text(text: str) -> str | None:
"""Run one command and return the reply, or None when it is not for us."""
text = (text or "").strip()
if not text.startswith("/"):
return None
head, _, args = text[1:].partition(" ")
name = head.split("@", 1)[0].lower()
handler = COMMANDS.get(name)
if handler is None:
return None
with session_scope() as session:
settings = settings_service.load_settings(session)
try:
return handler(session, settings, args)
except Exception as exc: # pragma: no cover - a bad command must not kill the bot
log.exception("command /%s failed", name)
return f"⚠️ {exc}"
# --------------------------------------------------------------------------
# Polling loop
# --------------------------------------------------------------------------
def _loop() -> None:
offset: int | None = None
while not _stop.is_set():
try:
with session_scope() as session:
settings = settings_service.load_settings(session)
telegram = settings.notifications.telegram
if not (telegram.enabled and telegram.commands_enabled and telegram.bot_token):
_stop.wait(30)
continue
payload: dict[str, Any] = {"timeout": _POLL_TIMEOUT}
if offset is not None:
payload["offset"] = offset
data = _api(
telegram.bot_token, "getUpdates", payload, timeout=_POLL_TIMEOUT + 10
)
for update in data.get("result", []):
offset = update["update_id"] + 1
message = update.get("message") or update.get("edited_message") or {}
chat_id = str((message.get("chat") or {}).get("id", ""))
if telegram.chat_id and chat_id and chat_id != str(telegram.chat_id):
continue # only the configured chat may drive the timer
reply = handle_text(message.get("text", ""))
if reply:
with session_scope() as session:
fresh = settings_service.load_settings(session)
_reply(fresh, chat_id or telegram.chat_id, reply)
except Exception as exc: # pragma: no cover
log.warning("telegram polling hiccup: %s", exc)
_stop.wait(10)
else:
time_module.sleep(0.3)
def start() -> None:
"""Start the polling thread if the environment allows it."""
global _thread
if not env_settings().telegram_bot_enabled or _thread is not None:
return
_stop.clear()
_thread = threading.Thread(target=_loop, name="telegram-bot", daemon=True)
_thread.start()
log.info("telegram command bot started")
def shutdown() -> None:
global _thread
_stop.set()
_thread = None
+300
View File
@@ -0,0 +1,300 @@
"""The user-editable settings, as one validated tree.
Everything the UI can change lives here: timer lengths, which todo sources are
on, what the blocker blocks, when Telegram gets poked and in what tone. It is
stored as a single JSON row (see `app.models.SettingsRow`), which keeps
"add a new option" to a one-line change instead of a migration.
Defaults are deliberately a complete, working configuration: a fresh install is
a classic 25/5/15 pomodoro with no integrations and nothing blocked.
"""
from __future__ import annotations
from datetime import time
from typing import Literal
from pydantic import BaseModel, Field, field_validator
# --------------------------------------------------------------------------
# Timer
# --------------------------------------------------------------------------
class PomodoroSettings(BaseModel):
"""Lengths and automation of the timer itself. All durations in minutes."""
work_minutes: int = Field(default=25, ge=1, le=240)
short_break_minutes: int = Field(default=5, ge=1, le=120)
long_break_minutes: int = Field(default=15, ge=1, le=240)
cycles_before_long_break: int = Field(default=4, ge=1, le=12)
auto_start_breaks: bool = True
"""Roll straight into the break when a work interval ends."""
auto_start_work: bool = False
"""Roll straight into the next work interval when a break ends."""
daily_goal_pomodoros: int = Field(default=8, ge=1, le=40)
"""The bar the daily report measures you against."""
strict_mode: bool = False
"""Abandoning a running work interval requires a reason, and is counted."""
allow_pause: bool = True
"""When false the only ways out of a work interval are finishing or abandoning."""
count_interruptions: bool = True
tick_sound: bool = False
alarm_sound: bool = True
alarm_volume: int = Field(default=70, ge=0, le=100)
desktop_notifications: bool = True
keep_on_top_during_work: bool = False
"""Desktop app: float the window above everything while a work interval runs."""
# --------------------------------------------------------------------------
# Todo sources
# --------------------------------------------------------------------------
class JiraSourceSettings(BaseModel):
"""Jira Cloud, authenticated with account email + API token (Basic auth)."""
enabled: bool = False
base_url: str = ""
email: str = ""
api_token: str = ""
extra_jql: str = ""
"""ANDed onto every query, e.g. `assignee = currentUser()`."""
start_date_field: str = "customfield_10015"
"""Jira's "Start date"; its id differs per site (see /rest/api/3/field)."""
include_all_open: bool = True
"""Fetch every open issue, not only the ones due today or overdue."""
max_issues: int = Field(default=200, ge=1, le=2000)
writeback_done: bool = True
"""Completing a Jira task here transitions the issue to Done in Jira."""
done_transition_name: str = "Done"
"""Preferred transition name when writing back; falls back to any 'done' one."""
class GoogleTasksSourceSettings(BaseModel):
"""Google Tasks, via an OAuth refresh token (see jira-helper's README)."""
enabled: bool = False
client_id: str = ""
client_secret: str = ""
refresh_token: str = ""
lists: list[str] = Field(default_factory=list)
"""Titles or ids to limit to. Empty = every list in the account."""
include_undated: bool = True
"""Keep tasks with no due date (jira-helper drops them; here they are todos)."""
writeback_done: bool = True
class TodoistSourceSettings(BaseModel):
"""Todoist, via a personal API token."""
enabled: bool = False
api_token: str = ""
projects: list[str] = Field(default_factory=list)
"""Names or ids to limit to. Empty = every project."""
filter_query: str = ""
"""Override the server-side filter. Empty = all open tasks."""
writeback_done: bool = True
class LaundrySourceSettings(BaseModel):
"""Campus laundry bookings — not a todo, but a thing that owns your time."""
enabled: bool = False
base_url: str = "https://4ka.inbicst.ru"
session_cookie: str = ""
"""The `sessionid` cookie, or a whole `name=value; ...` cookie string."""
class SourcesSettings(BaseModel):
jira: JiraSourceSettings = Field(default_factory=JiraSourceSettings)
google_tasks: GoogleTasksSourceSettings = Field(
default_factory=GoogleTasksSourceSettings
)
todoist: TodoistSourceSettings = Field(default_factory=TodoistSourceSettings)
laundry: LaundrySourceSettings = Field(default_factory=LaundrySourceSettings)
refresh_interval_minutes: int = Field(default=15, ge=1, le=1440)
"""How often the background job re-pulls every source."""
request_timeout_seconds: int = Field(default=30, ge=5, le=120)
# --------------------------------------------------------------------------
# Blocker (enforced by the desktop app)
# --------------------------------------------------------------------------
class BlockWindow(BaseModel):
"""A recurring time window in which blocking is on regardless of the timer."""
label: str = ""
start: time = time(9, 0)
end: time = time(18, 0)
days: list[int] = Field(default_factory=lambda: [0, 1, 2, 3, 4])
"""ISO weekdays, Monday = 0 .. Sunday = 6."""
class BlockerSettings(BaseModel):
enabled: bool = False
mode: Literal["during_work", "always", "scheduled"] = "during_work"
"""`during_work` blocks only while a work interval runs — the useful default."""
block_during_breaks: bool = False
blocked_domains: list[str] = Field(default_factory=list)
"""Bare hostnames, e.g. `youtube.com`. `www.` and subdomains are added for you."""
allowed_domains: list[str] = Field(default_factory=list)
"""Exceptions that survive a wildcard entry, e.g. `music.youtube.com`."""
blocked_apps: list[str] = Field(default_factory=list)
"""Process names, e.g. `steam`, `discord`, `Telegram`."""
kill_blocked_apps: bool = True
"""Terminate a blocked app when it appears; otherwise only warn and log."""
schedule: list[BlockWindow] = Field(default_factory=list)
strict: bool = False
"""The desktop app refuses to switch blocking off while a work interval runs."""
redirect_ip: str = "127.0.0.1"
app_poll_seconds: int = Field(default=5, ge=1, le=60)
notify_on_block: bool = True
# --------------------------------------------------------------------------
# Notifications and reports
# --------------------------------------------------------------------------
class QuietHours(BaseModel):
enabled: bool = False
start: time = time(23, 0)
end: time = time(8, 0)
class TelegramSettings(BaseModel):
"""Everything about the bot that nags, congratulates and reports."""
enabled: bool = False
bot_token: str = ""
chat_id: str = ""
commands_enabled: bool = True
"""Run a long-polling bot so /today, /report, /stats work in the chat."""
daily_report: bool = True
daily_report_time: time = time(21, 0)
morning_briefing: bool = True
"""A "here is your day" message with the todo list and yesterday's streak."""
morning_briefing_time: time = time(9, 0)
weekly_report: bool = True
weekly_report_weekday: int = Field(default=6, ge=0, le=6)
"""ISO weekday for the weekly wrap-up, Monday = 0 (default Sunday)."""
celebrate_every_n_pomodoros: int = Field(default=4, ge=0, le=50)
"""Cheer in the chat every N completed pomodoros today. 0 = never."""
nudge_enabled: bool = True
nudge_after_idle_minutes: int = Field(default=90, ge=10, le=600)
"""Poke you when you have not run a pomodoro for this long during the day."""
nudge_window_start: time = time(10, 0)
nudge_window_end: time = time(20, 0)
nudge_max_per_day: int = Field(default=4, ge=0, le=24)
goal_warning: bool = True
"""Warn in the evening when the daily goal is still out of reach."""
quiet_hours: QuietHours = Field(default_factory=QuietHours)
class SlackSettings(BaseModel):
enabled: bool = False
webhook_url: str = ""
class ReportSettings(BaseModel):
tone: Literal["friendly", "coach", "drill_sergeant", "dry"] = "friendly"
language: Literal["en", "ru"] = "en"
include_funny_stats: bool = True
include_task_list: bool = True
include_blocked_sites: bool = True
include_achievements: bool = True
include_comparison: bool = True
"""Compare today against your 7-day average."""
max_tasks_listed: int = Field(default=15, ge=1, le=100)
class NotificationsSettings(BaseModel):
telegram: TelegramSettings = Field(default_factory=TelegramSettings)
slack: SlackSettings = Field(default_factory=SlackSettings)
report: ReportSettings = Field(default_factory=ReportSettings)
# --------------------------------------------------------------------------
# Root
# --------------------------------------------------------------------------
class AppearanceSettings(BaseModel):
theme: Literal["light", "dark", "system"] = "system"
accent: str = "#6750A4"
"""Material 3 seed colour; the whole palette is derived from it."""
compact: bool = False
class AppSettings(BaseModel):
"""The single settings document stored in the database."""
timezone: str = "Europe/Moscow"
week_starts_monday: bool = True
pomodoro: PomodoroSettings = Field(default_factory=PomodoroSettings)
sources: SourcesSettings = Field(default_factory=SourcesSettings)
blocker: BlockerSettings = Field(default_factory=BlockerSettings)
notifications: NotificationsSettings = Field(default_factory=NotificationsSettings)
appearance: AppearanceSettings = Field(default_factory=AppearanceSettings)
@field_validator("timezone")
@classmethod
def _known_timezone(cls, value: str) -> str:
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
try:
ZoneInfo(value)
except (ZoneInfoNotFoundError, ValueError) as exc: # pragma: no cover
raise ValueError(f"unknown timezone: {value!r}") from exc
return value
# Keys whose values are never sent to a browser in full; the API replaces them
# with a masked placeholder and the UI sends the placeholder back unchanged.
SECRET_FIELDS = frozenset(
{
"api_token",
"client_secret",
"refresh_token",
"session_cookie",
"bot_token",
"webhook_url",
}
)
SECRET_MASK = "••••••••"
+12
View File
@@ -0,0 +1,12 @@
"""Todo sources.
Each module here turns one external service into `TodoItem`s and, where the
service allows it, closes a task back in that service. The auth schemes and the
pagination quirks are the ones worked out in `~/code/jira-helper`; what is new
is that these fetch **everything open**, not just what is due today, because a
pomodoro app needs something to point the timer at.
"""
from app.sources.base import SourceError, SourceResult, TodoItem
__all__ = ["SourceError", "SourceResult", "TodoItem"]
+70
View File
@@ -0,0 +1,70 @@
"""The contract every todo source implements."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date
from typing import Any, Protocol
class SourceError(RuntimeError):
"""A source could not be read. Carries a message meant for the user."""
@dataclass(slots=True)
class TodoItem:
"""One todo, normalised across Jira / Google Tasks / Todoist / local."""
source: str
external_id: str
title: str
description: str | None = None
url: str | None = None
project: str | None = None
status: str | None = None
priority: str | None = None
due_date: date | None = None
start_date: date | None = None
labels: list[str] = field(default_factory=list)
is_done: bool = False
raw: dict[str, Any] = field(default_factory=dict)
def days_overdue(self, today: date) -> int:
"""Days past the due date, or 0 when due today / not dated."""
if self.due_date is None:
return 0
return max((today - self.due_date).days, 0)
@dataclass(slots=True)
class SourceResult:
"""What one source returned, including the failure if it had one.
A source that is down costs you that source, never the whole page — the same
rule jira-helper applies to its message sections.
"""
source: str
items: list[TodoItem] = field(default_factory=list)
ok: bool = True
error: str | None = None
@classmethod
def failed(cls, source: str, error: str) -> "SourceResult":
return cls(source=source, items=[], ok=False, error=error)
class TodoSource(Protocol):
"""A configured, ready-to-call source."""
name: str
def fetch(self) -> list[TodoItem]:
"""Every open todo this source knows about."""
def complete(self, item: TodoItem) -> bool:
"""Close the task in the external system. False = not supported."""
def check(self) -> str:
"""Verify credentials; return a short human description of the account."""
+175
View File
@@ -0,0 +1,175 @@
"""Google Tasks as a todo source.
OAuth 2.0: the long-lived refresh token from the settings is exchanged for an
access token per run (cached for the life of the object). Obtain the refresh
token with jira-helper's `uv run google-tasks-auth` — the credentials are
interchangeable, so an existing setup works here unchanged.
Unlike jira-helper, undated tasks are kept by default: "someday" items are
still things you can spend a pomodoro on.
"""
from __future__ import annotations
from datetime import date
from typing import Any, Iterator
import httpx
from app.settings_schema import GoogleTasksSourceSettings
from app.sources.base import SourceError, TodoItem
_TOKEN_URL = "https://oauth2.googleapis.com/token"
_API_BASE = "https://tasks.googleapis.com/tasks/v1"
_PAGE_SIZE = 100
class GoogleTasksSource:
name = "google_tasks"
def __init__(self, config: GoogleTasksSourceSettings, *, timeout: int = 30) -> None:
if not (config.client_id and config.client_secret and config.refresh_token):
raise SourceError(
"Google Tasks needs a client id, client secret and refresh token."
)
self._config = config
self._client = httpx.Client(timeout=timeout)
self._access_token: str | None = None
# ---- plumbing ----------------------------------------------------------
def _token(self) -> str:
if self._access_token:
return self._access_token
try:
resp = self._client.post(
_TOKEN_URL,
data={
"client_id": self._config.client_id,
"client_secret": self._config.client_secret,
"refresh_token": self._config.refresh_token,
"grant_type": "refresh_token",
},
)
except httpx.HTTPError as exc:
raise SourceError(f"Google is unreachable: {exc}") from exc
if not resp.is_success:
raise SourceError(
"Could not refresh the Google access token "
f"({resp.status_code}). Re-run the Google auth flow for a fresh "
"refresh token."
)
token = (resp.json() or {}).get("access_token")
if not token:
raise SourceError("Google's token response carried no access_token.")
self._access_token = token
return token
def _get(self, path: str, params: dict[str, Any]) -> dict[str, Any]:
try:
resp = self._client.get(
f"{_API_BASE}{path}",
params=params,
headers={"Authorization": f"Bearer {self._token()}"},
)
except httpx.HTTPError as exc:
raise SourceError(f"Google is unreachable: {exc}") from exc
if not resp.is_success:
raise SourceError(f"Google GET {path} -> {resp.status_code}: {resp.text[:300]}")
return resp.json() or {}
def _paginate(self, path: str, params: dict[str, Any]) -> Iterator[dict[str, Any]]:
params = dict(params)
while True:
data = self._get(path, params)
yield from data.get("items", [])
token = data.get("nextPageToken")
if not token:
return
params["pageToken"] = token
def _task_lists(self) -> list[dict[str, Any]]:
lists = list(self._paginate("/users/@me/lists", {"maxResults": _PAGE_SIZE}))
if not self._config.lists:
return lists
wanted = set(self._config.lists)
return [
task_list
for task_list in lists
if wanted & {task_list.get("id", ""), task_list.get("title", "")}
]
# ---- source API --------------------------------------------------------
def fetch(self) -> list[TodoItem]:
items: list[TodoItem] = []
for task_list in self._task_lists():
list_id = task_list.get("id")
if not list_id:
continue
list_title = task_list.get("title") or "(untitled list)"
for raw in self._paginate(
f"/lists/{list_id}/tasks",
{
"maxResults": _PAGE_SIZE,
"showCompleted": "false",
"showHidden": "false",
"showDeleted": "false",
},
):
if raw.get("status") == "completed":
continue
due = _parse_due(raw.get("due"))
if due is None and not self._config.include_undated:
continue
task_id = raw.get("id") or ""
items.append(
TodoItem(
source=self.name,
# The list id is part of every write path, so it has to
# survive the round-trip through our database.
external_id=f"{list_id}:{task_id}",
title=(raw.get("title") or "").strip() or "(no title)",
description=(raw.get("notes") or "").strip() or None,
url=raw.get("webViewLink"),
project=list_title,
due_date=due,
raw={"list_id": list_id, "task_id": task_id},
)
)
return items
def check(self) -> str:
return f"{len(self._task_lists())} Google task list(s) visible"
def complete(self, item: TodoItem) -> bool:
"""PATCH the task's status — Google Tasks has no dedicated close verb."""
if not self._config.writeback_done:
return False
list_id, _, task_id = item.external_id.partition(":")
if not (list_id and task_id):
raise SourceError(f"Unusable Google task id: {item.external_id!r}")
try:
resp = self._client.patch(
f"{_API_BASE}/lists/{list_id}/tasks/{task_id}",
json={"status": "completed"},
headers={"Authorization": f"Bearer {self._token()}"},
)
except httpx.HTTPError as exc:
raise SourceError(f"Google is unreachable: {exc}") from exc
if not resp.is_success:
raise SourceError(f"Google could not complete the task: {resp.status_code}")
return True
def close(self) -> None:
self._client.close()
def _parse_due(raw: Any) -> date | None:
"""Google returns RFC-3339, but only the date part is ever meaningful."""
if not raw:
return None
try:
return date.fromisoformat(str(raw)[:10])
except ValueError:
return None
+184
View File
@@ -0,0 +1,184 @@
"""Jira Cloud as a todo source.
Auth is HTTP Basic with an Atlassian account email + API token, and search goes
through the token-paginated `/rest/api/3/search/jql` endpoint — both carried
over from jira-helper's client. The difference is the query: jira-helper asks
"what is due today", this asks "what is open at all", because you need
something to start a pomodoro on even when nothing is due.
Writing back is new here: finishing a Jira-backed pomodoro task transitions the
issue to Done, so the app is not a second place to keep your task list in sync.
"""
from __future__ import annotations
import re
from datetime import date
from typing import Any, Iterator
import httpx
from app.settings_schema import JiraSourceSettings
from app.sources.base import SourceError, TodoItem
_SEARCH_PATH = "/rest/api/3/search/jql"
_BASE_FIELDS = ["summary", "duedate", "status", "assignee", "priority", "project", "labels"]
_OPEN_CONDITION = "statusCategory != Done"
_CUSTOM_FIELD_RE = re.compile(r"^customfield_(\d+)$")
def start_date_clause(field_id: str) -> str:
"""`customfield_10015` has to be written `cf[10015]` in JQL; names get quoted."""
match = _CUSTOM_FIELD_RE.match(field_id)
return f"cf[{match.group(1)}]" if match else f'"{field_id}"'
class JiraSource:
name = "jira"
def __init__(self, config: JiraSourceSettings, *, timeout: int = 30) -> None:
if not (config.base_url and config.email and config.api_token):
raise SourceError("Jira needs a base URL, an account email and an API token.")
self._config = config
self._base_url = config.base_url.rstrip("/")
self._client = httpx.Client(
base_url=self._base_url,
auth=(config.email, config.api_token),
headers={"Accept": "application/json", "Content-Type": "application/json"},
timeout=timeout,
)
# ---- plumbing ----------------------------------------------------------
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
try:
resp = self._client.request(method, path, **kwargs)
except httpx.HTTPError as exc:
raise SourceError(f"Jira is unreachable: {exc}") from exc
if resp.status_code in (401, 403):
raise SourceError(
"Jira rejected the credentials (check the account email and the "
"API token — a token, never your password)."
)
if not resp.is_success:
raise SourceError(f"Jira {method} {path} -> {resp.status_code}: {resp.text[:300]}")
if resp.status_code == 204 or not resp.content:
return None
return resp.json()
def _fields(self) -> list[str]:
return [*_BASE_FIELDS, self._config.start_date_field]
def _jql(self) -> str:
"""Open issues, newest deadlines first, narrowed by the user's extra JQL."""
jql = _OPEN_CONDITION
if not self._config.include_all_open:
clause = start_date_clause(self._config.start_date_field)
jql = (
f"({_OPEN_CONDITION}) AND "
f"(duedate <= startOfDay('+7d') OR {clause} <= startOfDay())"
)
if self._config.extra_jql.strip():
jql = f"({jql}) AND ({self._config.extra_jql.strip()})"
return f"{jql} ORDER BY duedate ASC, priority DESC, updated DESC"
def _search(self, jql: str) -> Iterator[dict[str, Any]]:
payload: dict[str, Any] = {
"jql": jql,
"maxResults": 100,
"fields": self._fields(),
}
seen = 0
while True:
data = self._request("POST", _SEARCH_PATH, json=payload) or {}
for issue in data.get("issues", []):
yield issue
seen += 1
if seen >= self._config.max_issues:
return
token = data.get("nextPageToken")
if not token:
return
payload["nextPageToken"] = token
# ---- source API --------------------------------------------------------
def fetch(self) -> list[TodoItem]:
return [self._to_item(issue) for issue in self._search(self._jql())]
def check(self) -> str:
me = self._request("GET", "/rest/api/3/myself") or {}
return f"{me.get('displayName', 'unknown')} on {self._base_url}"
def complete(self, item: TodoItem) -> bool:
"""Transition the issue into a Done-category status.
Jira has no generic "close" verb: every project defines its own workflow,
so we ask the issue which transitions it offers and pick the one named in
the settings, else any transition landing in the Done category.
"""
if not self._config.writeback_done:
return False
key = item.external_id
data = self._request("GET", f"/rest/api/3/issue/{key}/transitions") or {}
transitions = data.get("transitions", [])
wanted = self._config.done_transition_name.strip().lower()
chosen = next(
(t for t in transitions if (t.get("name") or "").lower() == wanted),
None,
) or next(
(
t
for t in transitions
if ((t.get("to") or {}).get("statusCategory") or {}).get("key") == "done"
),
None,
)
if chosen is None:
raise SourceError(
f"{key} has no transition to a Done status"
+ (f" named {self._config.done_transition_name!r}" if wanted else "")
+ ". Available: "
+ ", ".join(t.get("name", "?") for t in transitions)
)
self._request(
"POST",
f"/rest/api/3/issue/{key}/transitions",
json={"transition": {"id": chosen["id"]}},
)
return True
def close(self) -> None:
self._client.close()
# ---- mapping -----------------------------------------------------------
def _to_item(self, issue: dict[str, Any]) -> TodoItem:
fields = issue.get("fields") or {}
key = issue.get("key", "")
status = (fields.get("status") or {}).get("name")
category = ((fields.get("status") or {}).get("statusCategory") or {}).get("key")
return TodoItem(
source=self.name,
external_id=key,
title=(fields.get("summary") or "").strip() or key,
url=f"{self._base_url}/browse/{key}",
project=((fields.get("project") or {}).get("name")),
status=status,
priority=(fields.get("priority") or {}).get("name"),
due_date=_parse_date(fields.get("duedate")),
start_date=_parse_date(fields.get(self._config.start_date_field)),
labels=list(fields.get("labels") or []),
is_done=category == "done",
raw={"key": key, "statusCategory": category},
)
def _parse_date(raw: Any) -> date | None:
if not raw:
return None
try:
return date.fromisoformat(str(raw)[:10])
except ValueError:
return None
+194
View File
@@ -0,0 +1,194 @@
"""Campus laundry bookings (4ka.inbicst.ru) as time commitments.
The site has no API and only offers Yandex SSO, so authentication *is* the
session cookie you copy out of your browser — the approach jira-helper settled
on. On the booking grid every slot is a `div.period`, and the ones you booked
carry an extra `my` class, which is the only thing that makes "my laundry
today" findable.
These are not todos you tick off: they are blocks of time that will interrupt
your pomodoros, so the UI shows them on the timer page and the daily report
mentions them.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from datetime import date, datetime, time
from typing import Any
from urllib.parse import urlsplit
import httpx
from bs4 import BeautifulSoup
from app.settings_schema import LaundrySourceSettings
from app.sources.base import SourceError, TodoItem
_PROFILE_PATH = "/profile/"
_GRID_PATH = "/"
_SESSION_COOKIE = "sessionid"
_COOKIE_PAIR_RE = re.compile(r"^[A-Za-z0-9_.\-]+=")
_TIME_RANGE_RE = re.compile(r"(\d{1,2}):(\d{2})\s*[-–—]\s*(\d{1,2}):(\d{2})")
_USER_AGENT = "Mozilla/5.0 (compatible; kadence laundry reader)"
_EXPIRED_HINT = (
"the laundry session cookie is no longer valid. Sign in at {base_url} in "
"your browser, copy the fresh `sessionid` cookie and paste it into "
"Settings → Sources → Laundry."
)
def parse_cookie_header(raw: str) -> dict[str, str]:
"""Accept either a bare `sessionid` value or a whole copied cookie string."""
if not _COOKIE_PAIR_RE.match(raw):
return {_SESSION_COOKIE: raw}
cookies: dict[str, str] = {}
for part in raw.split(";"):
name, _, value = part.strip().partition("=")
if name.strip() and value.strip():
cookies[name.strip()] = value.strip()
return cookies
@dataclass(slots=True)
class LaundrySlot:
location: str
machine: str
time_range: str
start: time | None
finished: bool
current: bool
refunded: bool
@property
def is_upcoming(self) -> bool:
return not self.finished and not self.refunded
class LaundrySource:
name = "laundry"
def __init__(self, config: LaundrySourceSettings, *, timeout: int = 30) -> None:
if not config.session_cookie:
raise SourceError("The laundry reader needs a `sessionid` cookie.")
cookies = parse_cookie_header(config.session_cookie)
if _SESSION_COOKIE not in cookies:
raise SourceError(
f"The laundry cookie has no {_SESSION_COOKIE!r} value in it."
)
self._base_url = config.base_url.rstrip("/")
host = urlsplit(self._base_url).hostname or ""
jar = httpx.Cookies()
for key, value in cookies.items():
jar.set(key, value, domain=host, path="/")
self._client = httpx.Client(
base_url=self._base_url,
cookies=jar,
headers={"User-Agent": _USER_AGENT},
timeout=timeout,
follow_redirects=False,
)
def _check_session(self) -> None:
"""An expired cookie silently serves the anonymous page, where none of
the slots are marked as yours — indistinguishable from "nothing booked".
So verify first."""
try:
resp = self._client.get(_PROFILE_PATH)
except httpx.HTTPError as exc:
raise SourceError(f"The laundry site is unreachable: {exc}") from exc
if resp.status_code != 200:
raise SourceError(_EXPIRED_HINT.format(base_url=self._base_url))
def fetch(self) -> list[TodoItem]:
self._check_session()
try:
resp = self._client.get(_GRID_PATH)
except httpx.HTTPError as exc:
raise SourceError(f"The laundry site is unreachable: {exc}") from exc
if not resp.is_success:
raise SourceError(f"Laundry grid -> {resp.status_code}")
_, slots = parse_my_slots(resp.text)
today = date.today()
return [
TodoItem(
source=self.name,
external_id=f"{today.isoformat()}:{slot.machine}:{slot.time_range}",
title=f"{slot.time_range} — {slot.machine} ({slot.location})",
project="Laundry",
status="running now" if slot.current else "booked",
due_date=today,
raw={
"start": slot.start.isoformat() if slot.start else None,
"current": slot.current,
},
)
for slot in slots
if slot.is_upcoming
]
def check(self) -> str:
self._check_session()
return f"signed in at {self._base_url}"
def complete(self, item: TodoItem) -> bool:
return False # the washing machine finishes on its own
def close(self) -> None:
self._client.close()
def _text(node: Any) -> str:
return node.get_text(strip=True) if node is not None else ""
def _parse_start(time_range: str) -> time | None:
match = _TIME_RANGE_RE.search(time_range)
if not match:
return None
hour, minute = int(match.group(1)), int(match.group(2))
if not (0 <= hour <= 23 and 0 <= minute <= 59):
return None
return time(hour, minute)
def parse_my_slots(html: str) -> tuple[str, list[LaundrySlot]]:
"""Pull your own slots off a booking grid. Returns `(day_label, slots)`."""
soup = BeautifulSoup(html, "html.parser")
day = soup.select_one(".date-machines")
if day is None:
raise SourceError(
"No booking grid in the response — the layout changed, or the "
"session was not accepted."
)
day_label = (day.get("id") or "").strip()
slots: list[LaundrySlot] = []
for block in day.select(".machines"):
location = _text(block.select_one(".floor-title")) or "?"
for machine in block.select(".machine"):
machine_name = _text(machine.select_one(".machine-title")) or "?"
for period in machine.select(".period.my"):
classes = set(period.get("class") or [])
time_range = _text(period.find("div")) or "?"
slots.append(
LaundrySlot(
location=location,
machine=machine_name,
time_range=time_range,
start=_parse_start(time_range),
finished="finished" in classes,
current="current" in classes,
refunded="refunded" in classes,
)
)
slots.sort(key=lambda slot: (slot.start is None, slot.start or time.min, slot.machine))
return day_label, slots
def slot_is_now(slot: LaundrySlot, moment: datetime | None = None) -> bool:
"""Whether the machine is running at `moment` (used by the timer page)."""
return slot.current
+125
View File
@@ -0,0 +1,125 @@
"""Builds the configured sources and pulls them all, independently.
Independence is the whole point: an expired laundry cookie or a Jira outage
costs you that one source and shows up as a warning beside it, never as an
empty task list.
"""
from __future__ import annotations
import logging
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Callable
from app.settings_schema import AppSettings
from app.sources.base import SourceError, SourceResult, TodoItem
from app.sources.google_tasks import GoogleTasksSource
from app.sources.jira import JiraSource
from app.sources.laundry import LaundrySource
from app.sources.todoist import TodoistSource
log = logging.getLogger(__name__)
SOURCE_LABELS = {
"jira": "Jira",
"todoist": "Todoist",
"google_tasks": "Google Tasks",
"laundry": "Laundry",
"local": "My list",
}
def build_sources(settings: AppSettings) -> dict[str, Any]:
"""Instantiate every enabled source. A misconfigured one is skipped, loudly."""
timeout = settings.sources.request_timeout_seconds
builders: list[tuple[str, Callable[[], Any]]] = [
("jira", lambda: JiraSource(settings.sources.jira, timeout=timeout)),
("todoist", lambda: TodoistSource(settings.sources.todoist, timeout=timeout)),
(
"google_tasks",
lambda: GoogleTasksSource(settings.sources.google_tasks, timeout=timeout),
),
("laundry", lambda: LaundrySource(settings.sources.laundry, timeout=timeout)),
]
enabled = {
"jira": settings.sources.jira.enabled,
"todoist": settings.sources.todoist.enabled,
"google_tasks": settings.sources.google_tasks.enabled,
"laundry": settings.sources.laundry.enabled,
}
sources: dict[str, Any] = {}
for name, build in builders:
if not enabled[name]:
continue
try:
sources[name] = build()
except SourceError as exc:
log.warning("source %s is enabled but unusable: %s", name, exc)
return sources
def _close(source: Any) -> None:
closer = getattr(source, "close", None)
if callable(closer):
try:
closer()
except Exception: # pragma: no cover
log.debug("closing %s failed", type(source).__name__, exc_info=True)
def fetch_all(settings: AppSettings) -> list[SourceResult]:
"""Fetch every enabled source in parallel; never raises."""
sources = build_sources(settings)
if not sources:
return []
def run(name: str, source: Any) -> SourceResult:
try:
items: list[TodoItem] = source.fetch()
return SourceResult(source=name, items=items)
except SourceError as exc:
return SourceResult.failed(name, str(exc))
except Exception as exc: # pragma: no cover - defensive
log.exception("source %s blew up", name)
return SourceResult.failed(name, f"unexpected error: {exc}")
try:
with ThreadPoolExecutor(max_workers=len(sources)) as pool:
futures = [pool.submit(run, name, src) for name, src in sources.items()]
return [future.result() for future in futures]
finally:
for source in sources.values():
_close(source)
def complete_remote(settings: AppSettings, item: TodoItem) -> tuple[bool, str | None]:
"""Close a task in its own system. Returns `(did_write, error)`."""
sources = build_sources(settings)
source = sources.get(item.source)
if source is None:
return False, None
try:
return bool(source.complete(item)), None
except SourceError as exc:
return False, str(exc)
finally:
for built in sources.values():
_close(built)
def check_source(settings: AppSettings, name: str) -> tuple[bool, str]:
"""Verify one source's credentials — what the settings page's Test button calls."""
sources = build_sources(settings)
source = sources.get(name)
if source is None:
return False, f"{SOURCE_LABELS.get(name, name)} is not enabled or not configured."
try:
return True, source.check()
except SourceError as exc:
return False, str(exc)
except Exception as exc: # pragma: no cover
return False, f"unexpected error: {exc}"
finally:
for built in sources.values():
_close(built)
+145
View File
@@ -0,0 +1,145 @@
"""Todoist as a todo source.
A personal API token sent as a Bearer token — no OAuth dance. jira-helper asks
Todoist's own `overdue | today` filter so Todoist's timezone and recurrence
rules decide what is due; here the default is *every* open task (you need
something to point the timer at), with the filter still available as a setting
for people who want the narrower list.
"""
from __future__ import annotations
from datetime import date
from typing import Any, Iterator
import httpx
from app.settings_schema import TodoistSourceSettings
from app.sources.base import SourceError, TodoItem
_API_BASE = "https://api.todoist.com/api/v1"
_TASK_URL = "https://app.todoist.com/app/task/{task_id}"
_PAGE_SIZE = 200
# Todoist stores priority upside down from how its apps label it: 4 is "p1".
_PRIORITY_LABELS = {4: "p1", 3: "p2", 2: "p3"}
class TodoistSource:
name = "todoist"
def __init__(self, config: TodoistSourceSettings, *, timeout: int = 30) -> None:
if not config.api_token:
raise SourceError("Todoist needs an API token (Settings → Integrations).")
self._config = config
self._client = httpx.Client(
base_url=_API_BASE,
headers={
"Accept": "application/json",
"Authorization": f"Bearer {config.api_token}",
},
timeout=timeout,
)
# ---- plumbing ----------------------------------------------------------
def _get(self, path: str, params: dict[str, Any]) -> dict[str, Any]:
try:
resp = self._client.get(path, params=params)
except httpx.HTTPError as exc:
raise SourceError(f"Todoist is unreachable: {exc}") from exc
if resp.status_code in (401, 403):
raise SourceError("Todoist rejected the API token.")
if not resp.is_success:
raise SourceError(f"Todoist GET {path} -> {resp.status_code}: {resp.text[:300]}")
return resp.json() or {}
def _paginate(self, path: str, params: dict[str, Any]) -> Iterator[dict[str, Any]]:
params = dict(params)
while True:
data = self._get(path, params)
yield from data.get("results", [])
cursor = data.get("next_cursor")
if not cursor:
return
params["cursor"] = cursor
def _projects(self) -> dict[str, str]:
return {
project["id"]: project.get("name") or "(untitled project)"
for project in self._paginate("/projects", {"limit": _PAGE_SIZE})
if project.get("id")
}
def _selected(self, project_id: str, project_name: str) -> bool:
if not self._config.projects:
return True
wanted = set(self._config.projects)
return bool(wanted & {project_id, project_name})
# ---- source API --------------------------------------------------------
def fetch(self) -> list[TodoItem]:
projects = self._projects()
query = self._config.filter_query.strip()
if query:
raw_tasks = self._paginate("/tasks/filter", {"query": query, "limit": _PAGE_SIZE})
else:
raw_tasks = self._paginate("/tasks", {"limit": _PAGE_SIZE})
items: list[TodoItem] = []
for raw in raw_tasks:
if raw.get("checked") or raw.get("is_deleted"):
continue
project_id = raw.get("project_id") or ""
project_name = projects.get(project_id, "(unknown project)")
if not self._selected(project_id, project_name):
continue
task_id = str(raw.get("id") or "")
due = raw.get("due") or {}
items.append(
TodoItem(
source=self.name,
external_id=task_id,
title=(raw.get("content") or "").strip() or "(no content)",
description=(raw.get("description") or "").strip() or None,
url=_TASK_URL.format(task_id=task_id) if task_id else None,
project=project_name,
priority=_PRIORITY_LABELS.get(raw.get("priority")),
due_date=_parse_due(due),
labels=list(raw.get("labels") or []),
raw={"recurring": bool(due.get("is_recurring"))},
)
)
return items
def check(self) -> str:
projects = self._projects()
return f"{len(projects)} Todoist project(s) visible"
def complete(self, item: TodoItem) -> bool:
"""`POST /tasks/{id}/close` — the same verb the apps' checkbox uses."""
if not self._config.writeback_done:
return False
try:
resp = self._client.post(f"/tasks/{item.external_id}/close")
except httpx.HTTPError as exc:
raise SourceError(f"Todoist is unreachable: {exc}") from exc
if not resp.is_success:
raise SourceError(f"Todoist could not close the task: {resp.status_code}")
return True
def close(self) -> None:
self._client.close()
def _parse_due(due: Any) -> date | None:
if not isinstance(due, dict):
return None
raw = due.get("date")
if not raw:
return None
try:
return date.fromisoformat(str(raw)[:10])
except ValueError:
return None
+75
View File
@@ -0,0 +1,75 @@
"""Timezone helpers.
Everything is stored in UTC and *reported* in the user's timezone, so there is
exactly one place that knows how to turn "now" into "which day does this count
for" — here.
"""
from __future__ import annotations
from datetime import date, datetime, time, timedelta, timezone
from zoneinfo import ZoneInfo
def tz(name: str) -> ZoneInfo:
try:
return ZoneInfo(name)
except Exception: # pragma: no cover - bad tz falls back rather than crashing
return ZoneInfo("UTC")
def now_utc() -> datetime:
return datetime.now(timezone.utc)
def ensure_utc(moment: datetime | None) -> datetime | None:
"""Attach UTC to a naive datetime.
SQLite has no timezone type, so a column declared `DateTime(timezone=True)`
comes back naive from SQLite and aware from Postgres. Everything we store is
UTC, so normalising on read keeps the arithmetic valid on both.
"""
if moment is None:
return None
return moment if moment.tzinfo is not None else moment.replace(tzinfo=timezone.utc)
def to_local(moment: datetime, tz_name: str) -> datetime:
if moment.tzinfo is None:
moment = moment.replace(tzinfo=timezone.utc)
return moment.astimezone(tz(tz_name))
def local_date(tz_name: str, moment: datetime | None = None) -> date:
"""The day a moment belongs to for the user (the report's unit of grouping)."""
return to_local(moment or now_utc(), tz_name).date()
def local_time(tz_name: str, moment: datetime | None = None) -> time:
return to_local(moment or now_utc(), tz_name).timetz().replace(tzinfo=None)
def day_bounds_utc(day: date, tz_name: str) -> tuple[datetime, datetime]:
"""The UTC half-open interval `[start, end)` covering a local calendar day."""
zone = tz(tz_name)
start = datetime.combine(day, time.min, tzinfo=zone)
end = start + timedelta(days=1)
return start.astimezone(timezone.utc), end.astimezone(timezone.utc)
def in_window(moment: time, start: time, end: time) -> bool:
"""Whether a clock time is inside a window, handling windows that wrap midnight."""
if start <= end:
return start <= moment <= end
return moment >= start or moment <= end
def humanize_minutes(total_minutes: int | float) -> str:
"""`95` -> `1h 35m`, `40` -> `40m`, `0` -> `0m`."""
minutes = round(total_minutes)
hours, rest = divmod(minutes, 60)
if hours and rest:
return f"{hours}h {rest}m"
if hours:
return f"{hours}h"
return f"{rest}m"
+40
View File
@@ -0,0 +1,40 @@
[project]
name = "kadence-backend"
version = "0.1.0"
description = "Pomodoro timer, todo aggregation, website/app blocking and daily Telegram reports."
readme = "../README.md"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.30",
"sqlalchemy>=2.0",
"psycopg[binary]>=3.2",
"pydantic>=2.8",
"pydantic-settings>=2.4",
"httpx>=0.27",
"beautifulsoup4>=4.12",
"apscheduler>=3.10,<4",
"python-dotenv>=1.0",
"tzdata>=2024.1",
]
[project.optional-dependencies]
dev = ["pytest>=8.0", "ruff>=0.6"]
[project.scripts]
kadence = "app.cli:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["app"]
[tool.ruff]
line-length = 90
target-version = "py312"
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
+64
View File
@@ -0,0 +1,64 @@
"""Test fixtures.
The engine is built at import time from the environment, so the database URL has
to be set before anything from `app` is imported — hence the env juggling here
rather than in a fixture.
"""
from __future__ import annotations
import os
import tempfile
import pytest
os.environ.setdefault("DATABASE_URL", "sqlite:///" + tempfile.mktemp(suffix=".db"))
os.environ["SCHEDULER_ENABLED"] = "false"
os.environ["TELEGRAM_BOT_ENABLED"] = "false"
# Keep the seeded settings empty so tests never reach a real Jira/Todoist.
for name in (
"JIRA_BASE_URL",
"JIRA_EMAIL",
"JIRA_API_TOKEN",
"TODOIST_API_TOKEN",
"GOOGLE_CLIENT_ID",
"GOOGLE_CLIENT_SECRET",
"GOOGLE_REFRESH_TOKEN",
"LAUNDRY_SESSION",
"TELEGRAM_BOT_TOKEN",
"TELEGRAM_CHAT_ID",
"SLACK_WEBHOOK_URL",
):
os.environ.pop(name, None)
from app.db import Base, SessionLocal, engine, init_db # noqa: E402
from app.services import settings_service # noqa: E402
from app.settings_schema import AppSettings # noqa: E402
@pytest.fixture()
def session():
Base.metadata.drop_all(bind=engine)
init_db()
db = SessionLocal()
try:
yield db
finally:
db.close()
@pytest.fixture()
def settings(session) -> AppSettings:
return settings_service.load_settings(session)
@pytest.fixture()
def client():
from fastapi.testclient import TestClient
from app.main import app
Base.metadata.drop_all(bind=engine)
init_db()
with TestClient(app) as test_client:
yield test_client
+99
View File
@@ -0,0 +1,99 @@
"""The HTTP surface, driven the way the clients drive it."""
from __future__ import annotations
def test_health_and_info(client):
assert client.get("/api/health").json()["status"] == "ok"
assert "auth_required" in client.get("/api/info").json()
def test_a_whole_pomodoro_through_the_api(client):
client.put("/api/settings", json={"pomodoro": {"daily_goal_pomodoros": 1}})
task = client.post("/api/tasks", json={"title": "Write the API"}).json()
state = client.post("/api/timer/start", json={"kind": "work", "task_id": task["id"]}).json()
assert state["current"]["kind"] == "work"
assert state["current"]["task"]["title"] == "Write the API"
assert client.post("/api/timer/pause").json()["current"]["status"] == "paused"
assert client.post("/api/timer/resume").json()["current"]["status"] == "running"
assert client.post("/api/timer/extend", json={"minutes": 5}).json()["current"]["planned_seconds"] > 0
done = client.post("/api/timer/complete", json={"auto_next": False}).json()
assert done["finished"]["status"] == "completed"
assert done["completed_today"] == 1
marked = client.post(f"/api/tasks/{task['id']}/done", json={"done": True}).json()
assert marked["task"]["is_done"] is True
report = client.get("/api/reports/daily").json()
assert report["report"]["metrics"]["pomodoros"] == 1
assert "Daily report" in report["telegram_preview"]
def test_timer_errors_are_400_not_500(client):
assert client.post("/api/timer/pause").status_code == 400
assert client.post("/api/timer/complete", json={}).status_code == 400
def test_task_crud(client):
created = client.post("/api/tasks", json={"title": "Temporary", "priority": "p2"}).json()
listing = client.get("/api/tasks").json()
assert any(task["id"] == created["id"] for task in listing["tasks"])
patched = client.patch(f"/api/tasks/{created['id']}", json={"pinned": True}).json()
assert patched["pinned"] is True
client.delete(f"/api/tasks/{created['id']}")
assert all(task["id"] != created["id"] for task in client.get("/api/tasks").json()["tasks"])
def test_refresh_reports_per_source_failures_without_failing(client):
client.put(
"/api/settings",
json={"sources": {"todoist": {"enabled": True, "api_token": "definitely-invalid"}}},
)
result = client.post("/api/tasks/refresh").json()
assert result["sources"], "the enabled source should be reported"
assert result["sources"][0]["ok"] is False
assert client.get("/api/tasks").status_code == 200
def test_blocker_policy_and_events(client):
client.put(
"/api/settings",
json={"blocker": {"enabled": True, "blocked_domains": ["youtube.com"]}},
)
assert client.get("/api/blocker/policy").json()["active"] is False
client.post("/api/timer/start", json={"kind": "work"})
policy = client.get("/api/blocker/policy").json()
assert policy["active"] is True
assert "youtube.com" in policy["domains"]
client.post("/api/blocker/events", json={"kind": "domain", "target": "youtube.com"})
events = client.get("/api/blocker/events").json()
assert events["total"] == 1
def test_settings_round_trip_masks_secrets(client):
client.put("/api/settings", json={"sources": {"todoist": {"api_token": "s3cret"}}})
body = client.get("/api/settings").json()
assert body["settings"]["sources"]["todoist"]["api_token"] == "••••••••"
assert body["defaults"]["blocked_domains"]
# Posting the mask back must not wipe the token.
client.put("/api/settings", json=body["settings"])
again = client.get("/api/settings").json()
assert again["settings"]["sources"]["todoist"]["api_token"] == "••••••••"
def test_invalid_settings_are_rejected(client):
assert client.put("/api/settings", json={"pomodoro": {"work_minutes": 0}}).status_code == 422
assert client.put("/api/settings", json={"timezone": "Mars/Olympus"}).status_code == 422
def test_notification_test_endpoint_falls_back_to_the_log(client):
body = client.post("/api/notifications/test", json={}).json()
assert body["deliveries"][0]["channel"] == "log"
+62
View File
@@ -0,0 +1,62 @@
"""Blocking policy: when it is on, and what is on the list."""
from __future__ import annotations
from app.services import blocker_service, pomodoro, settings_service
def test_disabled_blocker_blocks_nothing(session, settings):
pomodoro.start(session, settings, kind="work")
policy = blocker_service.policy(session, settings)
assert policy["active"] is False and policy["domains"] == []
def test_during_work_mode_follows_the_timer(session, settings):
settings.blocker.enabled = True
settings.blocker.blocked_domains = ["youtube.com"]
settings_service.save_settings(session, settings)
assert blocker_service.policy(session, settings)["active"] is False
pomodoro.start(session, settings, kind="work")
policy = blocker_service.policy(session, settings)
assert policy["active"] is True
assert "www.youtube.com" in policy["domains"]
pomodoro.complete(session, settings, auto_next=False)
assert blocker_service.policy(session, settings)["active"] is False
def test_breaks_do_not_block_unless_asked(session, settings):
settings.blocker.enabled = True
settings_service.save_settings(session, settings)
pomodoro.start(session, settings, kind="short_break")
assert blocker_service.policy(session, settings)["active"] is False
settings.blocker.block_during_breaks = True
settings_service.save_settings(session, settings)
assert blocker_service.policy(session, settings)["active"] is True
def test_always_mode_ignores_the_timer(session, settings):
settings.blocker.enabled = True
settings.blocker.mode = "always"
settings_service.save_settings(session, settings)
assert blocker_service.policy(session, settings)["active"] is True
def test_allowlist_wins_over_the_blocklist(session, settings):
settings.blocker.blocked_domains = ["youtube.com"]
settings.blocker.allowed_domains = ["m.youtube.com"]
hosts = blocker_service.expand_domains(settings)
assert "youtube.com" in hosts
assert "m.youtube.com" not in hosts
def test_events_are_grouped_for_the_report(session, settings):
for _ in range(3):
blocker_service.record_event(session, settings, kind="domain", target="YouTube.com")
blocker_service.record_event(session, settings, kind="app", target="steam")
summary = blocker_service.events_for_day(session, settings)
assert summary["total"] == 4
assert summary["by_target"][0] == {"target": "youtube.com", "count": 3}
+93
View File
@@ -0,0 +1,93 @@
"""The timer's state machine."""
from __future__ import annotations
import pytest
from app.models import PomodoroSession
from app.services import pomodoro, settings_service
def test_start_sets_duration_from_settings(session, settings):
row = pomodoro.start(session, settings, kind="work")
assert row.planned_seconds == settings.pomodoro.work_minutes * 60
assert row.status == "running"
assert pomodoro.remaining_seconds(row) <= row.planned_seconds
def test_minutes_override_wins(session, settings):
row = pomodoro.start(session, settings, kind="work", minutes=3)
assert row.planned_seconds == 180
def test_starting_again_abandons_the_previous_interval(session, settings):
first = pomodoro.start(session, settings, kind="work")
pomodoro.start(session, settings, kind="work")
session.refresh(first)
assert first.status == "abandoned"
assert len(session.query(PomodoroSession).all()) == 2
def test_pause_counts_an_interruption_and_freezes_the_clock(session, settings):
pomodoro.start(session, settings, kind="work")
row = pomodoro.pause(session, settings)
assert row.status == "paused"
assert row.interruptions == 1
frozen = pomodoro.elapsed_seconds(row)
assert pomodoro.elapsed_seconds(row) == frozen
def test_pause_can_be_switched_off(session, settings):
settings.pomodoro.allow_pause = False
settings_service.save_settings(session, settings)
pomodoro.start(session, settings, kind="work")
with pytest.raises(pomodoro.TimerError):
pomodoro.pause(session, settings)
def test_long_break_arrives_on_the_configured_cycle(session, settings):
settings.pomodoro.cycles_before_long_break = 2
settings.pomodoro.auto_start_breaks = False
settings_service.save_settings(session, settings)
pomodoro.start(session, settings, kind="work")
pomodoro.complete(session, settings, auto_next=False)
assert pomodoro.next_kind(session, settings) == "short_break"
pomodoro.start(session, settings, kind="short_break")
pomodoro.complete(session, settings, auto_next=False)
pomodoro.start(session, settings, kind="work")
pomodoro.complete(session, settings, auto_next=False)
assert pomodoro.next_kind(session, settings) == "long_break"
def test_auto_start_breaks_rolls_straight_on(session, settings):
settings.pomodoro.auto_start_breaks = True
settings_service.save_settings(session, settings)
pomodoro.start(session, settings, kind="work")
finished, follow_on = pomodoro.complete(session, settings)
assert finished.status == "completed"
assert follow_on is not None and follow_on.kind == "short_break"
def test_strict_mode_demands_a_reason(session, settings):
settings.pomodoro.strict_mode = True
settings_service.save_settings(session, settings)
pomodoro.start(session, settings, kind="work")
with pytest.raises(pomodoro.TimerError):
pomodoro.abandon(session, settings)
row = pomodoro.abandon(session, settings, reason="fire alarm")
assert row.status == "abandoned" and row.abandon_reason == "fire alarm"
def test_reap_closes_intervals_left_running(session, settings):
from datetime import timedelta
from app.models import utcnow
row = pomodoro.start(session, settings, kind="work")
row.started_at = utcnow() - timedelta(hours=9)
session.commit()
assert pomodoro.reap_stale(session, settings) == 1
session.refresh(row)
assert row.status == "abandoned"
+99
View File
@@ -0,0 +1,99 @@
"""Metrics, achievements and the rendered report."""
from __future__ import annotations
from datetime import timedelta
from app.models import utcnow
from app.services import pomodoro, report, settings_service, stats, tasks_service
from app.timeutil import local_date
def _run_pomodoros(session, settings, count: int, *, minutes: int = 25) -> None:
"""Record `count` finished work intervals, backdated so they do not overlap."""
for index in range(count):
row = pomodoro.start(session, settings, kind="work", minutes=minutes)
start = utcnow() - timedelta(minutes=(count - index) * (minutes + 5))
row.started_at = start
row.ended_at = start + timedelta(minutes=minutes)
row.actual_seconds = minutes * 60
row.status = "completed"
session.commit()
def test_day_metrics_counts_what_happened(session, settings):
_run_pomodoros(session, settings, 3)
metrics = stats.day_metrics(session, settings, local_date(settings.timezone))
assert metrics["pomodoros"] == 3
assert metrics["focus_minutes"] == 75
assert metrics["goal"] == settings.pomodoro.daily_goal_pomodoros
assert metrics["first_start"] and metrics["last_end"]
def test_longest_stretch_joins_adjacent_pomodoros(session, settings):
_run_pomodoros(session, settings, 3, minutes=25)
metrics = stats.day_metrics(session, settings, local_date(settings.timezone))
# 25-minute intervals five minutes apart are one unbroken stretch.
assert metrics["longest_stretch_minutes"] >= 75
def test_streak_survives_an_empty_today(session, settings):
today = local_date(settings.timezone)
_run_pomodoros(session, settings, 1)
# Move the work to yesterday and leave today empty.
from app.models import PomodoroSession
for row in session.query(PomodoroSession).all():
row.local_date = today - timedelta(days=1)
session.commit()
streak = stats.streak(session, settings, until=today)
assert streak["current"] == 1
def test_report_payload_has_the_pieces_the_ui_draws(session, settings):
settings.pomodoro.daily_goal_pomodoros = 2
settings_service.save_settings(session, settings)
_run_pomodoros(session, settings, 2)
task = tasks_service.create_local_task(session, title="Ship the thing")
tasks_service.complete_task(session, settings, task.id)
payload = report.build_daily(session, settings, local_date(settings.timezone))
assert payload["metrics"]["pomodoros"] == 2
assert payload["metrics"]["tasks_completed_count"] == 1
assert any(item["code"] == "goal_met" for item in payload["achievements"])
assert payload["fun_facts"]
assert 0 <= payload["score"] <= 100
def test_rendered_report_escapes_borrowed_text(session, settings):
task = tasks_service.create_local_task(session, title="Fix <b>&</b> ship")
tasks_service.complete_task(session, settings, task.id)
payload = report.build_daily(session, settings, local_date(settings.timezone))
text = report.render_daily_telegram(payload, settings)
assert "Fix &lt;b&gt;&amp;&lt;/b&gt; ship" in text
def test_russian_report_renders(session, settings):
settings.notifications.report.language = "ru"
settings.notifications.report.tone = "drill_sergeant"
settings_service.save_settings(session, settings)
_run_pomodoros(session, settings, 1)
payload = report.build_daily(session, settings, local_date(settings.timezone))
text = report.render_daily_telegram(payload, settings)
assert "Отчёт за день" in text
def test_weekly_review_covers_seven_days(session, settings):
_run_pomodoros(session, settings, 2)
payload = report.build_weekly(session, settings, local_date(settings.timezone))
assert len(payload["days"]) == 7
assert payload["pomodoros"] == 2
assert "Weekly review" in report.render_weekly_telegram(payload, settings)
def test_progress_bar_is_proportional():
assert report.progress_bar(0, 8) == "░" * 10
assert report.progress_bar(8, 8) == "█" * 10
assert report.progress_bar(4, 8).count("█") == 5
+72
View File
@@ -0,0 +1,72 @@
"""Settings: deep merge, secret masking, and the environment seed."""
from __future__ import annotations
from app.services import settings_service
from app.settings_schema import SECRET_MASK
def test_patch_merges_without_clobbering_siblings(session):
settings_service.apply_patch(session, {"pomodoro": {"work_minutes": 50}})
updated = settings_service.apply_patch(
session, {"pomodoro": {"daily_goal_pomodoros": 12}}
)
assert updated.pomodoro.work_minutes == 50
assert updated.pomodoro.daily_goal_pomodoros == 12
assert updated.pomodoro.short_break_minutes == 5 # untouched default
def test_secrets_are_masked_on_the_way_out(session):
settings_service.apply_patch(
session,
{"sources": {"todoist": {"enabled": True, "api_token": "super-secret"}}},
)
masked = settings_service.mask_secrets(
settings_service.load_settings(session).model_dump(mode="json")
)
assert masked["sources"]["todoist"]["api_token"] == SECRET_MASK
def test_sending_the_mask_back_keeps_the_stored_secret(session):
settings_service.apply_patch(
session, {"sources": {"todoist": {"api_token": "super-secret"}}}
)
settings_service.apply_patch(
session,
{"sources": {"todoist": {"api_token": SECRET_MASK, "enabled": True}}},
)
settings = settings_service.load_settings(session)
assert settings.sources.todoist.api_token == "super-secret"
assert settings.sources.todoist.enabled is True
def test_a_real_new_secret_replaces_the_old_one(session):
settings_service.apply_patch(session, {"sources": {"todoist": {"api_token": "old"}}})
settings_service.apply_patch(session, {"sources": {"todoist": {"api_token": "new"}}})
assert settings_service.load_settings(session).sources.todoist.api_token == "new"
def test_environment_seeds_the_sources(monkeypatch):
monkeypatch.setenv("JIRA_BASE_URL", "https://example.atlassian.net/")
monkeypatch.setenv("JIRA_EMAIL", "me@example.com")
monkeypatch.setenv("JIRA_API_TOKEN", "token")
monkeypatch.setenv("TODOIST_API_TOKEN", "todoist-token")
monkeypatch.setenv("TODOIST_PROJECTS", "Inbox, Учёба")
from app.config import env_settings
env_settings.cache_clear()
try:
settings = settings_service.settings_from_env()
finally:
env_settings.cache_clear()
assert settings.sources.jira.enabled
assert settings.sources.jira.base_url == "https://example.atlassian.net"
assert settings.sources.todoist.projects == ["Inbox", "Учёба"]
def test_listeners_fire_on_save(session):
seen = []
settings_service.on_change(lambda new: seen.append(new.pomodoro.work_minutes))
settings_service.apply_patch(session, {"pomodoro": {"work_minutes": 33}})
assert 33 in seen
+100
View File
@@ -0,0 +1,100 @@
"""Source mapping: the parts that work without a network."""
from __future__ import annotations
from datetime import date
from app.settings_schema import JiraSourceSettings, LaundrySourceSettings
from app.sources.jira import JiraSource, start_date_clause
from app.sources.laundry import parse_cookie_header, parse_my_slots
JIRA_CONFIG = JiraSourceSettings(
enabled=True,
base_url="https://example.atlassian.net",
email="me@example.com",
api_token="token",
)
def test_start_date_clause_handles_custom_fields_and_names():
assert start_date_clause("customfield_10015") == "cf[10015]"
assert start_date_clause("Start date") == '"Start date"'
def test_jira_jql_includes_the_extra_filter():
source = JiraSource(JIRA_CONFIG.model_copy(update={"extra_jql": "assignee = currentUser()"}))
jql = source._jql()
assert "statusCategory != Done" in jql
assert "assignee = currentUser()" in jql
source.close()
def test_jira_issue_maps_to_a_todo_item():
source = JiraSource(JIRA_CONFIG)
item = source._to_item(
{
"key": "ABC-1",
"fields": {
"summary": "Fix the thing",
"duedate": "2026-10-20",
"customfield_10015": "2026-10-15",
"status": {"name": "In Progress", "statusCategory": {"key": "indeterminate"}},
"priority": {"name": "High"},
"project": {"name": "Platform"},
"labels": ["backend"],
},
}
)
source.close()
assert item.external_id == "ABC-1"
assert item.title == "Fix the thing"
assert item.url == "https://example.atlassian.net/browse/ABC-1"
assert item.due_date == date(2026, 10, 20)
assert item.start_date == date(2026, 10, 15)
assert item.priority == "High"
assert item.is_done is False
assert item.days_overdue(date(2026, 10, 22)) == 2
def test_cookie_header_accepts_both_forms():
assert parse_cookie_header("abc123") == {"sessionid": "abc123"}
parsed = parse_cookie_header("csrftoken=x; sessionid=abc123")
assert parsed["sessionid"] == "abc123"
LAUNDRY_HTML = """
<div class="date-machines" id="Чт, 15 окт.">
<div class="machines">
<div class="floor-title">Этаж 1</div>
<div class="machine">
<div class="machine-title">Машинка 3</div>
<div class="period my finished"><div>09:00-12:00</div></div>
<div class="period my current"><div>18:00-21:00</div></div>
<div class="period"><div>21:00-00:00</div></div>
</div>
</div>
</div>
"""
def test_laundry_grid_yields_only_my_upcoming_slots():
label, slots = parse_my_slots(LAUNDRY_HTML)
assert label == "Чт, 15 окт."
assert len(slots) == 2
upcoming = [slot for slot in slots if slot.is_upcoming]
assert len(upcoming) == 1
assert upcoming[0].time_range == "18:00-21:00"
assert upcoming[0].machine == "Машинка 3"
assert upcoming[0].location == "Этаж 1"
def test_laundry_config_requires_a_session_cookie():
from app.sources.base import SourceError
try:
from app.sources.laundry import LaundrySource
LaundrySource(LaundrySourceSettings(enabled=True, session_cookie=""))
except SourceError:
return
raise AssertionError("expected a SourceError")
+71
View File
@@ -0,0 +1,71 @@
"""The Telegram commands — the chat is a remote control, so it needs testing."""
from __future__ import annotations
from app.services import settings_service, tasks_service, telegram_bot
def test_unknown_input_is_ignored(session):
assert telegram_bot.handle_text("hello there") is None
assert telegram_bot.handle_text("/nonsense") is None
def test_help_lists_the_verbs(session):
reply = telegram_bot.handle_text("/help")
assert "/start_work" in reply and "/report" in reply
def test_start_pause_resume_and_status(session):
assert "started" in telegram_bot.handle_text("/start_work 10").lower()
status = telegram_bot.handle_text("/status")
assert "work" in status.lower() or "focus" in status.lower()
assert "Paused" in telegram_bot.handle_text("/pause")
assert "Resumed" in telegram_bot.handle_text("/resume")
assert "Stopped" in telegram_bot.handle_text("/stop")
def test_stop_without_a_timer_explains_itself(session):
assert "No interval is running" in telegram_bot.handle_text("/stop")
def test_today_numbers_tasks_and_done_closes_them(session):
settings_service.load_settings(session)
first = tasks_service.create_local_task(session, title="Alpha task")
tasks_service.create_local_task(session, title="Beta task")
listing = telegram_bot.handle_text("/today")
assert "Alpha task" in listing and "<b>1.</b>" in listing
reply = telegram_bot.handle_text("/done 1")
assert "Alpha task" in reply
session.expire_all()
assert session.get(type(first), first.id).is_done is True
assert "No task with that number" in telegram_bot.handle_text("/done 99")
def test_done_without_a_listing_asks_for_one(session):
telegram_bot._INDEX.clear()
assert "/today" in telegram_bot.handle_text("/done 1")
def test_report_and_streak_render(session):
assert "Daily report" in telegram_bot.handle_text("/report")
assert "Streak" in telegram_bot.handle_text("/streak")
assert "Weekly review" in telegram_bot.handle_text("/week")
def test_block_command_describes_the_policy(session):
settings = settings_service.load_settings(session)
settings.blocker.enabled = True
settings.blocker.blocked_domains = ["youtube.com"]
settings_service.save_settings(session, settings)
reply = telegram_bot.handle_text("/block")
assert "Blocker" in reply and "Domains" in reply
def test_russian_replies_when_configured(session):
settings = settings_service.load_settings(session)
settings.notifications.report.language = "ru"
settings_service.save_settings(session, settings)
assert "помидорку" in telegram_bot.handle_text("/help")
+129
View File
@@ -0,0 +1,129 @@
# Kadence — the native desktop app
Not Electron. **PyQt6 + QML with Qt Quick Controls' Material style**: the window
is a native Wayland/X11 surface drawn by Qt, using the session's own fonts,
scaling and colour scheme. The only dependency is PyQt6, which on Arch is the
system package `python-pyqt6`; everything else — HTTP, config, process scanning —
is the Python standard library.
It is also the piece that *enforces* things, because it is the only part running
on your machine.
```bash
sudo pacman -S --needed python-pyqt6 qt6-declarative # Arch
./packaging/install-desktop.sh # menu entry, icon, launcher
sudo ./helper/install.sh # privileged helper (website blocking)
kadence-desktop
```
Run it straight from the checkout instead: `python3 -m kadence_desktop`.
---
## What "native" buys you
| Feature | How |
| --- | --- |
| **Tray icon that *is* the timer** | `QSystemTrayIcon` (a StatusNotifierItem on KDE). The icon is painted at runtime: a ring that drains as the interval runs, tinted by focus / break / idle, with a pause glyph when held. Middle-click toggles the timer. |
| **Real notifications with buttons** | `org.freedesktop.Notifications` over D-Bus, with actions: *Start break* · *Another pomodoro* · *+5 min*. Replaced in place, so a day of pomodoros never stacks a column of toasts. |
| **Scriptable over D-Bus** | `org.kadence.Desktop` exports `StartWork`, `Toggle`, `Pause`, `Complete`, `Extend`, `Interrupt`, `Status`, `SendReport`, `Raise`. Bind any of them to a global shortcut in System Settings — no key-grabbing hacks. |
| **Screen stays awake** | `org.freedesktop.ScreenSaver.Inhibit` while you focus, released the moment the interval ends. |
| **Do Not Disturb** | Other apps' banners are suppressed during focus (Plasma's `Notifications.Inhibit`; on GNOME the `show-banners` gsetting, put back afterwards). |
| **Your music pauses** | Any MPRIS player (Spotify, VLC, mpv, browsers) is paused when focus starts, and *only the ones we paused* resume on the break. |
| **Taskbar progress** | `com.canonical.Unity.LauncherEntry` — a progress bar under the dock icon and a badge with today's count. |
| **Knows when you walked away** | logind `Lock`/`Unlock` and `PrepareForSleep` pause and resume the timer; optional idle-pause uses KDE's or Mutter's idle monitor. |
| **Starts with the session** | XDG autostart entry, or a **systemd user unit** that restarts on failure and logs to the journal. |
| **Single instance** | Owning the D-Bus name is the lock; a second launch raises the first window and exits. |
| **Launcher actions** | Right-click the icon: *Start a pomodoro*, *Start a break*, *Start / pause*, *Send today's report*. |
Every one of these degrades to a no-op where the desktop does not provide it, so
the same build runs on KDE, GNOME or a bare window manager.
---
## Blocking
The rules (which sites, which apps, when) live on the backend so the web app can
edit them and a second machine obeys the same list. This app enforces them:
* **Websites** — the managed region of `/etc/hosts` is kept in sync with the
policy. Writing it needs root, which a desktop app must not have, so it calls
`kadence-helper` through **pkexec**.
* **Applications** — `/proc` is scanned every few seconds for your own processes
matching the list; they are terminated (SIGTERM, then SIGKILL) or merely
reported, and every attempt is sent to the backend for the evening report.
* Everything is released when the interval ends — and on exit, including a crash,
because `shutdown()` clears the block and the inhibits.
### The privileged helper
`helper/kadence-helper` is the entire privileged surface, and it is deliberately
dull:
* every hostname must match a strict pattern, the redirect must parse as an IP;
* only the region between its own markers is rewritten — your own entries above
and below survive byte for byte;
* the input list is opened `O_NOFOLLOW` and must be a regular file;
* the write is atomic (temp file + `rename`), and the first run backs up
`/etc/hosts` to `/etc/hosts.kadence-backup`;
* `localhost` can never be blocked.
```bash
sudo ./helper/install.sh # polkit asks once per session
sudo ./helper/install.sh --passwordless # or never, for your user only
sudo ./helper/install.sh --uninstall # unblocks first, then removes itself
```
---
## Global shortcuts
System Settings → Shortcuts → Custom Shortcuts → New → D-Bus command:
| Field | Value |
| --- | --- |
| Service | `org.kadence.Desktop` |
| Path | `/Timer` |
| Method | `org.kadence.Timer.Toggle` |
Or bind a command: `kadence-desktop --toggle`. The verbs talk to the running
instance instead of starting a second one:
```bash
kadence-desktop --toggle # start → pause → resume
kadence-desktop --start # start a focus interval
kadence-desktop --break
kadence-desktop --complete
kadence-desktop --status # "Focus 18:42 · Write the docs · 3/8 today"
kadence-desktop --report # send today's report to Telegram
```
In-window shortcuts: **Space** start/pause · **Ctrl+Enter** finish ·
**Ctrl+E** +5 min · **Ctrl+R** refresh sources · **Ctrl+1…5** pages ·
**Ctrl+W** hide to tray.
---
## Configuration
`~/.config/kadence/desktop.json` holds only what is about *this machine* — the
backend URL and token, and which integrations this box may use. Timer lengths,
task sources, block lists and Telegram all live on the server, so they are the
same everywhere. The **Settings** page edits both, clearly separated.
## Layout
```
kadence_desktop/
app.py # QApplication, QML engine, tray, D-Bus, single instance
controller.py # the object QML binds to; wires transitions to integrations
worker.py # the network thread (every HTTP call happens here)
api.py # stdlib HTTP client for the backend
config.py # ~/.config/kadence/desktop.json
tray.py # the painted countdown icon and its menu
integrations/ # notifications, inhibit/DND, MPRIS, launcher, logind, D-Bus, autostart
blocking/ # hosts file, /proc scanning, and the manager that syncs policy
qml/ # Material UI: Timer, Tasks, Blocker, Report, Settings
helper/ # the privileged helper + polkit action + installer
packaging/ # .desktop entry, icon, user installer
```
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# Installs Kadence's privileged helper and its polkit action.
#
# The desktop app itself never runs as root; this one small script is the entire
# privileged surface, and you can read all of it in a minute.
#
# sudo ./install.sh # prompt once per session (recommended)
# sudo ./install.sh --passwordless # no prompt for the invoking user
# sudo ./install.sh --uninstall
set -euo pipefail
HELPER_SRC="$(dirname "$(readlink -f "$0")")/kadence-helper"
POLICY_SRC="$(dirname "$(readlink -f "$0")")/org.kadence.helper.policy"
HELPER_DEST="/usr/local/bin/kadence-helper"
POLICY_DEST="/usr/share/polkit-1/actions/org.kadence.helper.policy"
RULE_DEST="/etc/polkit-1/rules.d/49-kadence.rules"
TARGET_USER="${SUDO_USER:-${USER:-root}}"
PASSWORDLESS=0
UNINSTALL=0
while [[ $# -gt 0 ]]; do
case "$1" in
--passwordless) PASSWORDLESS=1 ;;
--uninstall) UNINSTALL=1 ;;
--user) TARGET_USER="$2"; shift ;;
-h|--help) sed -n '2,12p' "$0"; exit 0 ;;
*) echo "unknown option: $1" >&2; exit 2 ;;
esac
shift
done
if [[ $EUID -ne 0 ]]; then
echo "Run me with sudo." >&2
exit 1
fi
if [[ $UNINSTALL -eq 1 ]]; then
# Unblock first — leaving a block behind after uninstalling would be rude.
[[ -x "$HELPER_DEST" ]] && "$HELPER_DEST" clear || true
rm -f "$HELPER_DEST" "$POLICY_DEST" "$RULE_DEST"
echo "Removed the helper, its polkit action and any passwordless rule."
exit 0
fi
install -m 0755 -o root -g root "$HELPER_SRC" "$HELPER_DEST"
install -m 0644 -o root -g root "$POLICY_SRC" "$POLICY_DEST"
echo "Installed $HELPER_DEST and its polkit action."
if [[ $PASSWORDLESS -eq 1 ]]; then
mkdir -p "$(dirname "$RULE_DEST")"
cat > "$RULE_DEST" <<RULE
// Lets $TARGET_USER manage the Kadence block list without a password prompt.
// Scope: this one action, this one user, this one helper binary.
polkit.addRule(function (action, subject) {
if (action.id == "org.kadence.helper.manage-hosts" &&
subject.user == "$TARGET_USER" && subject.local && subject.active) {
return polkit.Result.YES;
}
});
RULE
chmod 0644 "$RULE_DEST"
echo "Passwordless blocking enabled for '$TARGET_USER' ($RULE_DEST)."
echo "Remove that file to go back to prompting."
fi
echo
echo "Check it works: $HELPER_DEST status"
+211
View File
@@ -0,0 +1,211 @@
#!/usr/bin/env python3
"""Kadence's privileged helper: the only part that runs as root.
It does exactly one thing — maintain a marked region inside `/etc/hosts` — and
it is written to be boring on purpose:
* it never trusts its input: every hostname is matched against a strict pattern
and the redirect address must parse as an IP, so nothing else can be smuggled
into the file;
* it only ever rewrites the region between its own markers, so hand-written
entries above and below are preserved byte for byte;
* the input list is opened with `O_NOFOLLOW` and must be a regular file, which
closes the "point the helper at a symlink" trick;
* the write is atomic (temp file in `/etc` + `rename`), so an interrupted run
cannot leave the machine with half a hosts file;
* the first run keeps a backup at `/etc/hosts.kadence-backup`.
Installed by `install.sh` together with a polkit action, so the desktop app can
call it through `pkexec` without ever running as root itself.
Usage:
kadence-helper apply --ip 127.0.0.1 --file /tmp/list
kadence-helper clear
kadence-helper status
"""
from __future__ import annotations
import argparse
import ipaddress
import os
import re
import shutil
import stat as stat_module
import subprocess
import sys
import tempfile
HOSTS_PATH = "/etc/hosts"
BACKUP_PATH = "/etc/hosts.kadence-backup"
BEGIN_MARKER = "# >>> kadence block >>>"
END_MARKER = "# <<< kadence block <<<"
MAX_DOMAINS = 5000
HOSTNAME_RE = re.compile(r"^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$")
# Blocking these would break the machine, not your concentration.
FORBIDDEN = {"localhost", "localhost.localdomain"}
def fail(message: str) -> None:
print(f"kadence-helper: {message}", file=sys.stderr)
raise SystemExit(2)
def read_hosts() -> list[str]:
try:
with open(HOSTS_PATH, "r", encoding="utf-8") as handle:
return handle.read().splitlines()
except OSError as exc:
fail(f"cannot read {HOSTS_PATH}: {exc}")
return []
def strip_region(lines: list[str]) -> list[str]:
"""Everything except our managed region (and a stale, unterminated one)."""
output: list[str] = []
inside = False
for line in lines:
stripped = line.strip()
if stripped == BEGIN_MARKER:
inside = True
continue
if stripped == END_MARKER:
inside = False
continue
if not inside:
output.append(line)
while output and not output[-1].strip():
output.pop()
return output
def write_hosts(lines: list[str]) -> None:
if not os.path.exists(BACKUP_PATH):
try:
shutil.copy2(HOSTS_PATH, BACKUP_PATH)
except OSError:
pass # a missing backup must not stop us unblocking later
directory = os.path.dirname(HOSTS_PATH) or "/"
descriptor, temp_path = tempfile.mkstemp(dir=directory, prefix=".hosts.kadence.")
try:
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
handle.write("\n".join(lines) + "\n")
os.chmod(temp_path, 0o644)
os.chown(temp_path, 0, 0)
os.replace(temp_path, HOSTS_PATH)
except OSError as exc:
os.unlink(temp_path)
fail(f"cannot write {HOSTS_PATH}: {exc}")
def flush_dns() -> None:
"""Best effort: a resolver cache would otherwise keep serving the old answer."""
for command in (
["resolvectl", "flush-caches"],
["systemd-resolve", "--flush-caches"],
["nscd", "-i", "hosts"],
):
if shutil.which(command[0]):
subprocess.run(command, check=False, capture_output=True, timeout=10)
def load_domains(path: str) -> list[str]:
"""Read the list the client wrote, refusing anything surprising."""
try:
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
except OSError as exc:
fail(f"cannot open the domain list: {exc}")
return []
with os.fdopen(descriptor, "r", encoding="utf-8", errors="replace") as handle:
info = os.fstat(handle.fileno())
if not stat_module.S_ISREG(info.st_mode):
fail("the domain list must be a regular file")
if info.st_size > 1_000_000:
fail("the domain list is implausibly large")
raw = handle.read()
domains: list[str] = []
for line in raw.splitlines():
name = line.strip().lower()
if not name or name.startswith("#"):
continue
if name in FORBIDDEN:
continue
if not HOSTNAME_RE.match(name):
fail(f"refusing a hostname that does not look like one: {name[:80]!r}")
if name not in domains:
domains.append(name)
if len(domains) > MAX_DOMAINS:
fail(f"more than {MAX_DOMAINS} domains")
return domains
def command_apply(ip: str, path: str) -> None:
try:
address = ipaddress.ip_address(ip)
except ValueError:
fail(f"not an IP address: {ip!r}")
return
domains = load_domains(path)
lines = strip_region(read_hosts())
if domains:
lines.append("")
lines.append(BEGIN_MARKER)
lines.append("# Managed by Kadence. Edit the list in the app, not here.")
for domain in domains:
lines.append(f"{address}\t{domain}")
lines.append(END_MARKER)
write_hosts(lines)
flush_dns()
print(f"blocking {len(domains)} domain(s) -> {address}")
def command_clear() -> None:
write_hosts(strip_region(read_hosts()))
flush_dns()
print("cleared")
def command_status() -> None:
inside = False
count = 0
for line in read_hosts():
stripped = line.strip()
if stripped == BEGIN_MARKER:
inside = True
continue
if stripped == END_MARKER:
inside = False
continue
if inside and stripped and not stripped.startswith("#"):
count += 1
print(f"{count} domain(s) blocked")
def main() -> int:
parser = argparse.ArgumentParser(prog="kadence-helper", description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)
apply_parser = sub.add_parser("apply", help="replace the managed block")
apply_parser.add_argument("--ip", default="127.0.0.1")
apply_parser.add_argument("--file", required=True, help="newline-separated hostnames")
sub.add_parser("clear", help="remove the managed block")
sub.add_parser("status", help="how many domains are blocked")
args = parser.parse_args()
if os.geteuid() != 0:
fail("must run as root (it is meant to be called through pkexec)")
if args.command == "apply":
command_apply(args.ip, args.file)
elif args.command == "clear":
command_clear()
else:
command_status()
return 0
if __name__ == "__main__":
raise SystemExit(main())
+31
View File
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE policyconfig PUBLIC
"-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
"http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
<!--
Lets a logged-in user run Kadence's helper through pkexec.
The default is `auth_admin_keep`: you authenticate once and the grant is kept
for the rest of the session, so a day of pomodoros is one password prompt, not
twenty. install.sh can optionally drop in a rule that skips the prompt
entirely for one user.
-->
<policyconfig>
<vendor>Kadence</vendor>
<vendor_url>https://github.com/</vendor_url>
<action id="org.kadence.helper.manage-hosts">
<description>Manage the Kadence website block list</description>
<description xml:lang="ru">Управление списком заблокированных сайтов Kadence</description>
<message>Authentication is required to change which websites are blocked.</message>
<message xml:lang="ru">Для изменения списка заблокированных сайтов требуется аутентификация.</message>
<icon_name>kadence</icon_name>
<defaults>
<allow_any>auth_admin</allow_any>
<allow_inactive>auth_admin</allow_inactive>
<allow_active>auth_admin_keep</allow_active>
</defaults>
<annotate key="org.freedesktop.policykit.exec.path">/usr/local/bin/kadence-helper</annotate>
<annotate key="org.freedesktop.policykit.exec.allow_gui">true</annotate>
</action>
</policyconfig>
+4
View File
@@ -0,0 +1,4 @@
"""Native desktop client for Kadence (PyQt6 + QML, Material style)."""
__version__ = "0.1.0"
APP_ID = "org.kadence.Desktop"
+84
View File
@@ -0,0 +1,84 @@
"""Entry point.
Run with no arguments to open the app. The verbs (`--toggle`, `--status`, …)
talk to a *running* instance over D-Bus instead of starting a second one, which
is what makes them usable as global shortcuts:
kadence-desktop --toggle # bind this to a key in System Settings
kadence-desktop --status # prints "Focus 18:42 · Write the docs · 3/8 today"
"""
from __future__ import annotations
import argparse
import logging
import os
import sys
REMOTE_VERBS = {
"toggle": "Toggle",
"start": "StartWork",
"break": "StartBreak",
"pause": "Pause",
"resume": "Resume",
"complete": "Complete",
"stop": "Stop",
"extend": "Extend",
"interrupt": "Interrupt",
"status": "Status",
"report": "SendReport",
"show": "Raise",
}
def _remote(verb: str) -> int:
"""Call the running instance. Returns a process exit code."""
from PyQt6.QtCore import QCoreApplication
from PyQt6.QtDBus import QDBusConnection, QDBusInterface
_app = QCoreApplication(sys.argv) # noqa: F841 - needed for the D-Bus loop
interface = QDBusInterface(
"org.kadence.Desktop", "/Timer", "org.kadence.Timer", QDBusConnection.sessionBus()
)
if not interface.isValid():
print("Kadence is not running.", file=sys.stderr)
return 1
reply = interface.call(REMOTE_VERBS[verb])
arguments = reply.arguments()
if arguments:
print(arguments[0])
return 0
def main() -> int:
parser = argparse.ArgumentParser(prog="kadence-desktop", description=__doc__)
parser.add_argument("--minimized", action="store_true", help="start in the tray only")
parser.add_argument("--verbose", action="store_true")
for verb in REMOTE_VERBS:
parser.add_argument(
f"--{verb}", action="store_true", help=f"ask the running instance to {verb}"
)
args = parser.parse_args()
logging.basicConfig(
level=logging.DEBUG if args.verbose else os.environ.get("KADENCE_LOG", "INFO").upper(),
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
)
for verb in REMOTE_VERBS:
if getattr(args, verb):
return _remote(verb)
from kadence_desktop.app import KadenceApp
from kadence_desktop.integrations.dbus_service import call_existing
# Single instance: if somebody already owns the name, just raise their window.
if call_existing("Raise"):
print("Kadence is already running — raised its window.")
return 0
return KadenceApp(sys.argv, minimized=args.minimized).run()
if __name__ == "__main__":
raise SystemExit(main())
+151
View File
@@ -0,0 +1,151 @@
"""HTTP client for the backend.
Deliberately stdlib-only (`urllib`): the desktop app should start with the
system Python on a fresh machine, and a pomodoro client does not need an async
HTTP stack to call eight endpoints.
"""
from __future__ import annotations
import json
import urllib.error
import urllib.parse
import urllib.request
from typing import Any
class ApiError(RuntimeError):
"""A call failed. The message is meant to be shown in the UI."""
def __init__(self, message: str, status: int = 0) -> None:
super().__init__(message)
self.status = status
class Api:
def __init__(self, base_url: str, token: str = "", timeout: float = 10.0) -> None:
self.base_url = base_url.rstrip("/")
self.token = token
self.timeout = timeout
def configure(self, base_url: str, token: str) -> None:
self.base_url = base_url.rstrip("/")
self.token = token
# ---- plumbing ----------------------------------------------------------
def _request(self, method: str, path: str, payload: Any | None = None) -> Any:
url = f"{self.base_url}{path}"
data = None
headers = {"Accept": "application/json"}
if payload is not None:
data = json.dumps(payload).encode("utf-8")
headers["Content-Type"] = "application/json"
if self.token:
headers["X-API-Token"] = self.token
request = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(request, timeout=self.timeout) as response:
body = response.read()
return json.loads(body) if body else None
except urllib.error.HTTPError as exc:
detail = _detail(exc)
raise ApiError(detail, exc.code) from exc
except (urllib.error.URLError, TimeoutError) as exc:
raise ApiError(
f"Cannot reach the backend at {self.base_url} ({exc}). "
"Is `docker compose up` running?"
) from exc
except json.JSONDecodeError as exc:
raise ApiError(f"The backend sent something that is not JSON: {exc}") from exc
# ---- endpoints ---------------------------------------------------------
def health(self) -> dict:
return self._request("GET", "/api/health")
def timer_state(self) -> dict:
return self._request("GET", "/api/timer/state")
def start(self, kind: str | None = None, task_id: int | None = None, minutes: int | None = None) -> dict:
return self._request(
"POST", "/api/timer/start", {"kind": kind, "task_id": task_id, "minutes": minutes}
)
def pause(self) -> dict:
return self._request("POST", "/api/timer/pause", {})
def resume(self) -> dict:
return self._request("POST", "/api/timer/resume", {})
def complete(self, auto_next: bool = True) -> dict:
return self._request("POST", "/api/timer/complete", {"auto_next": auto_next})
def abandon(self, reason: str | None = None) -> dict:
return self._request("POST", "/api/timer/abandon", {"reason": reason})
def extend(self, minutes: int = 5) -> dict:
return self._request("POST", "/api/timer/extend", {"minutes": minutes})
def interrupt(self) -> dict:
return self._request("POST", "/api/timer/interrupt", {})
def attach_task(self, task_id: int | None) -> dict:
return self._request("POST", "/api/timer/task", {"task_id": task_id})
def tasks(self) -> dict:
return self._request("GET", "/api/tasks")
def refresh_tasks(self) -> dict:
return self._request("POST", "/api/tasks/refresh", {})
def task_done(self, task_id: int, done: bool = True) -> dict:
return self._request("POST", f"/api/tasks/{task_id}/done", {"done": done})
def create_task(self, title: str, due_date: str | None = None) -> dict:
return self._request("POST", "/api/tasks", {"title": title, "due_date": due_date})
def blocker_policy(self) -> dict:
return self._request("GET", "/api/blocker/policy")
def blocker_events(self) -> dict:
return self._request("GET", "/api/blocker/events")
def report_block(self, kind: str, target: str, action: str = "blocked", details: str | None = None) -> dict:
return self._request(
"POST",
"/api/blocker/events",
{"kind": kind, "target": target, "action": action, "details": details},
)
def daily_report(self) -> dict:
return self._request("GET", "/api/reports/daily")
def send_report(self) -> dict:
return self._request("POST", "/api/reports/daily/send", {"force": True})
def stats(self) -> dict:
return self._request("GET", "/api/stats/summary?days=30")
def settings(self) -> dict:
return self._request("GET", "/api/settings")
def patch_settings(self, patch: dict) -> dict:
return self._request("PUT", "/api/settings", patch)
def _detail(exc: urllib.error.HTTPError) -> str:
try:
body = json.loads(exc.read())
except Exception:
return f"{exc.code} {exc.reason}"
detail = body.get("detail") if isinstance(body, dict) else None
if isinstance(detail, str):
return detail
if isinstance(detail, list):
return "; ".join(
f"{'.'.join(str(part) for part in item.get('loc', [])[1:])}: {item.get('msg', '')}"
for item in detail
)
return f"{exc.code} {exc.reason}"
+146
View File
@@ -0,0 +1,146 @@
"""Application wiring: Qt, QML, the tray, and the D-Bus service.
Qt Quick Controls' **Material** style gives real Material 3 widgets rendered by
Qt itself — no web view, no bundled browser; the window is a native Wayland
surface with the session's own fonts, scaling and colour scheme.
"""
from __future__ import annotations
import logging
import os
import signal
from pathlib import Path
# The style has to be chosen before the first Qt object exists. PyQt6 on Arch
# ships no QtQuickControls2 binding, so the environment variable — which Qt
# itself reads — is the portable way to ask for Material.
os.environ.setdefault("QT_QUICK_CONTROLS_STYLE", "Material")
os.environ.setdefault("QT_QUICK_CONTROLS_MATERIAL_VARIANT", "Normal")
from PyQt6.QtCore import QObject, QTimer, QUrl
from PyQt6.QtGui import QIcon
from PyQt6.QtQml import QQmlApplicationEngine
from PyQt6.QtQuick import QQuickWindow
from PyQt6.QtWidgets import QApplication, QSystemTrayIcon
from kadence_desktop import APP_ID, __version__
from kadence_desktop.config import DesktopConfig
from kadence_desktop.controller import Controller
from kadence_desktop.integrations import dbus_service
from kadence_desktop.tray import Tray, render_icon
log = logging.getLogger(__name__)
QML_DIR = Path(__file__).resolve().parent / "qml"
class KadenceApp(QObject):
def __init__(self, argv: list[str], *, minimized: bool = False) -> None:
super().__init__()
# QApplication (not QGuiApplication): the tray icon and its menu are
# widgets, and they are what makes the app usable with no window open.
self._app = QApplication(argv)
self._app.setApplicationName("Kadence")
self._app.setApplicationDisplayName("Kadence")
self._app.setOrganizationName("Kadence")
self._app.setApplicationVersion(__version__)
# Wayland takes the window's app id from here: without it the window
# has no icon and the task manager cannot group it.
self._app.setDesktopFileName("kadence")
self._app.setWindowIcon(_load_icon())
self._app.setQuitOnLastWindowClosed(False)
self._config = DesktopConfig.load()
self._controller = Controller(self._config)
self._engine = QQmlApplicationEngine()
self._engine.rootContext().setContextProperty("controller", self._controller)
self._engine.rootContext().setContextProperty("appVersion", __version__)
self._engine.addImportPath(str(QML_DIR))
self._engine.load(QUrl.fromLocalFile(str(QML_DIR / "Main.qml")))
if not self._engine.rootObjects():
raise SystemExit("Could not load the QML interface.")
self._window: QQuickWindow = self._engine.rootObjects()[0]
self._tray: Tray | None = None
if self._config.tray_icon and QSystemTrayIcon.isSystemTrayAvailable():
self._tray = Tray(self._controller, self)
self._tray.showRequested.connect(self.toggle_window)
self._tray.quitRequested.connect(self.quit)
self._tray.show()
self._controller.showWindowRequested.connect(self.show_window)
self._app.aboutToQuit.connect(self._controller.shutdown)
if self._config.dbus_service:
self._register_dbus()
if minimized or self._config.start_minimized:
self._window.hide()
else:
self.show_window()
# Let Ctrl+C in a terminal actually kill the app (Qt swallows SIGINT
# unless the event loop gets a chance to run Python code now and then).
signal.signal(signal.SIGINT, lambda *_args: self.quit())
self._wakeup = QTimer(self)
self._wakeup.start(500)
self._wakeup.timeout.connect(lambda: None)
# ---- window ------------------------------------------------------------
def show_window(self) -> None:
self._window.show()
self._window.raise_()
self._window.requestActivate()
def toggle_window(self) -> None:
if self._window.isVisible() and self._window.isActive():
self._window.hide()
else:
self.show_window()
# ---- D-Bus -------------------------------------------------------------
def _register_dbus(self) -> None:
controller = self._controller
actions = {
"start_work": lambda: (controller.startWork(), "started")[1],
"start_break": lambda: (controller.startBreak(), "break started")[1],
"toggle": lambda: (controller.toggle(), "toggled")[1],
"pause": lambda: (controller.pause(), "paused")[1],
"resume": lambda: (controller.resume(), "resumed")[1],
"complete": lambda: (controller.complete(), "completed")[1],
"stop": lambda: (controller.abandon("stopped over D-Bus"), "stopped")[1],
"extend": lambda: (controller.extend(5), "extended")[1],
"interrupt": lambda: (controller.interrupt(), "noted")[1],
"status": controller.statusLine,
"send_report": lambda: (controller.sendReport(), "sending")[1],
"raise_window": lambda: (self.show_window(), "shown")[1],
}
self._dbus = dbus_service.TimerDBusService(actions, self)
if not dbus_service.register(self._dbus):
log.warning("another instance owns %s — this one will not be scriptable", APP_ID)
# ---- lifecycle ---------------------------------------------------------
def quit(self) -> None:
self._controller.shutdown()
if self._tray:
self._tray.hide()
self._app.quit()
def run(self) -> int:
return self._app.exec()
def _load_icon() -> QIcon:
"""The packaged icon if it is installed, else the one the tray paints."""
themed = QIcon.fromTheme("kadence")
if not themed.isNull():
return themed
packaged = Path(__file__).resolve().parent.parent / "packaging" / "kadence.svg"
if packaged.is_file():
return QIcon(str(packaged))
return render_icon(0.0, "work", False, True, False)
@@ -0,0 +1,6 @@
"""Enforcement: the part that actually stands between you and the distraction.
The *rules* live on the server (so the web UI can edit them and a second machine
obeys the same list); this package is what turns a rule into a redirected
hostname and a terminated process on this particular computer.
"""
+146
View File
@@ -0,0 +1,146 @@
"""Website blocking through `/etc/hosts`.
Why the hosts file rather than a proxy or an extension: it needs no browser
support, survives a browser restart, covers every app on the machine, and is
trivially auditable — you can read exactly what is blocked with `cat`.
Writing it needs root, which a desktop app must not have, so all writes go
through the small privileged helper (`kadence-helper`) under polkit. The helper
only ever rewrites the region between its own markers and validates every
hostname, so the worst a compromised client can do is block a site.
"""
from __future__ import annotations
import logging
import os
import re
import shutil
import subprocess
import tempfile
log = logging.getLogger(__name__)
HOSTS_PATH = "/etc/hosts"
BEGIN_MARKER = "# >>> kadence block >>>"
END_MARKER = "# <<< kadence block <<<"
_HOSTNAME_RE = re.compile(r"^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$")
def valid_hostname(name: str) -> bool:
return bool(_HOSTNAME_RE.match(name.strip().lower()))
def current_block() -> list[str]:
"""The hostnames currently inside our managed region of `/etc/hosts`."""
try:
with open(HOSTS_PATH, "r", encoding="utf-8") as handle:
lines = handle.read().splitlines()
except OSError:
return []
inside = False
hosts: list[str] = []
for line in lines:
if line.strip() == BEGIN_MARKER:
inside = True
continue
if line.strip() == END_MARKER:
inside = False
continue
if inside:
parts = line.split()
if len(parts) >= 2 and not line.lstrip().startswith("#"):
hosts.extend(parts[1:])
return hosts
class HostsBlocker:
"""Applies and clears the managed block, as rarely as possible.
Every change costs a polkit prompt in the worst case, so the desired state is
diffed against what is already in the file and a no-op is really a no-op.
"""
def __init__(self, helper_path: str = "/usr/local/bin/kadence-helper") -> None:
self.helper_path = helper_path
self._last_error: str | None = None
# ---- availability ------------------------------------------------------
@property
def helper_installed(self) -> bool:
return os.path.isfile(self.helper_path) and os.access(self.helper_path, os.X_OK)
@property
def last_error(self) -> str | None:
return self._last_error
def _runner(self) -> list[str] | None:
"""How to become root: polkit if we can, passwordless sudo otherwise."""
if not self.helper_installed:
self._last_error = (
f"The privileged helper is not installed at {self.helper_path}. "
"Run desktop/helper/install.sh once."
)
return None
if shutil.which("pkexec") and os.environ.get("DISPLAY", os.environ.get("WAYLAND_DISPLAY")):
return ["pkexec", self.helper_path]
if shutil.which("sudo"):
return ["sudo", "-n", self.helper_path]
self._last_error = "Neither pkexec nor sudo is available to run the helper."
return None
# ---- operations --------------------------------------------------------
def apply(self, domains: list[str], redirect_ip: str = "127.0.0.1") -> bool:
"""Make the managed region exactly `domains`. True when it already was."""
wanted = sorted({d.strip().lower() for d in domains if valid_hostname(d)})
if sorted(set(current_block())) == wanted:
return True
if not wanted:
return self.clear()
runner = self._runner()
if runner is None:
return False
with tempfile.NamedTemporaryFile("w", suffix=".kadence", delete=False, encoding="utf-8") as handle:
handle.write("\n".join(wanted))
list_path = handle.name
os.chmod(list_path, 0o644) # the helper reads it as root from /tmp
try:
return self._run(runner + ["apply", "--ip", redirect_ip, "--file", list_path])
finally:
try:
os.unlink(list_path)
except OSError:
pass
def clear(self) -> bool:
"""Remove the managed region. Safe to call when it is not there."""
if not current_block():
return True
runner = self._runner()
if runner is None:
return False
return self._run(runner + ["clear"])
def _run(self, command: list[str]) -> bool:
try:
result = subprocess.run(command, capture_output=True, text=True, timeout=60, check=False)
except (OSError, subprocess.SubprocessError) as exc:
self._last_error = f"Could not run the helper: {exc}"
log.warning(self._last_error)
return False
if result.returncode != 0:
detail = (result.stderr or result.stdout).strip()[:300]
# 126/127 is polkit's "you said no" — worth saying plainly.
if result.returncode in (126, 127):
detail = detail or "the authorisation prompt was dismissed"
self._last_error = f"Helper failed: {detail}"
log.warning(self._last_error)
return False
self._last_error = None
return True
+109
View File
@@ -0,0 +1,109 @@
"""Turning the server's policy into what this machine actually does.
Called on every poll with the policy the backend computed. It keeps the hosts
file and the process list in step with it, reports every turned-away attempt
back to the API (that is what makes the daily report fun), and — importantly —
puts everything back the moment the policy says blocking is off, including when
the app exits or crashes mid-interval.
"""
from __future__ import annotations
import logging
from typing import Callable
from kadence_desktop.blocking import processes
from kadence_desktop.blocking.hosts import HostsBlocker
log = logging.getLogger(__name__)
class BlockerManager:
def __init__(
self,
*,
helper_path: str,
report: Callable[[str, str, str, str | None], None],
notify: Callable[[str, str], None] | None = None,
) -> None:
self._hosts = HostsBlocker(helper_path)
self._report = report
self._notify = notify
self._active = False
self._applied: list[str] = []
self._announced: set[int] = set()
self.enforce_hosts = True
self.enforce_apps = True
self.last_error: str | None = None
self.killed_today = 0
# ---- state -------------------------------------------------------------
@property
def active(self) -> bool:
return self._active
@property
def helper_installed(self) -> bool:
return self._hosts.helper_installed
@property
def blocked_domains(self) -> list[str]:
return list(self._applied)
# ---- the one entry point ----------------------------------------------
def sync(self, policy: dict) -> None:
"""Bring the machine in line with `policy` (from /api/blocker/policy)."""
should_block = bool(policy.get("active")) and bool(policy.get("enabled"))
domains = [str(item) for item in policy.get("domains", [])]
apps = [str(item) for item in policy.get("apps", [])]
redirect_ip = str(policy.get("redirect_ip") or "127.0.0.1")
if should_block:
if self.enforce_hosts and domains != self._applied:
if self._hosts.apply(domains, redirect_ip):
self._applied = domains
self.last_error = None
log.info("blocking %s domain(s)", len(domains))
else:
self.last_error = self._hosts.last_error
if self.enforce_apps and apps:
self._enforce_apps(apps, kill=bool(policy.get("kill_apps", True)))
self._active = True
else:
self.release()
def release(self) -> None:
"""Unblock everything. Called when the interval ends, and on shutdown."""
if self._applied or self._hosts_dirty():
if self._hosts.clear():
self._applied = []
self.last_error = None
else:
self.last_error = self._hosts.last_error
self._active = False
self._announced.clear()
def _hosts_dirty(self) -> bool:
from kadence_desktop.blocking.hosts import current_block
return bool(current_block())
# ---- apps --------------------------------------------------------------
def _enforce_apps(self, patterns: list[str], *, kill: bool) -> None:
for app in processes.scan(patterns):
if app.pid in self._announced:
continue
self._announced.add(app.pid)
if kill and processes.terminate(app):
self.killed_today += 1
self._report("app", app.matched, "killed", f"{app.name} (pid {app.pid})")
if self._notify:
self._notify("Closed a distraction", f"{app.name} is blocked during focus.")
else:
self._report("app", app.matched, "warned", f"{app.name} (pid {app.pid})")
if self._notify:
self._notify("That app is blocked", f"{app.name} is on your block list.")
@@ -0,0 +1,102 @@
"""Application blocking by watching `/proc`.
No root needed: these are your own processes. The scan reads `/proc/*/comm` and
the cmdline, matches against the configured names, and either terminates the
process (SIGTERM, then SIGKILL if it ignores us) or just reports it, depending
on the setting.
Matching is deliberately forgiving — `steam` should catch `steamwebhelper`, and
`telegram` should catch `Telegram` — but it never matches ourselves, PID 1, or
anything outside the current user's processes.
"""
from __future__ import annotations
import logging
import os
import signal
import time
from dataclasses import dataclass
from pathlib import Path
log = logging.getLogger(__name__)
_SELF_MARKERS = ("kadence", "python3 -m kadence_desktop")
@dataclass(slots=True)
class RunningApp:
pid: int
name: str
cmdline: str
matched: str
def _read(path: Path) -> str:
try:
return path.read_text("utf-8", errors="replace")
except OSError:
return ""
def scan(patterns: list[str]) -> list[RunningApp]:
"""Every live process of this user matching one of `patterns`."""
wanted = [pattern.strip().lower() for pattern in patterns if pattern.strip()]
if not wanted:
return []
uid = os.getuid()
self_pid = os.getpid()
found: list[RunningApp] = []
for entry in Path("/proc").iterdir():
if not entry.name.isdigit():
continue
pid = int(entry.name)
if pid in (1, self_pid):
continue
try:
if entry.stat().st_uid != uid:
continue
except OSError:
continue
comm = _read(entry / "comm").strip()
if not comm:
continue
cmdline = _read(entry / "cmdline").replace("\x00", " ").strip()
haystack = f"{comm} {cmdline}".lower()
if any(marker in haystack for marker in _SELF_MARKERS):
continue
match = next((pattern for pattern in wanted if pattern in haystack), None)
if match:
found.append(RunningApp(pid=pid, name=comm, cmdline=cmdline[:200], matched=match))
return found
def terminate(app: RunningApp, *, grace_seconds: float = 2.0) -> bool:
"""Ask the process to quit, then insist. True when it is gone."""
try:
os.kill(app.pid, signal.SIGTERM)
except ProcessLookupError:
return True
except PermissionError:
log.warning("not allowed to stop %s (pid %s)", app.name, app.pid)
return False
deadline = time.monotonic() + grace_seconds
while time.monotonic() < deadline:
try:
os.kill(app.pid, 0)
except ProcessLookupError:
return True
time.sleep(0.1)
try:
os.kill(app.pid, signal.SIGKILL)
except ProcessLookupError:
return True
except PermissionError:
return False
return True
+97
View File
@@ -0,0 +1,97 @@
"""Desktop-local configuration.
Only what is *about this machine* lives here — where the backend is, and which
system integrations this box may use. Everything else (timer lengths, block
lists, Telegram) is server-side, so the phone, the web UI and this app always
agree.
Stored as JSON in `$XDG_CONFIG_HOME/kadence/desktop.json`.
"""
from __future__ import annotations
import json
import os
from dataclasses import asdict, dataclass, field
from pathlib import Path
def config_dir() -> Path:
base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
path = Path(base) / "kadence"
path.mkdir(parents=True, exist_ok=True)
return path
CONFIG_PATH = config_dir() / "desktop.json"
@dataclass
class DesktopConfig:
"""What this machine needs to know."""
server_url: str = "http://localhost:8000"
api_token: str = ""
poll_seconds: int = 5
"""How often to ask the backend for the timer state and the block policy."""
start_minimized: bool = False
close_to_tray: bool = True
# ---- system integration (each one is a real desktop feature) -----------
tray_icon: bool = True
notifications: bool = True
notification_actions: bool = True
"""Buttons on the notification itself: "Start break", "Skip", "+5 min"."""
inhibit_screensaver: bool = True
"""Keep the screen awake while a work interval runs."""
do_not_disturb: bool = True
"""Silence other apps' notifications during a work interval."""
taskbar_progress: bool = True
"""Progress on the dock/taskbar icon (Unity LauncherEntry — KDE honours it)."""
pause_media: bool = True
"""Pause whatever is playing (MPRIS) when focus starts."""
pause_on_lock: bool = True
"""Pause the timer when the session locks, resume when you come back."""
pause_on_idle_minutes: int = 0
"""Pause after this many idle minutes. 0 = never."""
dbus_service: bool = True
"""Expose org.kadence.Desktop so shortcuts and scripts can drive it."""
# ---- blocking (enforcement; the rules themselves are server-side) ------
enforce_blocking: bool = True
block_hosts_file: bool = True
block_apps: bool = True
helper_path: str = "/usr/local/bin/kadence-helper"
window: dict = field(default_factory=lambda: {"width": 1100, "height": 760})
@classmethod
def load(cls) -> "DesktopConfig":
if not CONFIG_PATH.exists():
config = cls()
config.save()
return config
try:
data = json.loads(CONFIG_PATH.read_text("utf-8"))
except (OSError, json.JSONDecodeError):
return cls()
known = {key: value for key, value in data.items() if key in cls.__dataclass_fields__}
return cls(**known)
def save(self) -> None:
CONFIG_PATH.write_text(json.dumps(asdict(self), indent=2), "utf-8")
def update(self, **changes: object) -> None:
for key, value in changes.items():
if key in self.__dataclass_fields__:
setattr(self, key, value)
self.save()
+639
View File
@@ -0,0 +1,639 @@
"""The object QML talks to, and the place the desktop integrations are wired up.
It holds the last known state from the server, ticks the countdown locally
between polls, and reacts to the *transitions* — a focus interval starting is
what inhibits the screensaver, silences notifications, pauses your music and
turns the blocker on; it ending is what gives all of that back.
"""
from __future__ import annotations
import logging
from typing import Any
from PyQt6.QtCore import (
QObject,
QThread,
QTimer,
QUrl,
pyqtProperty,
pyqtSignal,
pyqtSlot,
)
from PyQt6.QtGui import QDesktopServices
from kadence_desktop.api import Api
from kadence_desktop.blocking.manager import BlockerManager
from kadence_desktop.config import DesktopConfig
from kadence_desktop.integrations import autostart
from kadence_desktop.integrations.inhibit import DoNotDisturb, ScreensaverInhibitor
from kadence_desktop.integrations.launcher import LauncherEntry
from kadence_desktop.integrations.mpris import MediaController
from kadence_desktop.integrations.notifications import URGENCY_CRITICAL, Notifier
from kadence_desktop.integrations.session import SessionWatcher
from kadence_desktop.worker import ApiWorker
log = logging.getLogger(__name__)
KIND_LABELS = {"work": "Focus", "short_break": "Short break", "long_break": "Long break"}
def clock(seconds: int) -> str:
seconds = max(0, int(seconds))
return f"{seconds // 60:02d}:{seconds % 60:02d}"
class Controller(QObject):
# ---- notifications to QML ---------------------------------------------
stateChanged = pyqtSignal()
tasksChanged = pyqtSignal()
statsChanged = pyqtSignal()
reportChanged = pyqtSignal()
settingsChanged = pyqtSignal()
blockerChanged = pyqtSignal()
connectionChanged = pyqtSignal()
configChanged = pyqtSignal()
toast = pyqtSignal(str)
showWindowRequested = pyqtSignal()
def __init__(self, config: DesktopConfig, parent: QObject | None = None) -> None:
super().__init__(parent)
self._config = config
self._api = Api(config.server_url, config.api_token)
# ---- state mirrors --------------------------------------------------
self._state: dict[str, Any] = {}
self._policy: dict[str, Any] = {}
self._tasks: list[dict[str, Any]] = []
self._task_counts: dict[str, Any] = {}
self._stats: dict[str, Any] = {}
self._report: dict[str, Any] = {}
self._settings: dict[str, Any] = {}
self._connected = False
self._connection_error = ""
self._remaining = 0
self._last_session_id: int | None = None
self._last_kind = ""
self._last_status = ""
self._completing = False
self._paused_by_lock = False
# ---- system integrations -------------------------------------------
self._notifier = Notifier()
self._screensaver = ScreensaverInhibitor()
self._dnd = DoNotDisturb()
self._launcher = LauncherEntry()
self._media = MediaController()
self._session = SessionWatcher(self)
self._session.locked.connect(self._on_session_locked)
self._session.unlocked.connect(self._on_session_unlocked)
self._session.sleeping.connect(self._on_session_locked)
self._blocker = BlockerManager(
helper_path=config.helper_path,
report=self._report_block,
notify=self._blocker_notice,
)
self._blocker.enforce_hosts = config.block_hosts_file
self._blocker.enforce_apps = config.block_apps
# ---- network thread -------------------------------------------------
self._thread = QThread(self)
self._worker = ApiWorker(self._api, config.poll_seconds)
self._worker.moveToThread(self._thread)
self._thread.started.connect(self._worker.start)
self._worker.stateChanged.connect(self._on_state)
self._worker.policyChanged.connect(self._on_policy)
self._worker.tasksChanged.connect(self._on_tasks)
self._worker.statsChanged.connect(self._on_stats)
self._worker.reportChanged.connect(self._on_report)
self._worker.settingsChanged.connect(self._on_settings)
self._worker.connectionChanged.connect(self._on_connection)
self._worker.commandFailed.connect(self._on_command_failed)
self._worker.commandDone.connect(self._on_command_done)
self._thread.start()
# ---- local clock ----------------------------------------------------
self._tick = QTimer(self)
self._tick.setInterval(1000)
self._tick.timeout.connect(self._on_tick)
self._tick.start()
self._idle_check = QTimer(self)
self._idle_check.setInterval(30_000)
self._idle_check.timeout.connect(self._check_idle)
self._idle_check.start()
# ======================================================================
# Properties for QML
# ======================================================================
@pyqtProperty(bool, notify=connectionChanged)
def connected(self) -> bool:
return self._connected
@pyqtProperty(str, notify=connectionChanged)
def connectionError(self) -> str:
return self._connection_error
@pyqtProperty(bool, notify=stateChanged)
def running(self) -> bool:
return bool(self._state.get("current"))
@pyqtProperty(str, notify=stateChanged)
def kind(self) -> str:
current = self._state.get("current")
return str(current["kind"]) if current else str(self._state.get("next_kind", "work"))
@pyqtProperty(str, notify=stateChanged)
def kindLabel(self) -> str:
return KIND_LABELS.get(self.kind, self.kind)
@pyqtProperty(str, notify=stateChanged)
def status(self) -> str:
current = self._state.get("current")
return str(current["status"]) if current else "idle"
@pyqtProperty(int, notify=stateChanged)
def remainingSeconds(self) -> int:
if self._state.get("current"):
return int(self._remaining)
durations = self._state.get("durations") or {}
return int(durations.get(self.kind, 25)) * 60
@pyqtProperty(int, notify=stateChanged)
def plannedSeconds(self) -> int:
current = self._state.get("current")
if current:
return int(current["planned_seconds"])
durations = self._state.get("durations") or {}
return int(durations.get(self.kind, 25)) * 60
@pyqtProperty(float, notify=stateChanged)
def progress(self) -> float:
planned = self.plannedSeconds
if planned <= 0:
return 0.0
return max(0.0, min(1.0, 1 - self.remainingSeconds / planned))
@pyqtProperty(str, notify=stateChanged)
def clockText(self) -> str:
return clock(self.remainingSeconds)
@pyqtProperty(str, notify=stateChanged)
def taskTitle(self) -> str:
current = self._state.get("current") or {}
task = current.get("task") or {}
return str(task.get("title", ""))
@pyqtProperty(int, notify=stateChanged)
def taskId(self) -> int:
for task in self._tasks:
current = self._state.get("current") or {}
attached = current.get("task") or {}
if attached and task.get("external_id") == attached.get("external_id"):
return int(task.get("id", -1))
return -1
@pyqtProperty(int, notify=stateChanged)
def interruptions(self) -> int:
current = self._state.get("current") or {}
return int(current.get("interruptions", 0))
@pyqtProperty(int, notify=stateChanged)
def completedToday(self) -> int:
return int(self._state.get("completed_today", 0))
@pyqtProperty(int, notify=stateChanged)
def goal(self) -> int:
return int(self._state.get("goal", 8))
@pyqtProperty(int, notify=stateChanged)
def cyclePosition(self) -> int:
return int(self._state.get("cycle_position", 1))
@pyqtProperty(int, notify=stateChanged)
def cycleLength(self) -> int:
return int(self._state.get("cycles_before_long_break", 4))
@pyqtProperty(str, notify=stateChanged)
def nextKindLabel(self) -> str:
return KIND_LABELS.get(str(self._state.get("next_kind", "work")), "Focus")
@pyqtProperty("QVariantList", notify=tasksChanged)
def tasks(self) -> list[dict[str, Any]]:
return self._tasks
@pyqtProperty("QVariantMap", notify=tasksChanged)
def taskCounts(self) -> dict[str, Any]:
return self._task_counts
@pyqtProperty("QVariantMap", notify=statsChanged)
def stats(self) -> dict[str, Any]:
return self._stats
@pyqtProperty("QVariantMap", notify=reportChanged)
def report(self) -> dict[str, Any]:
return self._report
@pyqtProperty("QVariantMap", notify=settingsChanged)
def settings(self) -> dict[str, Any]:
return self._settings
@pyqtProperty(bool, notify=blockerChanged)
def blockerActive(self) -> bool:
return bool(self._policy.get("active"))
@pyqtProperty(str, notify=blockerChanged)
def blockerReason(self) -> str:
return str(self._policy.get("reason", ""))
@pyqtProperty("QVariantList", notify=blockerChanged)
def blockedDomains(self) -> list[str]:
return [str(item) for item in self._policy.get("all_domains", [])]
@pyqtProperty("QVariantList", notify=blockerChanged)
def blockedApps(self) -> list[str]:
return [str(item) for item in self._policy.get("all_apps", [])]
@pyqtProperty(bool, notify=blockerChanged)
def helperInstalled(self) -> bool:
return self._blocker.helper_installed
@pyqtProperty(str, notify=blockerChanged)
def blockerError(self) -> str:
return self._blocker.last_error or ""
@pyqtProperty("QVariantMap", notify=configChanged)
def config(self) -> dict[str, Any]:
from dataclasses import asdict
data = asdict(self._config)
data["autostart"] = autostart.autostart_enabled()
data["service_enabled"] = autostart.service_enabled()
return data
# ======================================================================
# Commands from QML (and from the tray, and from D-Bus)
# ======================================================================
def _send(self, name: str, args: dict[str, Any] | None = None) -> None:
from PyQt6.QtCore import Q_ARG, QMetaObject, Qt
QMetaObject.invokeMethod(
self._worker,
"command",
Qt.ConnectionType.QueuedConnection,
Q_ARG(str, name),
Q_ARG(dict, args or {}),
)
@pyqtSlot()
@pyqtSlot(int)
def startWork(self, minutes: int = 0) -> None:
args: dict[str, Any] = {"kind": "work"}
if minutes:
args["minutes"] = minutes
current_task = self._state.get("current") or {}
if not current_task.get("task"):
pinned = next((task for task in self._tasks if task.get("pinned")), None)
if pinned:
args["task_id"] = pinned["id"]
self._send("start", args)
@pyqtSlot()
def startBreak(self) -> None:
kind = str(self._state.get("next_kind", "short_break"))
self._send("start", {"kind": "long_break" if kind == "long_break" else "short_break"})
@pyqtSlot()
def toggle(self) -> None:
"""One verb for a global hotkey: start → pause → resume."""
if not self.running:
self.startWork()
elif self.status == "paused":
self.resume()
else:
self.pause()
@pyqtSlot()
def pause(self) -> None:
self._send("pause")
@pyqtSlot()
def resume(self) -> None:
self._send("resume")
@pyqtSlot()
def complete(self) -> None:
self._send("complete", {"auto_next": True})
@pyqtSlot()
@pyqtSlot(str)
def abandon(self, reason: str = "") -> None:
self._send("abandon", {"reason": reason or None})
@pyqtSlot()
@pyqtSlot(int)
def extend(self, minutes: int = 5) -> None:
self._send("extend", {"minutes": minutes})
@pyqtSlot()
def interrupt(self) -> None:
self._send("interrupt")
@pyqtSlot(int)
def attachTask(self, task_id: int) -> None:
self._send("attach_task", {"task_id": task_id if task_id >= 0 else None})
@pyqtSlot(int, bool)
def setTaskDone(self, task_id: int, done: bool) -> None:
self._send("task_done", {"task_id": task_id, "done": done})
@pyqtSlot(str, str)
def createTask(self, title: str, due_date: str) -> None:
self._send("create_task", {"title": title, "due_date": due_date or None})
@pyqtSlot()
def refreshSources(self) -> None:
self.toast.emit("Pulling Jira, Todoist, Google Tasks…")
self._send("refresh_sources")
@pyqtSlot()
def sendReport(self) -> None:
self._send("send_report")
@pyqtSlot()
def loadReport(self) -> None:
from PyQt6.QtCore import QMetaObject, Qt
QMetaObject.invokeMethod(self._worker, "refresh_report", Qt.ConnectionType.QueuedConnection)
@pyqtSlot()
def loadSettings(self) -> None:
from PyQt6.QtCore import QMetaObject, Qt
QMetaObject.invokeMethod(self._worker, "refresh_settings", Qt.ConnectionType.QueuedConnection)
@pyqtSlot("QVariantMap")
def patchSettings(self, patch: dict[str, Any]) -> None:
self._send("patch_settings", {"patch": patch})
@pyqtSlot(str, "QVariant")
def setConfig(self, key: str, value: Any) -> None:
"""Change a desktop-local setting and apply it immediately."""
if key == "autostart":
autostart.set_autostart(bool(value), minimized=self._config.start_minimized)
elif key == "service_enabled":
if value:
self.toast.emit(autostart.install_service(enable=True))
else:
autostart.remove_service()
self.toast.emit("systemd user service removed")
else:
self._config.update(**{key: value})
if key in ("server_url", "api_token"):
from PyQt6.QtCore import Q_ARG, QMetaObject, Qt
QMetaObject.invokeMethod(
self._worker,
"configure",
Qt.ConnectionType.QueuedConnection,
Q_ARG(str, self._config.server_url),
Q_ARG(str, self._config.api_token),
)
elif key == "poll_seconds":
from PyQt6.QtCore import Q_ARG, QMetaObject, Qt
QMetaObject.invokeMethod(
self._worker,
"set_interval",
Qt.ConnectionType.QueuedConnection,
Q_ARG(int, int(value)),
)
elif key == "block_hosts_file":
self._blocker.enforce_hosts = bool(value)
elif key == "block_apps":
self._blocker.enforce_apps = bool(value)
elif key == "enforce_blocking" and not value:
self._blocker.release()
self.configChanged.emit()
@pyqtSlot()
def openWebUi(self) -> None:
QDesktopServices.openUrl(QUrl(self._config.server_url))
@pyqtSlot(str)
def openUrl(self, url: str) -> None:
if url:
QDesktopServices.openUrl(QUrl(url))
@pyqtSlot(result=str)
def statusLine(self) -> str:
"""One line for the tray tooltip and the D-Bus `Status` method."""
if not self._connected:
return "Kadence — backend unreachable"
if not self.running:
return f"Kadence — idle · {self.completedToday}/{self.goal} today"
return (
f"{self.kindLabel} {clock(self.remainingSeconds)}"
+ (f" · {self.taskTitle}" if self.taskTitle else "")
+ f" · {self.completedToday}/{self.goal} today"
)
# ======================================================================
# Worker signals
# ======================================================================
def _on_state(self, state: dict[str, Any]) -> None:
previous_current = self._state.get("current") or {}
self._state = state
current = state.get("current") or {}
self._remaining = int(current.get("remaining_seconds", 0))
session_id = current.get("id")
kind = str(current.get("kind", ""))
status = str(current.get("status", "idle"))
if session_id != self._last_session_id or kind != self._last_kind or status != self._last_status:
self._on_transition(previous_current, current)
self._last_session_id = session_id
self._last_kind = kind
self._last_status = status
self._completing = False
self.stateChanged.emit()
self._update_launcher()
def _on_policy(self, policy: dict[str, Any]) -> None:
self._policy = policy
if self._config.enforce_blocking:
self._blocker.sync(policy)
self.blockerChanged.emit()
def _on_tasks(self, payload: dict[str, Any]) -> None:
self._tasks = list(payload.get("tasks", []))
self._task_counts = dict(payload.get("counts", {}))
self.tasksChanged.emit()
def _on_stats(self, payload: dict[str, Any]) -> None:
self._stats = payload
self.statsChanged.emit()
def _on_report(self, payload: dict[str, Any]) -> None:
self._report = payload
self.reportChanged.emit()
def _on_settings(self, payload: dict[str, Any]) -> None:
self._settings = payload.get("settings", payload)
self.settingsChanged.emit()
def _on_connection(self, connected: bool, detail: str) -> None:
self._connected = connected
self._connection_error = detail
self.connectionChanged.emit()
if not connected:
self.toast.emit(detail or "Backend unreachable")
def _on_command_failed(self, name: str, detail: str) -> None:
log.warning("%s failed: %s", name, detail)
self.toast.emit(detail)
def _on_command_done(self, name: str, result: dict[str, Any]) -> None:
if name == "complete":
finished = result.get("finished") or {}
self._announce_finished(str(finished.get("kind", "work")), result)
elif name == "send_report":
self.toast.emit("Daily report sent" if result.get("sent") else "Report not sent — check Telegram settings")
elif name == "refresh_sources":
failed = [item for item in result.get("sources", []) if not item.get("ok")]
self.toast.emit(
f"{len(failed)} source(s) failed" if failed else f"{result.get('total', 0)} tasks pulled"
)
elif name == "task_done" and result.get("warning"):
self.toast.emit(str(result["warning"]))
# ======================================================================
# Transitions → the system integrations
# ======================================================================
def _on_transition(self, previous: dict[str, Any], current: dict[str, Any]) -> None:
focusing = current.get("kind") == "work" and current.get("status") == "running"
if focusing:
if self._config.inhibit_screensaver:
self._screensaver.hold()
if self._config.do_not_disturb:
self._dnd.hold()
if self._config.pause_media and previous.get("kind") != "work":
self._media.pause_all()
else:
self._screensaver.release()
self._dnd.release()
if self._config.pause_media and previous.get("kind") == "work":
self._media.resume_paused()
def _announce_finished(self, kind: str, result: dict[str, Any]) -> None:
auto = result.get("auto_started") or {}
done = int(result.get("completed_today", self.completedToday))
goal = int(result.get("goal", self.goal))
if kind == "work":
summary = "Pomodoro done 🍅"
body = f"{done}/{goal} today."
if auto:
body += f" {KIND_LABELS.get(str(auto.get('kind')), 'Break')} started automatically."
actions = (
{
"break": ("Start break", self.startBreak),
"again": ("Another pomodoro", lambda: self.startWork()),
}
if not auto
else {"skip": ("Skip the break", lambda: self.startWork())}
)
else:
summary = "Break over ☕"
body = "Back to it — one more interval."
actions = {"work": ("Start focus", lambda: self.startWork()), "five": ("+5 min break", lambda: self.extend(5))}
if self._config.notifications:
self._notifier.notify(
summary,
body,
actions=actions if self._config.notification_actions else None,
urgency=URGENCY_CRITICAL if kind == "work" else 1,
timeout_ms=0 if kind != "work" else 12000,
)
if result.get("celebration"):
self.toast.emit("Milestone reached — Telegram got a cheer 🎉")
def _update_launcher(self) -> None:
if not self._config.taskbar_progress:
return
if self.running:
self._launcher.update(progress=self.progress, count=self.completedToday or None)
else:
self._launcher.update(progress=None, count=self.completedToday or None)
# ---- local clock -------------------------------------------------------
def _on_tick(self) -> None:
if not self.running or self.status == "paused":
return
if self._remaining > 0:
self._remaining -= 1
self.stateChanged.emit()
if self._remaining % 5 == 0:
self._update_launcher()
return
if not self._completing:
# The countdown reached zero: the server books the interval, and its
# reply decides what happens next (auto-break, or nothing).
self._completing = True
self.complete()
def _check_idle(self) -> None:
limit = self._config.pause_on_idle_minutes
if not limit or not self.running or self.status != "running" or self.kind != "work":
return
idle = self._session.idle_seconds()
if idle >= 0 and idle >= limit * 60:
self.pause()
self.toast.emit(f"Paused — no input for {limit} minutes")
if self._config.notifications:
self._notifier.notify("Timer paused", f"You have been away for {limit} minutes.")
def _on_session_locked(self) -> None:
if not self._config.pause_on_lock:
return
if self.running and self.status == "running":
self._paused_by_lock = True
self.pause()
def _on_session_unlocked(self) -> None:
if self._paused_by_lock and self.running and self.status == "paused":
self.resume()
if self._config.notifications:
self._notifier.notify("Welcome back", "Your interval is running again.")
self._paused_by_lock = False
# ---- blocker callbacks -------------------------------------------------
def _report_block(self, kind: str, target: str, action: str, details: str | None) -> None:
self._send("report_block", {"kind": kind, "target": target, "action": action, "details": details})
def _blocker_notice(self, summary: str, body: str) -> None:
if self._config.notifications:
self._notifier.notify(summary, body, urgency=URGENCY_CRITICAL, timeout_ms=6000)
self.toast.emit(body)
# ---- shutdown ----------------------------------------------------------
def shutdown(self) -> None:
"""Hand everything back: inhibits, DND, and the hosts file."""
self._tick.stop()
self._idle_check.stop()
self._screensaver.release()
self._dnd.release()
self._launcher.clear()
try:
self._blocker.release()
except Exception: # pragma: no cover
log.exception("could not clear the block on exit")
self._thread.quit()
self._thread.wait(2000)
@@ -0,0 +1,6 @@
"""Desktop-environment integration.
Each module here wraps one real system facility over D-Bus (or a file in the
right place), and every one of them degrades to a no-op when the running desktop
does not provide it — so the same build works on KDE, GNOME and a bare WM.
"""
@@ -0,0 +1,140 @@
"""Starting with the session, the way the desktop expects.
Two mechanisms, because the two desktops disagree about which is canonical:
* a `.desktop` file in `~/.config/autostart` — the XDG standard, honoured by
KDE, GNOME, XFCE and friends;
* a **systemd user unit**, which is what you actually want on a modern box: it
restarts the app if it dies and keeps its logs in the journal.
Either can be switched on alone; the app only ever touches files under the
user's own config, never system paths.
"""
from __future__ import annotations
import logging
import os
import shutil
import subprocess
import sys
from pathlib import Path
log = logging.getLogger(__name__)
DESKTOP_FILE_NAME = "kadence.desktop"
SERVICE_NAME = "kadence-desktop.service"
def _autostart_dir() -> Path:
base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
path = Path(base) / "autostart"
path.mkdir(parents=True, exist_ok=True)
return path
def _systemd_user_dir() -> Path:
base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
path = Path(base) / "systemd" / "user"
path.mkdir(parents=True, exist_ok=True)
return path
def launch_command() -> str:
"""How to start this app again, whether installed or run from a checkout."""
executable = shutil.which("kadence-desktop")
if executable:
return executable
return f"{sys.executable} -m kadence_desktop"
# --------------------------------------------------------------------------
# XDG autostart
# --------------------------------------------------------------------------
def autostart_enabled() -> bool:
return (_autostart_dir() / DESKTOP_FILE_NAME).exists()
def set_autostart(enabled: bool, *, minimized: bool = True) -> None:
path = _autostart_dir() / DESKTOP_FILE_NAME
if not enabled:
path.unlink(missing_ok=True)
return
command = launch_command() + (" --minimized" if minimized else "")
path.write_text(
"\n".join(
[
"[Desktop Entry]",
"Type=Application",
"Name=Kadence",
"Comment=Pomodoro timer, task list and focus guard",
f"Exec={command}",
"Icon=kadence",
"Terminal=false",
"Categories=Utility;Office;",
"X-GNOME-Autostart-enabled=true",
"",
]
),
"utf-8",
)
log.info("autostart entry written to %s", path)
# --------------------------------------------------------------------------
# systemd user service
# --------------------------------------------------------------------------
def service_installed() -> bool:
return (_systemd_user_dir() / SERVICE_NAME).exists()
def service_enabled() -> bool:
if not shutil.which("systemctl"):
return False
result = subprocess.run(
["systemctl", "--user", "is-enabled", SERVICE_NAME],
capture_output=True,
text=True,
check=False,
)
return result.stdout.strip() == "enabled"
def install_service(*, enable: bool = True) -> str:
"""Write (and optionally enable) the user unit. Returns a status line."""
path = _systemd_user_dir() / SERVICE_NAME
path.write_text(
"\n".join(
[
"[Unit]",
"Description=Kadence desktop client",
"PartOf=graphical-session.target",
"After=graphical-session.target",
"",
"[Service]",
"Type=simple",
f"ExecStart={launch_command()} --minimized",
"Restart=on-failure",
"RestartSec=5",
"",
"[Install]",
"WantedBy=graphical-session.target",
"",
]
),
"utf-8",
)
if not shutil.which("systemctl"):
return f"unit written to {path} (systemctl not found)"
subprocess.run(["systemctl", "--user", "daemon-reload"], check=False)
if enable:
subprocess.run(["systemctl", "--user", "enable", SERVICE_NAME], check=False)
return f"unit written to {path}" + (" and enabled" if enable else "")
def remove_service() -> None:
if shutil.which("systemctl"):
subprocess.run(["systemctl", "--user", "disable", "--now", SERVICE_NAME], check=False)
(_systemd_user_dir() / SERVICE_NAME).unlink(missing_ok=True)
if shutil.which("systemctl"):
subprocess.run(["systemctl", "--user", "daemon-reload"], check=False)
@@ -0,0 +1,122 @@
"""Kadence's own D-Bus API: `org.kadence.Desktop`.
This is what makes the app scriptable and bindable. KDE's System Settings →
Shortcuts → Custom Shortcuts can call a D-Bus method directly, so a global
hotkey for "start a pomodoro" needs no X11 key-grabbing hack:
qdbus org.kadence.Desktop /Timer org.kadence.Timer.StartWork
Owning the name also gives single-instance behaviour for free: a second launch
sees the name is taken, calls `Raise()` on the first, and exits.
"""
from __future__ import annotations
import logging
from typing import Callable
from PyQt6.QtCore import QObject, pyqtSlot
from PyQt6.QtDBus import QDBusConnection
log = logging.getLogger(__name__)
SERVICE_NAME = "org.kadence.Desktop"
OBJECT_PATH = "/Timer"
INTERFACE = "org.kadence.Timer"
class TimerDBusService(QObject):
"""The exported object. Every slot is a verb you can bind to a key."""
def __init__(self, actions: dict[str, Callable[[], object]], parent: QObject | None = None) -> None:
super().__init__(parent)
self._actions = actions
def _run(self, name: str) -> str:
action = self._actions.get(name)
if action is None:
return f"unknown action: {name}"
try:
result = action()
return str(result) if result is not None else "ok"
except Exception as exc: # pragma: no cover - never let a caller crash us
log.exception("D-Bus action %s failed", name)
return f"error: {exc}"
@pyqtSlot(result=str)
def StartWork(self) -> str: # noqa: N802 - D-Bus methods are PascalCase
"""Start a focus interval."""
return self._run("start_work")
@pyqtSlot(result=str)
def StartBreak(self) -> str: # noqa: N802
return self._run("start_break")
@pyqtSlot(result=str)
def Toggle(self) -> str: # noqa: N802
"""Start if idle, pause if running, resume if paused — one hotkey."""
return self._run("toggle")
@pyqtSlot(result=str)
def Pause(self) -> str: # noqa: N802
return self._run("pause")
@pyqtSlot(result=str)
def Resume(self) -> str: # noqa: N802
return self._run("resume")
@pyqtSlot(result=str)
def Complete(self) -> str: # noqa: N802
return self._run("complete")
@pyqtSlot(result=str)
def Stop(self) -> str: # noqa: N802
return self._run("stop")
@pyqtSlot(result=str)
def Extend(self) -> str: # noqa: N802
return self._run("extend")
@pyqtSlot(result=str)
def Interrupt(self) -> str: # noqa: N802
"""Count a distraction without stopping the clock."""
return self._run("interrupt")
@pyqtSlot(result=str)
def Status(self) -> str: # noqa: N802
"""One line: what is running and how much is left."""
return self._run("status")
@pyqtSlot(result=str)
def SendReport(self) -> str: # noqa: N802
return self._run("send_report")
@pyqtSlot(result=str)
def Raise(self) -> str: # noqa: N802
"""Show the window (used by the second instance)."""
return self._run("raise_window")
def register(service: TimerDBusService) -> bool:
"""Own the name and export the object. False means another instance has it."""
bus = QDBusConnection.sessionBus()
if not bus.registerService(SERVICE_NAME):
return False
bus.registerObject(
OBJECT_PATH,
service,
QDBusConnection.RegisterOption.ExportAllSlots | QDBusConnection.RegisterOption.ExportAllInvokables,
)
log.info("D-Bus service %s%s is up", SERVICE_NAME, OBJECT_PATH)
return True
def call_existing(method: str = "Raise") -> bool:
"""Poke the instance that already owns the name."""
from PyQt6.QtDBus import QDBusInterface
interface = QDBusInterface(SERVICE_NAME, OBJECT_PATH, INTERFACE, QDBusConnection.sessionBus())
if not interface.isValid():
return False
interface.call(method)
return True
@@ -0,0 +1,106 @@
"""Two things a focus app owes you: the screen stays on, and the world shuts up.
* **Screensaver inhibit** — `org.freedesktop.ScreenSaver.Inhibit`, honoured by
KDE, GNOME and anything XDG-ish. Without it a 50-minute interval spent reading
ends with a lock screen.
* **Do Not Disturb** — Plasma (and any daemon implementing the spec's optional
`Inhibit`) silences other apps' banners while you focus. GNOME has no such
method, so there we flip the `show-banners` gsetting and put it back after.
Both are strictly paired: every inhibit is released on exit, including a crash,
because the cookie dies with the D-Bus connection.
"""
from __future__ import annotations
import logging
import shutil
import subprocess
from PyQt6.QtDBus import QDBusConnection, QDBusInterface
log = logging.getLogger(__name__)
_SCREENSAVER = ("org.freedesktop.ScreenSaver", "/org/freedesktop/ScreenSaver", "org.freedesktop.ScreenSaver")
_NOTIFICATIONS = ("org.freedesktop.Notifications", "/org/freedesktop/Notifications", "org.freedesktop.Notifications")
class ScreensaverInhibitor:
"""Keeps the session awake for as long as `hold()` is in effect."""
def __init__(self, app_name: str = "Kadence") -> None:
self._app_name = app_name
self._interface = QDBusInterface(*_SCREENSAVER, QDBusConnection.sessionBus())
self._cookie: int | None = None
@property
def active(self) -> bool:
return self._cookie is not None
def hold(self, reason: str = "Focus interval in progress") -> None:
if self._cookie is not None or not self._interface.isValid():
return
reply = self._interface.call("Inhibit", self._app_name, reason)
arguments = reply.arguments()
if arguments:
self._cookie = int(arguments[0])
log.debug("screensaver inhibited (cookie %s)", self._cookie)
def release(self) -> None:
if self._cookie is None:
return
self._interface.call("UnInhibit", self._cookie)
log.debug("screensaver released")
self._cookie = None
class DoNotDisturb:
"""Silences other applications' notifications during focus."""
def __init__(self, app_name: str = "Kadence") -> None:
self._app_name = app_name
self._interface = QDBusInterface(*_NOTIFICATIONS, QDBusConnection.sessionBus())
self._cookie: int | None = None
self._gnome_restored = True
@property
def active(self) -> bool:
return self._cookie is not None or not self._gnome_restored
def hold(self, reason: str = "Focus interval in progress") -> None:
if self.active:
return
if self._interface.isValid():
reply = self._interface.call("Inhibit", self._app_name, reason, {})
arguments = reply.arguments()
if arguments and not reply.errorName():
self._cookie = int(arguments[0])
return
self._gnome_dnd(True)
def release(self) -> None:
if self._cookie is not None:
self._interface.call("UnInhibit", self._cookie)
self._cookie = None
if not self._gnome_restored:
self._gnome_dnd(False)
def _gnome_dnd(self, enable: bool) -> None:
"""GNOME fallback: toggle the banner gsetting, and remember to undo it."""
if not shutil.which("gsettings"):
return
try:
subprocess.run(
[
"gsettings",
"set",
"org.gnome.desktop.notifications",
"show-banners",
"false" if enable else "true",
],
check=False,
timeout=5,
)
self._gnome_restored = not enable
except (OSError, subprocess.SubprocessError) as exc: # pragma: no cover
log.debug("gsettings DND failed: %s", exc)
@@ -0,0 +1,43 @@
"""Progress on the dock icon, via `com.canonical.Unity.LauncherEntry`.
A signal, not a service: anything listening (Plasma's task manager, Dash to
Dock, Latte) picks it up, and nothing breaks where nobody listens. The result is
a progress bar filling under the icon as the interval runs and a badge with the
pomodoro count for the day — the timer stays readable with the window closed.
"""
from __future__ import annotations
from PyQt6.QtCore import QVariant
from PyQt6.QtDBus import QDBusConnection, QDBusMessage
_PATH = "/org/kadence/Desktop"
_IFACE = "com.canonical.Unity.LauncherEntry"
_APP_URI = "application://kadence.desktop"
class LauncherEntry:
def __init__(self) -> None:
self._bus = QDBusConnection.sessionBus()
def update(self, *, progress: float | None = None, count: int | None = None, urgent: bool = False) -> None:
"""Push a new state. `progress` is 0..1; `count` is the badge number."""
properties: dict[str, QVariant] = {}
if progress is None:
properties["progress-visible"] = QVariant(False)
else:
properties["progress"] = QVariant(max(0.0, min(1.0, progress)))
properties["progress-visible"] = QVariant(True)
if count is None:
properties["count-visible"] = QVariant(False)
else:
properties["count"] = QVariant(int(count))
properties["count-visible"] = QVariant(True)
properties["urgent"] = QVariant(bool(urgent))
message = QDBusMessage.createSignal(_PATH, _IFACE, "Update")
message.setArguments([_APP_URI, properties])
self._bus.send(message)
def clear(self) -> None:
self.update(progress=None, count=None, urgent=False)
@@ -0,0 +1,69 @@
"""Pause whatever is playing when focus starts — and only that.
Every modern player (Spotify, VLC, mpv, browsers, Elisa) speaks MPRIS, so this
needs no per-app support: list the `org.mpris.MediaPlayer2.*` names on the
session bus, pause the ones that are actually playing, and remember exactly
which ones so the break can hand the music back.
"""
from __future__ import annotations
import logging
from PyQt6.QtDBus import QDBusConnection, QDBusInterface
log = logging.getLogger(__name__)
_PREFIX = "org.mpris.MediaPlayer2."
_PATH = "/org/mpris/MediaPlayer2"
_PLAYER_IFACE = "org.mpris.MediaPlayer2.Player"
_PROPS_IFACE = "org.freedesktop.DBus.Properties"
class MediaController:
def __init__(self) -> None:
self._bus = QDBusConnection.sessionBus()
self._paused_by_us: list[str] = []
def _players(self) -> list[str]:
interface = QDBusInterface(
"org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", self._bus
)
reply = interface.call("ListNames")
arguments = reply.arguments()
names = list(arguments[0]) if arguments else []
return [name for name in names if name.startswith(_PREFIX)]
def _status(self, service: str) -> str:
properties = QDBusInterface(service, _PATH, _PROPS_IFACE, self._bus)
reply = properties.call("Get", _PLAYER_IFACE, "PlaybackStatus")
arguments = reply.arguments()
return str(arguments[0]) if arguments else ""
def pause_all(self) -> list[str]:
"""Pause every playing player; returns the ones that were paused."""
paused: list[str] = []
for service in self._players():
try:
if self._status(service) != "Playing":
continue
QDBusInterface(service, _PATH, _PLAYER_IFACE, self._bus).call("Pause")
paused.append(service)
except Exception as exc: # pragma: no cover - a rude player is not our problem
log.debug("could not pause %s: %s", service, exc)
self._paused_by_us = paused
if paused:
log.info("paused %s media player(s)", len(paused))
return paused
def resume_paused(self) -> None:
"""Resume only what we paused, and only if it is still around."""
alive = set(self._players())
for service in self._paused_by_us:
if service not in alive:
continue
try:
QDBusInterface(service, _PATH, _PLAYER_IFACE, self._bus).call("Play")
except Exception as exc: # pragma: no cover
log.debug("could not resume %s: %s", service, exc)
self._paused_by_us = []
@@ -0,0 +1,133 @@
"""Desktop notifications with buttons, over org.freedesktop.Notifications.
Qt's `QSystemTrayIcon.showMessage` can only show text. The real spec — which
KDE, GNOME and anything using a freedesktop daemon implement — takes *actions*,
so "Pomodoro finished" can carry [Start break] [Skip] [+5 min] and act without
you finding the window. Notifications are also replaced in place (the same id),
so a running timer never stacks up a column of toasts.
"""
from __future__ import annotations
import logging
from typing import Callable
from PyQt6.QtCore import QMetaType, QObject, QVariant, pyqtSlot
from PyQt6.QtDBus import QDBusArgument, QDBusConnection, QDBusInterface, QDBusMessage
log = logging.getLogger(__name__)
_SERVICE = "org.freedesktop.Notifications"
_PATH = "/org/freedesktop/Notifications"
_IFACE = "org.freedesktop.Notifications"
# Urgency levels from the freedesktop spec.
URGENCY_LOW, URGENCY_NORMAL, URGENCY_CRITICAL = 0, 1, 2
class Notifier(QObject):
"""Sends notifications and routes their button presses back to callables."""
def __init__(self, app_name: str = "Kadence", parent: QObject | None = None) -> None:
super().__init__(parent)
self._app_name = app_name
self._bus = QDBusConnection.sessionBus()
self._interface = QDBusInterface(_SERVICE, _PATH, _IFACE, self._bus)
self._handlers: dict[int, dict[str, Callable[[], None]]] = {}
self._last_id = 0
self._available = self._interface.isValid()
if self._available:
self._bus.connect(_SERVICE, _PATH, _IFACE, "ActionInvoked", self._on_action)
self._bus.connect(_SERVICE, _PATH, _IFACE, "NotificationClosed", self._on_closed)
@property
def available(self) -> bool:
return self._available
def capabilities(self) -> list[str]:
if not self._available:
return []
reply = self._interface.call("GetCapabilities")
arguments = reply.arguments()
return list(arguments[0]) if arguments else []
def notify(
self,
summary: str,
body: str = "",
*,
actions: dict[str, tuple[str, Callable[[], None]]] | None = None,
urgency: int = URGENCY_NORMAL,
timeout_ms: int = 8000,
replace: bool = True,
icon: str = "kadence",
sound: bool = True,
) -> int:
"""Show a notification. `actions` maps key -> (label, callback)."""
if not self._available:
return 0
action_list: list[str] = []
handlers: dict[str, Callable[[], None]] = {}
for key, (label, callback) in (actions or {}).items():
action_list += [key, label]
handlers[key] = callback
hints = {
"urgency": QVariant(urgency),
"desktop-entry": QVariant("kadence"),
"x-kde-origin-name": QVariant(self._app_name),
}
if not sound:
hints["suppress-sound"] = QVariant(True)
# The spec's signature is `susssasa{sv}i`: `replaces_id` is a uint32 and
# `actions` is an array of strings. Python ints marshal as int32 and a
# Python list as an array of variants, so both need spelling out or the
# daemon rejects the call as "no such method".
reply = self._interface.call(
"Notify",
self._app_name,
QDBusArgument(self._last_id if replace else 0, QMetaType.Type.UInt.value),
icon,
summary,
body,
QDBusArgument(action_list, QMetaType.Type.QStringList.value),
hints,
timeout_ms,
)
if reply.errorName():
log.warning("notification refused: %s — %s", reply.errorName(), reply.errorMessage())
return 0
arguments = reply.arguments()
try:
notification_id = int(arguments[0]) if arguments else 0
except (TypeError, ValueError):
log.warning("unexpected Notify reply: %r", arguments)
return 0
self._last_id = notification_id
if handlers:
self._handlers[notification_id] = handlers
return notification_id
def close(self, notification_id: int) -> None:
if self._available and notification_id:
self._interface.call("CloseNotification", notification_id)
@pyqtSlot(QDBusMessage)
def _on_action(self, message: QDBusMessage) -> None:
arguments = message.arguments()
if len(arguments) < 2:
return
notification_id, key = int(arguments[0]), str(arguments[1])
handler = self._handlers.get(notification_id, {}).get(key)
if handler:
handler()
@pyqtSlot(QDBusMessage)
def _on_closed(self, message: QDBusMessage) -> None:
arguments = message.arguments()
if arguments:
self._handlers.pop(int(arguments[0]), None)
@@ -0,0 +1,119 @@
"""Knowing when you walked away.
Two signals, both from the session itself rather than from guesswork:
* **logind** emits `Lock`/`Unlock` on your session object, and `PrepareForSleep`
when the machine suspends. Locking the screen is the clearest possible "I am
not at this desk" — the timer pauses, and resumes when you come back.
* **Idle time** comes from KDE's `org.kde.screensaver.GetSessionIdleTime` or
GNOME's `org.gnome.Mutter.IdleMonitor`, so an interval you forgot to stop does
not quietly bank half an hour of "focus" you spent in the kitchen.
"""
from __future__ import annotations
import logging
import os
from PyQt6.QtCore import QObject, pyqtSignal, pyqtSlot
from PyQt6.QtDBus import QDBusConnection, QDBusInterface, QDBusMessage
log = logging.getLogger(__name__)
_LOGIND = "org.freedesktop.login1"
_MANAGER_PATH = "/org/freedesktop/login1"
_MANAGER_IFACE = "org.freedesktop.login1.Manager"
_SESSION_IFACE = "org.freedesktop.login1.Session"
class SessionWatcher(QObject):
"""Emits lock/unlock/sleep, and answers "how long have they been idle?"."""
locked = pyqtSignal()
unlocked = pyqtSignal()
sleeping = pyqtSignal()
woke = pyqtSignal()
def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent)
self._system_bus = QDBusConnection.systemBus()
self._session_bus = QDBusConnection.sessionBus()
self._session_path = self._find_session_path()
if self._session_path:
for signal_name, slot in (("Lock", self._on_lock), ("Unlock", self._on_unlock)):
self._system_bus.connect(_LOGIND, self._session_path, _SESSION_IFACE, signal_name, slot)
log.debug("watching logind session %s", self._session_path)
self._system_bus.connect(_LOGIND, _MANAGER_PATH, _MANAGER_IFACE, "PrepareForSleep", self._on_sleep)
# Plasma's own lock signal, for setups where logind's does not arrive.
self._session_bus.connect(
"org.freedesktop.ScreenSaver",
"/org/freedesktop/ScreenSaver",
"org.freedesktop.ScreenSaver",
"ActiveChanged",
self._on_screensaver,
)
def _find_session_path(self) -> str:
"""Ask logind for *this* session's object path."""
manager = QDBusInterface(_LOGIND, _MANAGER_PATH, _MANAGER_IFACE, self._system_bus)
if not manager.isValid():
return ""
session_id = os.environ.get("XDG_SESSION_ID", "")
if session_id:
reply = manager.call("GetSession", session_id)
arguments = reply.arguments()
if arguments and not reply.errorName():
return str(arguments[0].path() if hasattr(arguments[0], "path") else arguments[0])
reply = manager.call("GetSessionByPID", os.getpid())
arguments = reply.arguments()
if arguments and not reply.errorName():
value = arguments[0]
return str(value.path() if hasattr(value, "path") else value)
return ""
def idle_seconds(self) -> int:
"""Seconds since the last input, or -1 when nothing can tell us."""
kde = QDBusInterface(
"org.freedesktop.ScreenSaver", "/ScreenSaver", "org.kde.screensaver", self._session_bus
)
if kde.isValid():
reply = kde.call("GetSessionIdleTime")
arguments = reply.arguments()
if arguments and not reply.errorName():
return int(arguments[0]) // 1000
mutter = QDBusInterface(
"org.gnome.Mutter.IdleMonitor",
"/org/gnome/Mutter/IdleMonitor/Core",
"org.gnome.Mutter.IdleMonitor",
self._session_bus,
)
if mutter.isValid():
reply = mutter.call("GetIdletime")
arguments = reply.arguments()
if arguments and not reply.errorName():
return int(arguments[0]) // 1000
return -1
@pyqtSlot(QDBusMessage)
def _on_lock(self, _message: QDBusMessage) -> None:
self.locked.emit()
@pyqtSlot(QDBusMessage)
def _on_unlock(self, _message: QDBusMessage) -> None:
self.unlocked.emit()
@pyqtSlot(QDBusMessage)
def _on_sleep(self, message: QDBusMessage) -> None:
arguments = message.arguments()
going_to_sleep = bool(arguments[0]) if arguments else False
(self.sleeping if going_to_sleep else self.woke).emit()
@pyqtSlot(QDBusMessage)
def _on_screensaver(self, message: QDBusMessage) -> None:
arguments = message.arguments()
if not arguments:
return
(self.locked if bool(arguments[0]) else self.unlocked).emit()
+191
View File
@@ -0,0 +1,191 @@
// What the blocker is doing right now, and what it will do.
//
// The rules themselves are server-side (edit them in the web UI or below); this
// page is about *enforcement on this machine* — the helper, the hosts file and
// the process watcher.
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Layouts
import "components"
ScrollView {
id: page
contentWidth: availableWidth
clip: true
ColumnLayout {
width: page.availableWidth
spacing: 16
Banner {
Layout.fillWidth: true
visible: !controller.helperInstalled
text: "The privileged helper is not installed, so websites cannot be blocked. "
+ "Run: sudo desktop/helper/install.sh"
actionText: ""
}
Banner {
Layout.fillWidth: true
visible: controller.blockerError !== ""
text: controller.blockerError
}
ColumnLayout {
Layout.fillWidth: true
Layout.margins: 20
spacing: 16
Card {
title: controller.blockerActive ? "🛡 Blocking is ON" : "Blocking is off"
subtitle: controller.blockerReason
RowLayout {
Layout.fillWidth: true
spacing: 20
StatTile {
label: "Domains"
value: controller.blockedDomains.length
caption: "redirected to 127.0.0.1"
}
StatTile {
label: "Apps"
value: controller.blockedApps.length
caption: "closed on sight"
}
StatTile {
label: "Attempts today"
value: controller.stats.today ? controller.stats.today.blocked_attempts : 0
caption: "and counting"
}
}
RowLayout {
Layout.fillWidth: true
Switch {
text: "Enforce on this machine"
checked: controller.config.enforce_blocking
onToggled: controller.setConfig("enforce_blocking", checked)
}
Switch {
text: "Websites"
checked: controller.config.block_hosts_file
onToggled: controller.setConfig("block_hosts_file", checked)
}
Switch {
text: "Apps"
checked: controller.config.block_apps
onToggled: controller.setConfig("block_apps", checked)
}
}
}
Card {
title: "Blocked websites"
subtitle: "One hostname per line. `www.` and `m.` are added automatically."
TextArea {
id: domainsArea
Layout.fillWidth: true
Layout.minimumHeight: 140
wrapMode: TextArea.NoWrap
font.family: "Roboto Mono, monospace"
text: (controller.settings.blocker ? controller.settings.blocker.blocked_domains : []).join("\n")
placeholderText: "youtube.com\nreddit.com\ntwitter.com"
}
RowLayout {
PrimaryButton {
text: "Save websites"
onClicked: {
var list = domainsArea.text.split("\n").map(function (s) { return s.trim() })
.filter(function (s) { return s.length > 0 })
controller.patchSettings({ blocker: { blocked_domains: list } })
}
}
Item { Layout.fillWidth: true }
}
}
Card {
title: "Blocked applications"
subtitle: "Process names, matched loosely: `steam` also catches `steamwebhelper`."
TextArea {
id: appsArea
Layout.fillWidth: true
Layout.minimumHeight: 100
wrapMode: TextArea.NoWrap
font.family: "Roboto Mono, monospace"
text: (controller.settings.blocker ? controller.settings.blocker.blocked_apps : []).join("\n")
placeholderText: "steam\ndiscord\ntelegram-desktop"
}
RowLayout {
PrimaryButton {
text: "Save applications"
onClicked: {
var list = appsArea.text.split("\n").map(function (s) { return s.trim() })
.filter(function (s) { return s.length > 0 })
controller.patchSettings({ blocker: { blocked_apps: list } })
}
}
Item { Layout.fillWidth: true }
}
}
Card {
title: "When to block"
subtitle: "Server-side, so every device agrees."
RowLayout {
Layout.fillWidth: true
Switch {
text: "Blocking enabled"
checked: controller.settings.blocker ? controller.settings.blocker.enabled : false
onToggled: controller.patchSettings({ blocker: { enabled: checked } })
}
ComboBox {
Layout.preferredWidth: 220
model: ["During focus intervals", "Always", "On a schedule"]
currentIndex: {
var mode = controller.settings.blocker ? controller.settings.blocker.mode : "during_work"
return mode === "always" ? 1 : (mode === "scheduled" ? 2 : 0)
}
onActivated: function (index) {
var modes = ["during_work", "always", "scheduled"]
controller.patchSettings({ blocker: { mode: modes[index] } })
}
}
Switch {
text: "Keep blocking during breaks"
checked: controller.settings.blocker ? controller.settings.blocker.block_during_breaks : false
onToggled: controller.patchSettings({ blocker: { block_during_breaks: checked } })
}
}
}
Card {
title: "Today's temptations"
subtitle: "Every attempt the blocker turned away — this is what the daily report jokes about."
Repeater {
model: controller.stats.today ? controller.stats.today.blocked_top : []
delegate: RowLayout {
required property var modelData
Layout.fillWidth: true
Label { text: modelData.target; Layout.fillWidth: true }
Label { text: "× " + modelData.count; font.weight: Font.DemiBold }
}
}
Label {
visible: !controller.stats.today || controller.stats.today.blocked_top.length === 0
text: "Nothing yet today. Either the blocker is off, or you are behaving."
opacity: 0.6
font.pixelSize: 12
}
}
}
}
Component.onCompleted: controller.loadSettings()
}
+234
View File
@@ -0,0 +1,234 @@
// The window.
//
// Qt Quick Controls' Material style, following the session's own light/dark
// preference, with a navigation rail on the left and the pages stacked on the
// right. Everything it shows comes from `controller`, the Python object bound
// into the QML context — there is no second copy of the state here.
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Layouts
import QtQuick.Window
import "components"
ApplicationWindow {
id: window
width: 1100
height: 760
minimumWidth: 420
minimumHeight: 560
visible: false
title: controller.running
? controller.clockText + " · " + controller.kindLabel + " — Kadence"
: "Kadence"
Material.theme: Material.System
Material.primary: "#6750A4"
Material.accent: "#6750A4"
readonly property color workColor: "#2a78d6"
readonly property color shortBreakColor: "#eb6834"
readonly property color longBreakColor: "#1baf7a"
readonly property bool wide: width >= 820
property int page: 0
function kindColor(kind) {
if (kind === "short_break") return shortBreakColor
if (kind === "long_break") return longBreakColor
return workColor
}
// Closing the window keeps the app alive in the tray — the timer is still
// running, and that is the whole point of a tray icon.
onClosing: function (close) {
if (controller.config.close_to_tray && controller.config.tray_icon) {
close.accepted = false
window.hide()
}
}
Connections {
target: controller
function onToast(message) { snackbar.show(message) }
function onShowWindowRequested() { window.show(); window.raise(); window.requestActivate() }
}
// ---- shortcuts ---------------------------------------------------------
Shortcut { sequence: "Space"; onActivated: controller.toggle() }
Shortcut { sequence: "Ctrl+Return"; onActivated: controller.complete() }
Shortcut { sequence: "Ctrl+E"; onActivated: controller.extend(5) }
Shortcut { sequence: "Ctrl+R"; onActivated: controller.refreshSources() }
Shortcut { sequence: "Ctrl+1"; onActivated: window.page = 0 }
Shortcut { sequence: "Ctrl+2"; onActivated: window.page = 1 }
Shortcut { sequence: "Ctrl+3"; onActivated: window.page = 2 }
Shortcut { sequence: "Ctrl+4"; onActivated: window.page = 3 }
Shortcut { sequence: "Ctrl+5"; onActivated: window.page = 4 }
Shortcut { sequence: "Ctrl+W"; onActivated: window.hide() }
header: ToolBar {
visible: !window.wide
Material.background: Material.backgroundColor
RowLayout {
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
Label {
text: ["Timer", "Tasks", "Blocker", "Report", "Settings"][window.page]
font.pixelSize: 18
font.weight: Font.Medium
Layout.fillWidth: true
}
StatusPill {
running: controller.running
paused: controller.status === "paused"
connected: controller.connected
text: controller.connected ? controller.clockText : "offline"
accent: window.kindColor(controller.kind)
}
}
}
RowLayout {
anchors.fill: parent
spacing: 0
// ---- navigation rail ------------------------------------------------
Pane {
visible: window.wide
Layout.fillHeight: true
Layout.preferredWidth: 212
Material.elevation: 0
padding: 12
ColumnLayout {
anchors.fill: parent
spacing: 4
RowLayout {
Layout.bottomMargin: 16
Layout.leftMargin: 8
spacing: 10
Rectangle {
width: 36; height: 36; radius: 18
color: Material.accent
Label { anchors.centerIn: parent; text: "🍅"; font.pixelSize: 18 }
}
ColumnLayout {
spacing: 0
Label { text: "Kadence"; font.pixelSize: 17; font.weight: Font.DemiBold }
Label {
text: controller.connected ? "connected" : "offline"
font.pixelSize: 11
opacity: 0.6
}
}
}
Repeater {
model: [
{ label: "Timer", icon: "⏱" },
{ label: "Tasks", icon: "✓" },
{ label: "Blocker", icon: "🛡" },
{ label: "Report", icon: "📊" },
{ label: "Settings", icon: "⚙" }
]
delegate: ItemDelegate {
required property int index
required property var modelData
Layout.fillWidth: true
height: 44
highlighted: window.page === index
onClicked: window.page = index
background: Rectangle {
radius: 22
color: parent.highlighted ? Material.accent
: (parent.hovered ? Material.listHighlightColor : "transparent")
opacity: parent.highlighted ? 0.18 : 1
}
contentItem: RowLayout {
spacing: 12
Label { text: modelData.icon; font.pixelSize: 16 }
Label {
text: modelData.label
font.weight: parent.parent.highlighted ? Font.DemiBold : Font.Normal
Layout.fillWidth: true
}
}
}
}
Item { Layout.fillHeight: true }
StatusPill {
Layout.fillWidth: true
running: controller.running
paused: controller.status === "paused"
connected: controller.connected
text: controller.connected ? (controller.running ? controller.clockText : "idle") : "offline"
accent: window.kindColor(controller.kind)
}
Label {
Layout.fillWidth: true
Layout.topMargin: 6
text: controller.completedToday + "/" + controller.goal + " today"
font.pixelSize: 12
opacity: 0.7
horizontalAlignment: Text.AlignHCenter
}
Label {
Layout.fillWidth: true
visible: controller.blockerActive
text: "🛡 blocking is on"
font.pixelSize: 12
color: "#e34948"
horizontalAlignment: Text.AlignHCenter
}
}
}
// ---- pages ----------------------------------------------------------
ColumnLayout {
Layout.fillWidth: true
Layout.fillHeight: true
spacing: 0
Banner {
Layout.fillWidth: true
visible: !controller.connected
text: controller.connectionError !== ""
? controller.connectionError
: "Cannot reach the backend. Start it with `docker compose up`."
actionText: "Settings"
onActionClicked: window.page = 4
}
StackLayout {
Layout.fillWidth: true
Layout.fillHeight: true
currentIndex: window.page
TimerPage {}
TasksPage {}
BlockerPage {}
ReportPage {}
SettingsPage {}
}
}
}
// ---- footer: bottom navigation on a narrow window ----------------------
footer: TabBar {
visible: !window.wide
currentIndex: window.page
onCurrentIndexChanged: window.page = currentIndex
TabButton { text: "⏱" }
TabButton { text: "✓" }
TabButton { text: "🛡" }
TabButton { text: "📊" }
TabButton { text: "⚙" }
}
Snackbar { id: snackbar }
}
+180
View File
@@ -0,0 +1,180 @@
// The day, summarised — the same payload the Telegram message is built from.
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Layouts
import "components"
ScrollView {
id: page
contentWidth: availableWidth
clip: true
readonly property var payload: controller.report.report ? controller.report.report : null
readonly property var metrics: payload ? payload.metrics : null
ColumnLayout {
width: page.availableWidth
spacing: 16
ColumnLayout {
Layout.fillWidth: true
Layout.margins: 20
spacing: 16
RowLayout {
Layout.fillWidth: true
Label {
text: page.payload ? "Report for " + page.payload.date : "Report"
font.pixelSize: 20
font.weight: Font.Medium
Layout.fillWidth: true
}
Button { text: "⟳"; flat: true; onClicked: controller.loadReport() }
PrimaryButton { text: "Send to Telegram"; onClicked: controller.sendReport() }
}
GridLayout {
Layout.fillWidth: true
columns: page.width > 760 ? 4 : 2
columnSpacing: 12
rowSpacing: 12
StatTile {
label: "Pomodoros"
value: page.metrics ? page.metrics.pomodoros + "/" + page.metrics.goal : "—"
caption: page.metrics && page.metrics.goal_met ? "goal met 🎯" : "keep going"
accent: window.workColor
}
StatTile {
label: "Focus"
value: page.metrics ? page.metrics.focus_minutes + "m" : "—"
caption: page.metrics ? page.metrics.break_minutes + "m resting" : ""
}
StatTile {
label: "Score"
value: page.payload ? Math.round(page.payload.score) : "—"
caption: "out of 100"
}
StatTile {
label: "Streak"
value: page.payload ? page.payload.streak.current + "d" : "—"
caption: page.payload ? "best " + page.payload.streak.best : ""
}
}
Card {
title: "Last 30 days"
subtitle: "Goal line is the dashed row; darker bars cleared it."
BarRow {
Layout.fillWidth: true
days: controller.stats.range ? controller.stats.range : []
goal: controller.goal
}
}
Card {
visible: page.payload && page.payload.achievements.length > 0
title: "Achievements"
Flow {
Layout.fillWidth: true
spacing: 8
Repeater {
model: page.payload ? page.payload.achievements : []
delegate: Pane {
required property var modelData
Material.elevation: 0
Material.roundedScale: Material.SmallScale
padding: 10
background: Rectangle {
radius: 10
color: Material.accent
opacity: 0.14
}
ColumnLayout {
spacing: 1
Label {
text: modelData.emoji + " " + modelData.title
font.weight: Font.DemiBold
font.pixelSize: 13
}
Label {
text: modelData.detail
visible: modelData.detail !== ""
font.pixelSize: 11
opacity: 0.75
}
}
}
}
}
}
Card {
visible: page.payload && page.payload.fun_facts.length > 0
title: "The funny part"
Repeater {
model: page.payload ? page.payload.fun_facts : []
delegate: Label {
required property var modelData
text: modelData
wrapMode: Text.WordWrap
Layout.fillWidth: true
font.pixelSize: 13
}
}
}
Card {
visible: page.metrics && page.metrics.tasks_completed.length > 0
title: "Closed today"
Repeater {
model: page.metrics ? page.metrics.tasks_completed : []
delegate: RowLayout {
required property var modelData
Layout.fillWidth: true
Label { text: "✅"; font.pixelSize: 12 }
Label { text: modelData.title; Layout.fillWidth: true; elide: Text.ElideRight }
Label { text: modelData.source; font.pixelSize: 11; opacity: 0.6 }
}
}
}
Card {
title: "All time"
RowLayout {
Layout.fillWidth: true
spacing: 12
StatTile {
label: "Pomodoros"
value: controller.stats.lifetime ? controller.stats.lifetime.pomodoros : "—"
}
StatTile {
label: "Hours focused"
value: controller.stats.lifetime ? controller.stats.lifetime.focus_hours : "—"
}
StatTile {
label: "Tasks closed"
value: controller.stats.lifetime ? controller.stats.lifetime.tasks_completed : "—"
}
StatTile {
label: "Blocked"
value: controller.stats.lifetime ? controller.stats.lifetime.blocked_attempts : "—"
}
}
}
Label {
visible: page.payload && page.payload.verdict !== ""
text: page.payload ? page.payload.verdict : ""
font.italic: true
font.pixelSize: 14
opacity: 0.85
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
}
}
Component.onCompleted: controller.loadReport()
}
@@ -0,0 +1,391 @@
// Settings, split by where they live.
//
// Anything under "This computer" is local to this machine (and saved in
// ~/.config/kadence/desktop.json). Everything else is server-side, shared with
// the web app and the Telegram bot.
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Layouts
import "components"
ScrollView {
id: page
contentWidth: availableWidth
clip: true
readonly property var pomodoro: controller.settings.pomodoro ? controller.settings.pomodoro : null
readonly property var telegram: controller.settings.notifications
? controller.settings.notifications.telegram : null
readonly property var report: controller.settings.notifications
? controller.settings.notifications.report : null
function patchPomodoro(key, value) {
var patch = {}; patch[key] = value
controller.patchSettings({ pomodoro: patch })
}
function patchTelegram(key, value) {
var patch = {}; patch[key] = value
controller.patchSettings({ notifications: { telegram: patch } })
}
ColumnLayout {
width: page.availableWidth
spacing: 16
ColumnLayout {
Layout.fillWidth: true
Layout.margins: 20
spacing: 16
// ---- connection --------------------------------------------------
Card {
title: "Backend"
subtitle: controller.connected ? "Connected to " + controller.config.server_url
: controller.connectionError
RowLayout {
Layout.fillWidth: true
TextField {
id: serverField
Layout.fillWidth: true
text: controller.config.server_url
placeholderText: "http://localhost:8000"
}
TextField {
id: tokenField
Layout.preferredWidth: 180
text: controller.config.api_token
placeholderText: "API token (optional)"
echoMode: TextInput.Password
}
PrimaryButton {
text: "Connect"
onClicked: {
controller.setConfig("server_url", serverField.text.trim())
controller.setConfig("api_token", tokenField.text)
}
}
}
RowLayout {
Label { text: "Poll every"; font.pixelSize: 12 }
SpinBox {
from: 1; to: 60
value: controller.config.poll_seconds
onValueModified: controller.setConfig("poll_seconds", value)
}
Label { text: "seconds"; font.pixelSize: 12 }
Item { Layout.fillWidth: true }
Button { text: "Open the web app"; flat: true; onClicked: controller.openWebUi() }
}
}
// ---- timer -------------------------------------------------------
Card {
title: "Timer"
subtitle: "Shared with the web app and the Telegram bot."
GridLayout {
Layout.fillWidth: true
columns: page.width > 700 ? 4 : 2
columnSpacing: 16
rowSpacing: 8
ColumnLayout {
Label { text: "Focus (min)"; font.pixelSize: 11; opacity: 0.7 }
SpinBox {
from: 1; to: 240
value: page.pomodoro ? page.pomodoro.work_minutes : 25
onValueModified: page.patchPomodoro("work_minutes", value)
}
}
ColumnLayout {
Label { text: "Short break"; font.pixelSize: 11; opacity: 0.7 }
SpinBox {
from: 1; to: 120
value: page.pomodoro ? page.pomodoro.short_break_minutes : 5
onValueModified: page.patchPomodoro("short_break_minutes", value)
}
}
ColumnLayout {
Label { text: "Long break"; font.pixelSize: 11; opacity: 0.7 }
SpinBox {
from: 1; to: 240
value: page.pomodoro ? page.pomodoro.long_break_minutes : 15
onValueModified: page.patchPomodoro("long_break_minutes", value)
}
}
ColumnLayout {
Label { text: "Long break every"; font.pixelSize: 11; opacity: 0.7 }
SpinBox {
from: 1; to: 12
value: page.pomodoro ? page.pomodoro.cycles_before_long_break : 4
onValueModified: page.patchPomodoro("cycles_before_long_break", value)
}
}
ColumnLayout {
Label { text: "Daily goal"; font.pixelSize: 11; opacity: 0.7 }
SpinBox {
from: 1; to: 40
value: page.pomodoro ? page.pomodoro.daily_goal_pomodoros : 8
onValueModified: page.patchPomodoro("daily_goal_pomodoros", value)
}
}
}
Flow {
Layout.fillWidth: true
spacing: 16
Switch {
text: "Auto-start breaks"
checked: page.pomodoro ? page.pomodoro.auto_start_breaks : true
onToggled: page.patchPomodoro("auto_start_breaks", checked)
}
Switch {
text: "Auto-start next focus"
checked: page.pomodoro ? page.pomodoro.auto_start_work : false
onToggled: page.patchPomodoro("auto_start_work", checked)
}
Switch {
text: "Allow pausing"
checked: page.pomodoro ? page.pomodoro.allow_pause : true
onToggled: page.patchPomodoro("allow_pause", checked)
}
Switch {
text: "Strict mode"
checked: page.pomodoro ? page.pomodoro.strict_mode : false
onToggled: page.patchPomodoro("strict_mode", checked)
ToolTip.visible: hovered
ToolTip.text: "Abandoning a focus interval requires a reason."
}
}
}
// ---- desktop integration ----------------------------------------
Card {
title: "This computer"
subtitle: "Native desktop features. Stored locally, not on the server."
Flow {
Layout.fillWidth: true
spacing: 16
Switch {
text: "Tray icon"
checked: controller.config.tray_icon
onToggled: controller.setConfig("tray_icon", checked)
}
Switch {
text: "Close to tray"
checked: controller.config.close_to_tray
onToggled: controller.setConfig("close_to_tray", checked)
}
Switch {
text: "Notifications"
checked: controller.config.notifications
onToggled: controller.setConfig("notifications", checked)
}
Switch {
text: "Notification buttons"
checked: controller.config.notification_actions
onToggled: controller.setConfig("notification_actions", checked)
ToolTip.visible: hovered
ToolTip.text: "“Start break” / “Skip” directly on the notification."
}
Switch {
text: "Keep the screen awake"
checked: controller.config.inhibit_screensaver
onToggled: controller.setConfig("inhibit_screensaver", checked)
}
Switch {
text: "Do Not Disturb while focusing"
checked: controller.config.do_not_disturb
onToggled: controller.setConfig("do_not_disturb", checked)
}
Switch {
text: "Pause music when focus starts"
checked: controller.config.pause_media
onToggled: controller.setConfig("pause_media", checked)
ToolTip.visible: hovered
ToolTip.text: "Any MPRIS player: Spotify, VLC, mpv, browsers."
}
Switch {
text: "Taskbar progress"
checked: controller.config.taskbar_progress
onToggled: controller.setConfig("taskbar_progress", checked)
}
Switch {
text: "Pause when the screen locks"
checked: controller.config.pause_on_lock
onToggled: controller.setConfig("pause_on_lock", checked)
}
Switch {
text: "D-Bus control"
checked: controller.config.dbus_service
onToggled: controller.setConfig("dbus_service", checked)
ToolTip.visible: hovered
ToolTip.text: "org.kadence.Desktop — bind it to a global shortcut."
}
}
RowLayout {
Layout.fillWidth: true
Label { text: "Pause after"; font.pixelSize: 12 }
SpinBox {
from: 0; to: 120
value: controller.config.pause_on_idle_minutes
onValueModified: controller.setConfig("pause_on_idle_minutes", value)
}
Label { text: "idle minutes (0 = never)"; font.pixelSize: 12 }
Item { Layout.fillWidth: true }
}
RowLayout {
Layout.fillWidth: true
Switch {
text: "Start with the session"
checked: controller.config.autostart
onToggled: controller.setConfig("autostart", checked)
}
Switch {
text: "systemd user service"
checked: controller.config.service_enabled
onToggled: controller.setConfig("service_enabled", checked)
ToolTip.visible: hovered
ToolTip.text: "Restarts the app if it dies; logs to the journal."
}
Switch {
text: "Start minimised"
checked: controller.config.start_minimized
onToggled: controller.setConfig("start_minimized", checked)
}
}
}
// ---- telegram ----------------------------------------------------
Card {
title: "Telegram"
subtitle: "Daily report, morning briefing and the nudges that get you back to work."
RowLayout {
Layout.fillWidth: true
Switch {
text: "Enabled"
checked: page.telegram ? page.telegram.enabled : false
onToggled: page.patchTelegram("enabled", checked)
}
TextField {
Layout.fillWidth: true
placeholderText: "Bot token from @BotFather"
text: page.telegram ? page.telegram.bot_token : ""
onEditingFinished: if (text !== "••••••••") page.patchTelegram("bot_token", text)
}
TextField {
Layout.preferredWidth: 160
placeholderText: "Chat id"
text: page.telegram ? page.telegram.chat_id : ""
onEditingFinished: page.patchTelegram("chat_id", text)
}
}
Flow {
Layout.fillWidth: true
spacing: 16
Switch {
text: "Daily report"
checked: page.telegram ? page.telegram.daily_report : true
onToggled: page.patchTelegram("daily_report", checked)
}
Switch {
text: "Morning briefing"
checked: page.telegram ? page.telegram.morning_briefing : true
onToggled: page.patchTelegram("morning_briefing", checked)
}
Switch {
text: "Weekly review"
checked: page.telegram ? page.telegram.weekly_report : true
onToggled: page.patchTelegram("weekly_report", checked)
}
Switch {
text: "Nudge me when idle"
checked: page.telegram ? page.telegram.nudge_enabled : true
onToggled: page.patchTelegram("nudge_enabled", checked)
}
Switch {
text: "Chat commands"
checked: page.telegram ? page.telegram.commands_enabled : true
onToggled: page.patchTelegram("commands_enabled", checked)
}
}
RowLayout {
Layout.fillWidth: true
ColumnLayout {
Label { text: "Report at (HH:MM)"; font.pixelSize: 11; opacity: 0.7 }
TextField {
text: page.telegram ? page.telegram.daily_report_time : "21:00"
onEditingFinished: page.patchTelegram("daily_report_time", text)
}
}
ColumnLayout {
Label { text: "Briefing at (HH:MM)"; font.pixelSize: 11; opacity: 0.7 }
TextField {
text: page.telegram ? page.telegram.morning_briefing_time : "09:00"
onEditingFinished: page.patchTelegram("morning_briefing_time", text)
}
}
ColumnLayout {
Label { text: "Nudge after (min idle)"; font.pixelSize: 11; opacity: 0.7 }
SpinBox {
from: 10; to: 600
value: page.telegram ? page.telegram.nudge_after_idle_minutes : 90
onValueModified: page.patchTelegram("nudge_after_idle_minutes", value)
}
}
ColumnLayout {
Label { text: "Tone"; font.pixelSize: 11; opacity: 0.7 }
ComboBox {
model: ["friendly", "coach", "drill_sergeant", "dry"]
currentIndex: page.report ? model.indexOf(page.report.tone) : 0
onActivated: function (index) {
controller.patchSettings({ notifications: { report: { tone: model[index] } } })
}
}
}
ColumnLayout {
Label { text: "Language"; font.pixelSize: 11; opacity: 0.7 }
ComboBox {
model: ["en", "ru"]
currentIndex: page.report && page.report.language === "ru" ? 1 : 0
onActivated: function (index) {
controller.patchSettings({ notifications: { report: { language: model[index] } } })
}
}
}
}
}
Card {
title: "Shortcuts and scripting"
subtitle: "The app exposes org.kadence.Desktop on the session bus."
Label {
Layout.fillWidth: true
wrapMode: Text.WordWrap
font.family: "Roboto Mono, monospace"
font.pixelSize: 11
text: "qdbus org.kadence.Desktop /Timer org.kadence.Timer.Toggle\n"
+ "qdbus org.kadence.Desktop /Timer org.kadence.Timer.Status\n"
+ "kadence-desktop --toggle # same thing from a terminal\n\n"
+ "System Settings → Shortcuts → Custom → D-Bus command binds these to a key."
}
Label {
text: "Kadence " + appVersion + " · sources, blocking rules and reports live on the backend."
font.pixelSize: 11
opacity: 0.6
}
}
}
}
Component.onCompleted: controller.loadSettings()
}
+180
View File
@@ -0,0 +1,180 @@
// Everything you owe, from every source, grouped by deadline.
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Layouts
import "components"
Item {
id: page
function bucketTasks(bucket) {
var out = []
for (var i = 0; i < controller.tasks.length; i++) {
var task = controller.tasks[i]
if (!task.is_done && task.bucket === bucket) out.push(task)
}
return out
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 20
spacing: 12
RowLayout {
Layout.fillWidth: true
spacing: 8
Label {
text: controller.tasks.length + " open"
font.pixelSize: 13
opacity: 0.7
}
Item { Layout.fillWidth: true }
Button { text: "+ Add"; onClicked: addDialog.open() }
PrimaryButton { text: "⟳ Refresh sources"; onClicked: controller.refreshSources() }
}
ScrollView {
Layout.fillWidth: true
Layout.fillHeight: true
contentWidth: availableWidth
clip: true
ColumnLayout {
width: parent.parent.availableWidth
spacing: 12
Repeater {
model: [
{ key: "overdue", label: "Overdue", mark: "🔴" },
{ key: "today", label: "Due today", mark: "🟡" },
{ key: "upcoming", label: "Coming up", mark: "🔵" },
{ key: "someday", label: "No deadline", mark: "⚪" }
]
delegate: Card {
required property var modelData
property var items: page.bucketTasks(modelData.key)
visible: items.length > 0
title: modelData.mark + " " + modelData.label + " (" + items.length + ")"
Repeater {
model: parent.parent.items
delegate: RowLayout {
required property var modelData
Layout.fillWidth: true
spacing: 10
CheckBox {
checked: modelData.is_done
onToggled: controller.setTaskDone(modelData.id, checked)
}
ColumnLayout {
Layout.fillWidth: true
spacing: 2
Label {
text: modelData.title
font.pixelSize: 14
elide: Text.ElideRight
Layout.fillWidth: true
}
RowLayout {
spacing: 6
Label {
text: modelData.source_label
font.pixelSize: 10
opacity: 0.65
}
Label {
visible: !!modelData.project
text: "· " + modelData.project
font.pixelSize: 10
opacity: 0.65
}
Label {
visible: modelData.days_overdue > 0
text: "· " + modelData.days_overdue + "d overdue"
font.pixelSize: 10
color: "#e34948"
}
Label {
visible: !!modelData.due_date && modelData.days_overdue === 0
text: "· " + modelData.due_date
font.pixelSize: 10
opacity: 0.65
}
Label {
visible: !!modelData.priority
text: "· " + modelData.priority
font.pixelSize: 10
opacity: 0.65
}
}
}
ToolButton {
text: "↗"
visible: !!modelData.url
onClicked: controller.openUrl(modelData.url)
ToolTip.visible: hovered
ToolTip.text: "Open in " + modelData.source_label
}
ToolButton {
text: "▶"
onClicked: { controller.attachTask(modelData.id); controller.startWork(); window.page = 0 }
ToolTip.visible: hovered
ToolTip.text: "Start a pomodoro on this"
}
}
}
}
}
Card {
visible: controller.tasks.length === 0
title: "Nothing on the list"
subtitle: "Connect Jira, Todoist or Google Tasks in Settings → Sources, "
+ "or add something here yourself."
Button {
text: "Open settings"
onClicked: window.page = 4
}
}
}
}
}
Dialog {
id: addDialog
anchors.centerIn: Overlay.overlay
width: 380
modal: true
title: "New task"
standardButtons: Dialog.Cancel
ColumnLayout {
anchors.fill: parent
spacing: 10
TextField {
id: titleField
Layout.fillWidth: true
placeholderText: "What needs doing?"
}
TextField {
id: dueField
Layout.fillWidth: true
placeholderText: "Due date (YYYY-MM-DD, optional)"
inputMask: ""
}
PrimaryButton {
text: "Add"
enabled: titleField.text.trim() !== ""
Layout.alignment: Qt.AlignRight
onClicked: {
controller.createTask(titleField.text.trim(), dueField.text.trim())
titleField.text = ""; dueField.text = ""
addDialog.close()
}
}
}
}
}
+284
View File
@@ -0,0 +1,284 @@
// The timer page: the dial, the controls, and what you are working on.
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Layouts
import "components"
ScrollView {
id: page
contentWidth: availableWidth
clip: true
property var openTasks: {
var list = []
for (var i = 0; i < controller.tasks.length; i++)
if (!controller.tasks[i].is_done) list.push(controller.tasks[i])
return list
}
ColumnLayout {
width: page.availableWidth
spacing: 16
anchors.margins: 20
GridLayout {
Layout.fillWidth: true
Layout.margins: 20
columns: page.width > 900 ? 2 : 1
columnSpacing: 16
rowSpacing: 16
// ---- dial + controls --------------------------------------------
Card {
Layout.fillWidth: true
Layout.preferredWidth: 1
TimerDial {
Layout.alignment: Qt.AlignHCenter
Layout.preferredWidth: Math.min(300, page.availableWidth * 0.7)
Layout.preferredHeight: Layout.preferredWidth
progress: controller.progress
clockText: controller.clockText
label: controller.running ? controller.kindLabel : "Next: " + controller.nextKindLabel
sublabel: controller.running
? (controller.taskTitle !== "" ? controller.taskTitle : " ")
: "Press start when you are ready"
accent: window.kindColor(controller.kind)
paused: controller.status === "paused"
}
Flow {
Layout.alignment: Qt.AlignHCenter
Layout.topMargin: 8
spacing: 8
PrimaryButton {
visible: !controller.running
text: "▶ Start focus"
onClicked: controller.startWork()
}
Button {
visible: !controller.running
text: "☕ " + controller.nextKindLabel
onClicked: controller.startBreak()
}
Button {
visible: controller.running && controller.status !== "paused"
text: "⏸ Pause"
onClicked: controller.pause()
}
PrimaryButton {
visible: controller.running && controller.status === "paused"
text: "▶ Resume"
onClicked: controller.resume()
}
PrimaryButton {
visible: controller.running
text: "✓ Done"
onClicked: controller.complete()
}
Button {
visible: controller.running
text: "+5"
onClicked: controller.extend(5)
ToolTip.visible: hovered
ToolTip.text: "Add five minutes"
}
Button {
visible: controller.running
text: "🔔"
onClicked: controller.interrupt()
ToolTip.visible: hovered
ToolTip.text: "Count an interruption without stopping"
}
Button {
visible: controller.running
text: "⏹"
flat: true
Material.foreground: "#e34948"
onClicked: abandonDialog.open()
ToolTip.visible: hovered
ToolTip.text: "Abandon this interval"
}
}
Label {
Layout.alignment: Qt.AlignHCenter
visible: controller.interruptions > 0
text: controller.interruptions + " interruption(s) this interval"
font.pixelSize: 11
opacity: 0.6
}
Label {
Layout.alignment: Qt.AlignHCenter
text: "Space start/pause · Ctrl+Enter finish · Ctrl+E +5 min"
font.pixelSize: 10
opacity: 0.45
}
}
// ---- today + task picker ----------------------------------------
ColumnLayout {
Layout.fillWidth: true
Layout.preferredWidth: 1
Layout.alignment: Qt.AlignTop
spacing: 16
Card {
title: "Today"
subtitle: "Cycle " + controller.cyclePosition + " of " + controller.cycleLength
+ " before a long break"
RowLayout {
Layout.fillWidth: true
spacing: 8
Label {
text: controller.completedToday
font.pixelSize: 34
font.weight: Font.Light
}
Label {
text: "/ " + controller.goal + " pomodoros"
font.pixelSize: 16
opacity: 0.7
Layout.alignment: Qt.AlignBottom
Layout.bottomMargin: 6
}
Item { Layout.fillWidth: true }
}
ProgressBar {
Layout.fillWidth: true
from: 0
to: Math.max(controller.goal, 1)
value: Math.min(controller.completedToday, controller.goal)
}
Flow {
Layout.fillWidth: true
spacing: 6
Repeater {
model: Math.max(controller.goal, controller.completedToday)
delegate: Rectangle {
required property int index
width: 14; height: 14; radius: 7
color: index < controller.completedToday ? window.workColor : "transparent"
border.width: index < controller.completedToday ? 0 : 1
border.color: Material.hintTextColor
}
}
}
}
Card {
title: "What are you working on?"
ComboBox {
id: taskPicker
Layout.fillWidth: true
model: {
var names = ["No task"]
for (var i = 0; i < page.openTasks.length; i++)
names.push((page.openTasks[i].pinned ? "📌 " : "")
+ page.openTasks[i].title
+ (page.openTasks[i].due_date ? " · " + page.openTasks[i].due_date : ""))
return names
}
onActivated: function (index) {
controller.attachTask(index === 0 ? -1 : page.openTasks[index - 1].id)
}
}
Label {
Layout.fillWidth: true
wrapMode: Text.WordWrap
font.pixelSize: 12
opacity: 0.7
text: controller.taskTitle !== ""
? "This interval is tracked against “" + controller.taskTitle + "”."
: "Pick a task so the daily report knows where the time went."
}
RowLayout {
Layout.fillWidth: true
spacing: 8
Label {
text: "🔴 " + (controller.taskCounts.overdue || 0) + " overdue"
font.pixelSize: 12
}
Label {
text: "🟡 " + (controller.taskCounts.today || 0) + " due today"
font.pixelSize: 12
}
Item { Layout.fillWidth: true }
Button { text: "Tasks"; flat: true; onClicked: window.page = 1 }
}
}
}
}
// ---- the day's numbers ------------------------------------------------
GridLayout {
Layout.fillWidth: true
Layout.leftMargin: 20
Layout.rightMargin: 20
Layout.bottomMargin: 20
columns: page.width > 760 ? 4 : 2
columnSpacing: 12
rowSpacing: 12
StatTile {
label: "Focus today"
value: controller.stats.today ? controller.stats.today.focus_minutes + "m" : "—"
caption: controller.completedToday + " pomodoros"
}
StatTile {
label: "Longest stretch"
value: controller.stats.today ? controller.stats.today.longest_stretch_minutes + "m" : "—"
caption: "unbroken focus"
}
StatTile {
label: "Streak"
value: controller.stats.streak ? controller.stats.streak.current + "d" : "—"
caption: controller.stats.streak ? "best " + controller.stats.streak.best : ""
}
StatTile {
label: "Blocked"
value: controller.stats.today ? controller.stats.today.blocked_attempts : "—"
caption: "temptations turned away"
}
}
}
Dialog {
id: abandonDialog
anchors.centerIn: Overlay.overlay
width: 380
modal: true
title: "Abandon this interval?"
standardButtons: Dialog.Cancel
ColumnLayout {
anchors.fill: parent
spacing: 10
Label {
text: controller.clockText + " left. The interval is recorded as abandoned."
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
TextField {
id: reasonField
Layout.fillWidth: true
placeholderText: "What happened? (optional)"
}
Button {
text: "Abandon"
Material.background: "#e34948"
Material.foreground: "white"
Layout.alignment: Qt.AlignRight
onClicked: { controller.abandon(reasonField.text); reasonField.text = ""; abandonDialog.close() }
}
}
}
}
@@ -0,0 +1,36 @@
// An inline warning strip — used for "the backend is gone" and "the helper is
// not installed", the two states where the app cannot do its job.
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Layouts
Rectangle {
id: banner
property string text: ""
property string actionText: ""
signal actionClicked()
implicitHeight: visible ? row.implicitHeight + 20 : 0
color: "#e34948"
opacity: 0.92
RowLayout {
id: row
anchors.fill: parent
anchors.margins: 10
spacing: 12
Label {
text: banner.text
color: "white"
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
Button {
visible: banner.actionText !== ""
text: banner.actionText
flat: true
Material.foreground: "white"
onClicked: banner.actionClicked()
}
}
}
@@ -0,0 +1,79 @@
// A tiny bar chart: magnitude over time, one hue, baseline at zero.
//
// The ramp step says "did this day clear the goal" without adding a second
// colour, and every bar carries its own tooltip, so no bar needs a label.
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Layouts
Item {
id: chart
property var days: [] // [{date, pomodoros, focus_minutes, goal_met}]
property int goal: 8
property int barHeight: 110
implicitHeight: barHeight + 18
readonly property int maxValue: {
var top = goal
for (var i = 0; i < days.length; i++) top = Math.max(top, days[i].pomodoros)
return Math.max(top, 1)
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
y: chart.barHeight - (chart.goal / chart.maxValue) * chart.barHeight
height: 1
color: Material.hintTextColor
opacity: 0.45
visible: chart.goal > 0
}
RowLayout {
anchors.left: parent.left
anchors.right: parent.right
height: chart.barHeight
spacing: 2
Repeater {
model: chart.days
delegate: Item {
required property var modelData
Layout.fillWidth: true
Layout.fillHeight: true
Rectangle {
anchors.bottom: parent.bottom
width: parent.width
height: Math.max(modelData.pomodoros > 0
? (modelData.pomodoros / chart.maxValue) * chart.barHeight : 2, 2)
radius: 4
color: modelData.pomodoros === 0 ? Material.dividerColor
: (modelData.goal_met ? "#256abf" : "#6da7ec")
ToolTip.visible: hover.hovered
ToolTip.text: modelData.date + ": " + modelData.pomodoros
+ " pomodoros · " + modelData.focus_minutes + " min"
HoverHandler { id: hover }
Behavior on height { NumberAnimation { duration: 250 } }
}
}
}
}
RowLayout {
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
Label {
text: chart.days.length ? chart.days[0].date.substring(5) : ""
font.pixelSize: 10; opacity: 0.6
}
Item { Layout.fillWidth: true }
Label {
text: chart.days.length ? chart.days[chart.days.length - 1].date.substring(5) : ""
font.pixelSize: 10; opacity: 0.6
}
}
}
@@ -0,0 +1,51 @@
// A Material surface with a title. Used everywhere instead of bare Panes so the
// pages share one radius, one padding and one elevation.
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Layouts
Pane {
id: card
property string title: ""
property string subtitle: ""
default property alias content: inner.data
Material.elevation: 1
Material.roundedScale: Material.LargeScale
padding: 18
Layout.fillWidth: true
background: Rectangle {
radius: 18
color: Material.dialogColor
border.width: 1
border.color: Material.dividerColor
}
ColumnLayout {
anchors.fill: parent
spacing: 10
ColumnLayout {
spacing: 2
visible: card.title !== ""
Layout.fillWidth: true
Label { text: card.title; font.pixelSize: 16; font.weight: Font.DemiBold }
Label {
text: card.subtitle
visible: card.subtitle !== ""
font.pixelSize: 12
opacity: 0.7
Layout.fillWidth: true
wrapMode: Text.WordWrap
}
}
ColumnLayout {
id: inner
Layout.fillWidth: true
Layout.fillHeight: true
spacing: 10
}
}
}
@@ -0,0 +1,34 @@
// The filled Material button, painted explicitly.
//
// Relying on `highlighted: true` leaves the fill to the style, and under some
// renderers that fill never appears — white text on a white surface. The
// primary action on a page is exactly the thing that must never be invisible,
// so it draws its own pill.
import QtQuick
import QtQuick.Controls.Material
Button {
id: control
Material.foreground: "white"
font.weight: Font.Medium
leftPadding: 20
rightPadding: 20
implicitHeight: 40
background: Rectangle {
radius: height / 2
color: control.enabled ? Material.accent : Material.buttonDisabledColor
opacity: control.pressed ? 0.82 : (control.hovered ? 0.92 : 1)
Behavior on opacity { NumberAnimation { duration: 120 } }
}
contentItem: Label {
text: control.text
font: control.font
color: control.enabled ? "white" : Material.hintTextColor
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
@@ -0,0 +1,33 @@
// Transient messages. Material's own popup, anchored to the bottom.
import QtQuick
import QtQuick.Controls.Material
Popup {
id: snackbar
property string message: ""
function show(text) {
message = text
open()
timer.restart()
}
x: Math.round((parent.width - width) / 2)
y: parent.height - height - 72
width: Math.min(parent.width - 48, Math.max(260, label.implicitWidth + 48))
padding: 16
modal: false
focus: false
closePolicy: Popup.NoAutoClose
Material.elevation: 6
Material.roundedScale: Material.SmallScale
Label {
id: label
anchors.fill: parent
text: snackbar.message
wrapMode: Text.WordWrap
}
Timer { id: timer; interval: 5000; onTriggered: snackbar.close() }
}
@@ -0,0 +1,38 @@
// One number, read at a glance. Not a chart.
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Layouts
Pane {
property string label: ""
property string value: "—"
property string caption: ""
property color accent: Material.foreground
Material.elevation: 1
Material.roundedScale: Material.LargeScale
padding: 16
Layout.fillWidth: true
background: Rectangle {
radius: 16
color: Material.dialogColor
border.width: 1
border.color: Material.dividerColor
}
ColumnLayout {
anchors.fill: parent
spacing: 2
Label { text: label.toUpperCase(); font.pixelSize: 10; font.letterSpacing: 1; opacity: 0.6 }
Label { text: value; font.pixelSize: 26; font.weight: Font.Light; color: accent }
Label {
text: caption
visible: caption !== ""
font.pixelSize: 11
opacity: 0.7
Layout.fillWidth: true
elide: Text.ElideRight
}
}
}
@@ -0,0 +1,29 @@
// The little "what is the timer doing" chip, shown in the rail and the toolbar.
import QtQuick
import QtQuick.Controls.Material
Rectangle {
property bool running: false
property bool paused: false
property bool connected: true
property string text: ""
property color accent: "#2a78d6"
implicitWidth: Math.max(78, label.implicitWidth + 28)
implicitHeight: 32
radius: 16
color: !connected ? "#e34948" : (running ? accent : "transparent")
opacity: paused ? 0.6 : 1
border.width: running || !connected ? 0 : 1
border.color: Material.hintTextColor
Label {
id: label
anchors.centerIn: parent
text: parent.text
font.pixelSize: 13
font.weight: Font.DemiBold
font.family: "Roboto Mono, monospace"
color: (parent.running || !parent.connected) ? "white" : Material.foreground
}
}
@@ -0,0 +1,87 @@
// The countdown ring, drawn with Canvas.
//
// A hero number inside a progress arc: the number is what you read, the arc is
// the glance. The arc colour is the only thing that says focus vs break.
import QtQuick
import QtQuick.Controls.Material
Item {
id: dial
property real progress: 0 // 0..1 elapsed
property string clockText: "00:00"
property string label: ""
property string sublabel: ""
property color accent: "#2a78d6"
property bool paused: false
implicitWidth: 260
implicitHeight: 260
onProgressChanged: canvas.requestPaint()
onAccentChanged: canvas.requestPaint()
onPausedChanged: canvas.requestPaint()
Canvas {
id: canvas
anchors.fill: parent
antialiasing: true
onPaint: {
var ctx = getContext("2d")
ctx.reset()
var centre = Math.min(width, height) / 2
var stroke = Math.max(10, centre * 0.11)
var radius = centre - stroke
ctx.lineCap = "round"
ctx.lineWidth = stroke
ctx.beginPath()
ctx.strokeStyle = Qt.rgba(0.5, 0.5, 0.5, 0.18)
ctx.arc(centre, centre, radius, 0, Math.PI * 2)
ctx.stroke()
var remaining = 1 - Math.max(0, Math.min(1, dial.progress))
if (remaining > 0) {
ctx.beginPath()
ctx.globalAlpha = dial.paused ? 0.45 : 1
ctx.strokeStyle = dial.accent
ctx.arc(centre, centre, radius, -Math.PI / 2, -Math.PI / 2 + remaining * Math.PI * 2)
ctx.stroke()
}
}
}
Column {
anchors.centerIn: parent
spacing: 4
width: parent.width * 0.72
Label {
width: parent.width
text: dial.label.toUpperCase()
font.pixelSize: 11
font.letterSpacing: 1.5
opacity: 0.65
horizontalAlignment: Text.AlignHCenter
}
Label {
width: parent.width
text: dial.clockText
font.pixelSize: Math.round(dial.width * 0.2)
font.weight: Font.Light
font.family: "Roboto Mono, monospace"
opacity: dial.paused ? 0.6 : 1
horizontalAlignment: Text.AlignHCenter
}
Label {
width: parent.width
text: dial.sublabel
visible: dial.sublabel !== ""
font.pixelSize: 13
opacity: 0.75
elide: Text.ElideRight
horizontalAlignment: Text.AlignHCenter
}
}
}
+186
View File
@@ -0,0 +1,186 @@
"""The tray icon — on KDE this becomes a StatusNotifierItem, so it behaves.
The icon is painted at runtime rather than loaded from a file, because it *is*
the timer: a ring that empties as the interval runs, tinted by what is running
(focus / break / idle). With the window closed you still have a countdown, a
tooltip with the full status, and a menu that can drive everything.
"""
from __future__ import annotations
from PyQt6.QtCore import QObject, QRectF, Qt, pyqtSignal
from PyQt6.QtGui import QAction, QColor, QIcon, QPainter, QPen, QPixmap
from PyQt6.QtWidgets import QMenu, QSystemTrayIcon
# The same validated hues the charts use, so the tray matches the app.
COLOR_WORK = QColor("#2a78d6")
COLOR_SHORT = QColor("#eb6834")
COLOR_LONG = QColor("#1baf7a")
COLOR_IDLE = QColor("#8a8a8a")
COLOR_OFFLINE = QColor("#e34948")
_SIZE = 64
def _state_color(kind: str, running: bool, connected: bool) -> QColor:
if not connected:
return COLOR_OFFLINE
if not running:
return COLOR_IDLE
return {"work": COLOR_WORK, "short_break": COLOR_SHORT, "long_break": COLOR_LONG}.get(kind, COLOR_WORK)
def render_icon(progress: float, kind: str, running: bool, connected: bool, paused: bool) -> QIcon:
"""A ring that drains as the interval runs."""
pixmap = QPixmap(_SIZE, _SIZE)
pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
color = _state_color(kind, running, connected)
rect = QRectF(7, 7, _SIZE - 14, _SIZE - 14)
track = QColor(color)
track.setAlpha(60)
painter.setPen(QPen(track, 8, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap))
painter.drawArc(rect, 0, 360 * 16)
if running:
painter.setPen(QPen(color, 8, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap))
span = int(-360 * 16 * max(0.0, min(1.0, 1 - progress)))
painter.drawArc(rect, 90 * 16, span)
if paused:
# Two bars in the middle: unmistakably "on hold".
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(color)
painter.drawRect(QRectF(24, 23, 6, 18))
painter.drawRect(QRectF(34, 23, 6, 18))
else:
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(color)
painter.drawEllipse(QRectF(26, 26, 12, 12))
painter.end()
return QIcon(pixmap)
class Tray(QObject):
"""Tray icon plus its menu. Every entry maps to a controller slot."""
showRequested = pyqtSignal()
quitRequested = pyqtSignal()
def __init__(self, controller, parent: QObject | None = None) -> None:
super().__init__(parent)
self._controller = controller
self._icon = QSystemTrayIcon(parent)
self._menu = QMenu()
self._build_menu()
self._icon.setContextMenu(self._menu)
self._icon.activated.connect(self._on_activated)
controller.stateChanged.connect(self.refresh)
controller.connectionChanged.connect(self.refresh)
controller.blockerChanged.connect(self.refresh)
self.refresh()
def show(self) -> None:
self._icon.show()
def hide(self) -> None:
self._icon.hide()
@property
def available(self) -> bool:
return QSystemTrayIcon.isSystemTrayAvailable()
def _build_menu(self) -> None:
controller = self._controller
self._action_status = QAction("…", self._menu)
self._action_status.setEnabled(False)
self._menu.addAction(self._action_status)
self._menu.addSeparator()
self._action_start = QAction("Start focus", self._menu)
self._action_start.triggered.connect(lambda: controller.startWork())
self._menu.addAction(self._action_start)
self._action_break = QAction("Start break", self._menu)
self._action_break.triggered.connect(controller.startBreak)
self._menu.addAction(self._action_break)
self._action_pause = QAction("Pause", self._menu)
self._action_pause.triggered.connect(controller.pause)
self._menu.addAction(self._action_pause)
self._action_resume = QAction("Resume", self._menu)
self._action_resume.triggered.connect(controller.resume)
self._menu.addAction(self._action_resume)
self._action_done = QAction("Finish now", self._menu)
self._action_done.triggered.connect(controller.complete)
self._menu.addAction(self._action_done)
self._action_extend = QAction("+5 minutes", self._menu)
self._action_extend.triggered.connect(lambda: controller.extend(5))
self._menu.addAction(self._action_extend)
self._action_abandon = QAction("Abandon", self._menu)
self._action_abandon.triggered.connect(lambda: controller.abandon("abandoned from the tray"))
self._menu.addAction(self._action_abandon)
self._menu.addSeparator()
report = QAction("Send today's report", self._menu)
report.triggered.connect(controller.sendReport)
self._menu.addAction(report)
refresh = QAction("Refresh tasks", self._menu)
refresh.triggered.connect(controller.refreshSources)
self._menu.addAction(refresh)
self._menu.addSeparator()
show = QAction("Open Kadence", self._menu)
show.triggered.connect(self.showRequested.emit)
self._menu.addAction(show)
web = QAction("Open the web app", self._menu)
web.triggered.connect(controller.openWebUi)
self._menu.addAction(web)
self._menu.addSeparator()
quit_action = QAction("Quit", self._menu)
quit_action.triggered.connect(self.quitRequested.emit)
self._menu.addAction(quit_action)
def refresh(self) -> None:
controller = self._controller
running = controller.running
paused = controller.status == "paused"
self._icon.setIcon(
render_icon(controller.progress, controller.kind, running, controller.connected, paused)
)
status = controller.statusLine()
blocker = " · 🛡 blocking" if controller.blockerActive else ""
self._icon.setToolTip(f"{status}{blocker}")
self._action_status.setText(status)
self._action_start.setVisible(not running)
self._action_break.setVisible(not running)
self._action_pause.setVisible(running and not paused)
self._action_resume.setVisible(running and paused)
self._action_done.setVisible(running)
self._action_extend.setVisible(running)
self._action_abandon.setVisible(running)
def _on_activated(self, reason: QSystemTrayIcon.ActivationReason) -> None:
# Left click toggles the window; middle click toggles the timer, which is
# the fastest "start/pause" on the whole desktop.
if reason == QSystemTrayIcon.ActivationReason.Trigger:
self.showRequested.emit()
elif reason == QSystemTrayIcon.ActivationReason.MiddleClick:
self._controller.toggle()
def message(self, title: str, body: str) -> None:
self._icon.showMessage(title, body, QSystemTrayIcon.MessageIcon.Information, 5000)
+164
View File
@@ -0,0 +1,164 @@
"""The network thread.
Every HTTP call happens here, on a worker thread, so a slow or dead backend can
never freeze the window — the UI keeps ticking its countdown from the last known
state and shows a "reconnecting" chip instead.
Commands go in through a queued slot and come back out as signals; the GUI side
never touches `Api` directly.
"""
from __future__ import annotations
import logging
from typing import Any
from PyQt6.QtCore import QObject, QTimer, pyqtSignal, pyqtSlot
from kadence_desktop.api import Api, ApiError
log = logging.getLogger(__name__)
class ApiWorker(QObject):
stateChanged = pyqtSignal(dict)
policyChanged = pyqtSignal(dict)
tasksChanged = pyqtSignal(dict)
statsChanged = pyqtSignal(dict)
reportChanged = pyqtSignal(dict)
settingsChanged = pyqtSignal(dict)
connectionChanged = pyqtSignal(bool, str)
commandFailed = pyqtSignal(str, str)
commandDone = pyqtSignal(str, dict)
def __init__(self, api: Api, poll_seconds: int = 5) -> None:
super().__init__()
self._api = api
self._poll_seconds = poll_seconds
self._timer: QTimer | None = None
self._connected: bool | None = None
self._slow_tick = 0
# ---- lifecycle ---------------------------------------------------------
@pyqtSlot()
def start(self) -> None:
"""Called once the worker is on its own thread."""
self._timer = QTimer(self)
self._timer.timeout.connect(self.poll)
self._timer.start(max(1, self._poll_seconds) * 1000)
self.poll()
self.refresh_tasks()
self.refresh_stats()
@pyqtSlot(int)
def set_interval(self, seconds: int) -> None:
if self._timer:
self._timer.start(max(1, seconds) * 1000)
@pyqtSlot(str, str)
def configure(self, base_url: str, token: str) -> None:
self._api.configure(base_url, token)
self._connected = None
self.poll()
# ---- polling -----------------------------------------------------------
@pyqtSlot()
def poll(self) -> None:
try:
state = self._api.timer_state()
policy = self._api.blocker_policy()
except ApiError as exc:
self._set_connected(False, str(exc))
return
self._set_connected(True, "")
self.stateChanged.emit(state)
self.policyChanged.emit(policy)
# Task and stats data changes far more slowly than the clock does.
self._slow_tick += 1
if self._slow_tick % 12 == 0:
self.refresh_tasks()
self.refresh_stats()
def _set_connected(self, connected: bool, detail: str) -> None:
if connected != self._connected:
self._connected = connected
self.connectionChanged.emit(connected, detail)
@pyqtSlot()
def refresh_tasks(self) -> None:
try:
self.tasksChanged.emit(self._api.tasks())
except ApiError as exc:
log.debug("tasks: %s", exc)
@pyqtSlot()
def refresh_stats(self) -> None:
try:
self.statsChanged.emit(self._api.stats())
except ApiError as exc:
log.debug("stats: %s", exc)
@pyqtSlot()
def refresh_report(self) -> None:
try:
self.reportChanged.emit(self._api.daily_report())
except ApiError as exc:
self.commandFailed.emit("report", str(exc))
@pyqtSlot()
def refresh_settings(self) -> None:
try:
self.settingsChanged.emit(self._api.settings())
except ApiError as exc:
self.commandFailed.emit("settings", str(exc))
# ---- commands ----------------------------------------------------------
@pyqtSlot(str, dict)
def command(self, name: str, args: dict[str, Any]) -> None:
"""Run one API action by name and re-poll so the UI lands on the truth."""
handlers = {
"start": lambda: self._api.start(args.get("kind"), args.get("task_id"), args.get("minutes")),
"pause": self._api.pause,
"resume": self._api.resume,
"complete": lambda: self._api.complete(bool(args.get("auto_next", True))),
"abandon": lambda: self._api.abandon(args.get("reason")),
"extend": lambda: self._api.extend(int(args.get("minutes", 5))),
"interrupt": self._api.interrupt,
"attach_task": lambda: self._api.attach_task(args.get("task_id")),
"task_done": lambda: self._api.task_done(int(args["task_id"]), bool(args.get("done", True))),
"create_task": lambda: self._api.create_task(str(args["title"]), args.get("due_date")),
"refresh_sources": self._api.refresh_tasks,
"send_report": self._api.send_report,
"patch_settings": lambda: self._api.patch_settings(dict(args.get("patch", {}))),
"report_block": lambda: self._api.report_block(
str(args.get("kind", "app")),
str(args.get("target", "")),
str(args.get("action", "blocked")),
args.get("details"),
),
}
handler = handlers.get(name)
if handler is None:
self.commandFailed.emit(name, f"unknown command {name!r}")
return
try:
result = handler()
except ApiError as exc:
self.commandFailed.emit(name, str(exc))
return
except Exception as exc: # pragma: no cover
log.exception("command %s blew up", name)
self.commandFailed.emit(name, str(exc))
return
self.commandDone.emit(name, result if isinstance(result, dict) else {})
if name in ("refresh_sources", "task_done", "create_task"):
self.refresh_tasks()
if name == "patch_settings":
self.refresh_settings()
if name != "report_block":
self.poll()
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Installs Kadence's launcher entry and icon for the current user.
#
# The app itself runs straight from the checkout (`python3 -m kadence_desktop`)
# or from a pip install; this just makes it appear in the application menu with
# a proper icon and the right-click actions.
set -euo pipefail
HERE="$(dirname "$(readlink -f "$0")")"
APPS="${XDG_DATA_HOME:-$HOME/.local/share}/applications"
ICONS="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor/scalable/apps"
BIN="${HOME}/.local/bin"
mkdir -p "$APPS" "$ICONS" "$BIN"
# A launcher that works whether or not the package is pip-installed.
cat > "$BIN/kadence-desktop" <<LAUNCHER
#!/usr/bin/env bash
exec python3 -m kadence_desktop "\$@"
LAUNCHER
chmod +x "$BIN/kadence-desktop"
# Make the package importable from wherever this checkout lives.
SITE_PTH="$(python3 -c 'import site; print(site.getusersitepackages())')"
mkdir -p "$SITE_PTH"
echo "$(dirname "$HERE")" > "$SITE_PTH/kadence-desktop.pth"
install -m 0644 "$HERE/kadence.desktop" "$APPS/kadence.desktop"
install -m 0644 "$HERE/kadence.svg" "$ICONS/kadence.svg"
command -v update-desktop-database >/dev/null && update-desktop-database "$APPS" || true
command -v gtk-update-icon-cache >/dev/null && gtk-update-icon-cache -f -t "${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor" 2>/dev/null || true
echo "Installed. Launch it from the menu, or run: kadence-desktop"
echo "Make sure ~/.local/bin is on your PATH."
+34
View File
@@ -0,0 +1,34 @@
[Desktop Entry]
Type=Application
Name=Kadence
GenericName=Pomodoro timer
Comment=Pomodoro timer, task list from Jira/Todoist/Google Tasks, and a focus guard
Exec=kadence-desktop
TryExec=kadence-desktop
Icon=kadence
Terminal=false
Categories=Utility;Office;ProjectManagement;
Keywords=pomodoro;timer;focus;tasks;productivity;
StartupNotify=true
StartupWMClass=kadence
SingleMainWindow=true
X-GNOME-UsesNotifications=true
# Right-click the icon in the launcher or the task manager for these.
Actions=StartFocus;StartBreak;Toggle;Report;
[Desktop Action StartFocus]
Name=Start a pomodoro
Exec=kadence-desktop --start
[Desktop Action StartBreak]
Name=Start a break
Exec=kadence-desktop --break
[Desktop Action Toggle]
Name=Start / pause
Exec=kadence-desktop --toggle
[Desktop Action Report]
Name=Send today's report
Exec=kadence-desktop --report
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Kadence: a tomato as a timer ring — the pomodoro and the countdown in one mark. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<defs>
<linearGradient id="leaf" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#4caf50"/>
<stop offset="1" stop-color="#2e7d32"/>
</linearGradient>
</defs>
<circle cx="32" cy="36" r="22" fill="#e8502f"/>
<path d="M32 14 A22 22 0 0 1 54 36" fill="none" stroke="#ffffff" stroke-width="5"
stroke-linecap="round" opacity="0.92"/>
<path d="M26 13c3-4 9-4 12 0 2-3 6-4 9-2-2 4-6 6-10 6h-10c-4 0-8-2-10-6 3-2 7-1 9 2z"
fill="url(#leaf)"/>
<rect x="30.5" y="8" width="3" height="8" rx="1.5" fill="#2e7d32"/>
</svg>

After

Width:  |  Height:  |  Size: 781 B

+19
View File
@@ -0,0 +1,19 @@
[project]
name = "kadence-desktop"
version = "0.1.0"
description = "Native Qt/QML desktop app for Kadence: tray, D-Bus, notifications, website and app blocking."
requires-python = ">=3.11"
# PyQt6 is the only dependency, and on Arch it is already a system package
# (python-pyqt6). Everything else — HTTP, config, process scanning — is stdlib,
# so the app starts with the system Python and no virtualenv.
dependencies = ["PyQt6>=6.6"]
[project.scripts]
kadence-desktop = "kadence_desktop.__main__:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["kadence_desktop"]
+31
View File
@@ -0,0 +1,31 @@
# Development overlay: live reload, the source mounted, SQLite instead of
# Postgres, and the Vite dev server for the UI.
#
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up
#
# The UI then runs on http://localhost:5173 and proxies /api to the backend.
services:
app:
build:
context: .
target: runtime
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
user: root
volumes:
- ./backend/app:/app/app:ro
environment:
LOG_LEVEL: DEBUG
web:
image: node:22-alpine
working_dir: /web
command: sh -c "npm install --no-audit --no-fund && npm run dev -- --host 0.0.0.0"
environment:
VITE_API_TARGET: http://app:8000
volumes:
- ./frontend:/web
ports:
- "5173:5173"
depends_on:
- app
+67
View File
@@ -0,0 +1,67 @@
# Kadence: API + web UI in one container, Postgres beside it.
#
# cp .env.example .env # fill in Jira / Todoist / Telegram
# docker compose up -d # http://localhost:8000
#
# The native desktop app runs on your machine and points at this backend.
services:
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-kadence}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-kadence}
POSTGRES_DB: ${POSTGRES_DB:-kadence}
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
# The app's first query happens seconds after boot, so the API must not
# start until Postgres can actually answer.
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-kadence} -d ${POSTGRES_DB:-kadence}"]
interval: 5s
timeout: 5s
retries: 10
app:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: >-
postgresql+psycopg://${POSTGRES_USER:-kadence}:${POSTGRES_PASSWORD:-kadence}@db:5432/${POSTGRES_DB:-kadence}
TZ: ${TIMEZONE:-Europe/Moscow}
TIMEZONE: ${TIMEZONE:-Europe/Moscow}
CORS_ORIGINS: ${CORS_ORIGINS:-*}
API_TOKEN: ${API_TOKEN:-}
SCHEDULER_ENABLED: ${SCHEDULER_ENABLED:-true}
TELEGRAM_BOT_ENABLED: ${TELEGRAM_BOT_ENABLED:-true}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
# Seeds for the editable settings — the same variable names jira-helper
# uses, so an existing .env can be copied across unchanged.
JIRA_BASE_URL: ${JIRA_BASE_URL:-}
JIRA_EMAIL: ${JIRA_EMAIL:-}
JIRA_API_TOKEN: ${JIRA_API_TOKEN:-}
JIRA_EXTRA_JQL: ${JIRA_EXTRA_JQL:-}
JIRA_START_DATE_FIELD: ${JIRA_START_DATE_FIELD:-customfield_10015}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
GOOGLE_REFRESH_TOKEN: ${GOOGLE_REFRESH_TOKEN:-}
GOOGLE_TASKS_LISTS: ${GOOGLE_TASKS_LISTS:-}
TODOIST_API_TOKEN: ${TODOIST_API_TOKEN:-}
TODOIST_PROJECTS: ${TODOIST_PROJECTS:-}
LAUNDRY_SESSION: ${LAUNDRY_SESSION:-}
LAUNDRY_BASE_URL: ${LAUNDRY_BASE_URL:-https://4ka.inbicst.ru}
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
SLACK_WEBHOOK_URL: ${SLACK_WEBHOOK_URL:-}
ports:
- "${APP_PORT:-8000}:8000"
volumes:
db-data:
+20
View File
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#6750A4" />
<title>Kadence</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🍅</text></svg>" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&family=Roboto+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2572
View File
File diff suppressed because it is too large. Load diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "kadence-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@mui/icons-material": "^6.1.6",
"@mui/material": "^6.1.6",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.27.0"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"typescript": "^5.6.3",
"vite": "^5.4.10"
}
}
+75
View File
@@ -0,0 +1,75 @@
import { useEffect, useMemo, useState } from "react";
import { Alert, Box, CssBaseline, LinearProgress, Snackbar, ThemeProvider, useMediaQuery } from "@mui/material";
import { Navigate, Route, Routes } from "react-router-dom";
import AppShell from "./components/AppShell";
import { SettingsProvider, useSettings } from "./lib/settings";
import { buildTheme, type ThemeMode } from "./theme";
import BlockerPage from "./pages/BlockerPage";
import ReportPage from "./pages/ReportPage";
import SettingsPage from "./pages/SettingsPage";
import TasksPage from "./pages/TasksPage";
import TimerPage from "./pages/TimerPage";
function Shell() {
const { settings, loading, error } = useSettings();
const prefersDark = useMediaQuery("(prefers-color-scheme: dark)");
const [toast, setToast] = useState<string | null>(null);
const mode: ThemeMode = useMemo(() => {
const preference = settings?.appearance.theme ?? "system";
if (preference === "system") return prefersDark ? "dark" : "light";
return preference;
}, [settings?.appearance.theme, prefersDark]);
const theme = useMemo(
() => buildTheme(mode, settings?.appearance.accent || "#6750A4", settings?.appearance.compact ?? false),
[mode, settings?.appearance.accent, settings?.appearance.compact],
);
// Keep the browser chrome in step with the app's surface.
useEffect(() => {
document.documentElement.dataset.theme = mode;
document.body.style.backgroundColor = theme.palette.background.default;
}, [mode, theme]);
return (
<ThemeProvider theme={theme}>
<CssBaseline />
{loading && <LinearProgress />}
{error && (
<Box sx={{ p: 2 }}>
<Alert severity="error">
The backend is not reachable ({error}). Start it with{" "}
<code>docker compose up</code> or check the API token in Settings.
</Alert>
</Box>
)}
<AppShell onToast={setToast} dark={mode === "dark"}>
<Routes>
<Route path="/" element={<TimerPage onToast={setToast} dark={mode === "dark"} />} />
<Route path="/tasks" element={<TasksPage onToast={setToast} />} />
<Route path="/blocker" element={<BlockerPage onToast={setToast} />} />
<Route path="/report" element={<ReportPage dark={mode === "dark"} onToast={setToast} />} />
<Route path="/settings" element={<SettingsPage onToast={setToast} />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</AppShell>
<Snackbar
open={toast !== null}
autoHideDuration={5000}
onClose={() => setToast(null)}
message={toast ?? ""}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
/>
</ThemeProvider>
);
}
export default function App() {
return (
<SettingsProvider>
<Shell />
</SettingsProvider>
);
}
+234
View File
@@ -0,0 +1,234 @@
/**
* The frame: a navigation rail on a wide screen, a bottom bar on a phone.
*
* It also carries the two things that have to be visible from every page — what
* the timer is doing, and whether the blocker is on — because both are reasons
* you might switch pages in the first place.
*/
import { useEffect, type ReactNode } from "react";
import {
AppBar,
Avatar,
Badge,
Box,
BottomNavigation,
BottomNavigationAction,
Chip,
Drawer,
List,
ListItemButton,
ListItemIcon,
ListItemText,
Stack,
Toolbar,
Tooltip,
Typography,
useMediaQuery,
useTheme,
} from "@mui/material";
import AssignmentTurnedInIcon from "@mui/icons-material/AssignmentTurnedIn";
import BarChartIcon from "@mui/icons-material/BarChart";
import ShieldIcon from "@mui/icons-material/Shield";
import SettingsIcon from "@mui/icons-material/Settings";
import TimerIcon from "@mui/icons-material/Timer";
import { useLocation, useNavigate } from "react-router-dom";
import { clock } from "../lib/format";
import { useResource } from "../lib/hooks";
import type { BlockerPolicy, TimerState } from "../lib/types";
const NAV = [
{ to: "/", label: "Timer", icon: <TimerIcon /> },
{ to: "/tasks", label: "Tasks", icon: <AssignmentTurnedInIcon /> },
{ to: "/blocker", label: "Blocker", icon: <ShieldIcon /> },
{ to: "/report", label: "Report", icon: <BarChartIcon /> },
{ to: "/settings", label: "Settings", icon: <SettingsIcon /> },
];
const RAIL_WIDTH = 220;
export default function AppShell({
children,
dark,
}: {
children: ReactNode;
onToast: (message: string) => void;
dark: boolean;
}) {
const theme = useTheme();
const wide = useMediaQuery(theme.breakpoints.up("md"));
const navigate = useNavigate();
const location = useLocation();
const timer = useResource<TimerState>("/api/timer/state", 5000);
const policy = useResource<BlockerPolicy>("/api/blocker/policy", 10000);
const current = timer.data?.current ?? null;
const remaining = current ? current.remaining_seconds : 0;
// The tab title doubles as a timer you can read from another window.
useEffect(() => {
document.title = current
? `${clock(remaining)} · ${current.kind === "work" ? "Focus" : "Break"} — Kadence`
: "Kadence";
}, [current, remaining]);
const activeIndex = Math.max(
0,
NAV.findIndex((item) => item.to === location.pathname),
);
return (
<Box sx={{ display: "flex", minHeight: "100vh", bgcolor: "background.default" }}>
{wide && (
<Drawer
variant="permanent"
sx={{
width: RAIL_WIDTH,
flexShrink: 0,
"& .MuiDrawer-paper": {
width: RAIL_WIDTH,
borderRight: "none",
bgcolor: "background.default",
px: 1.5,
py: 2,
},
}}
>
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ px: 1.5, pb: 3 }}>
<Avatar sx={{ bgcolor: "primary.main", width: 36, height: 36 }}>🍅</Avatar>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 600, lineHeight: 1.1 }}>
Kadence
</Typography>
<Typography variant="caption" color="text.secondary">
find your rhythm
</Typography>
</Box>
</Stack>
<List sx={{ flexGrow: 1 }}>
{NAV.map((item) => {
const selected = location.pathname === item.to;
return (
<ListItemButton
key={item.to}
selected={selected}
onClick={() => navigate(item.to)}
sx={{
borderRadius: 999,
mb: 0.5,
"&.Mui-selected": {
bgcolor: "secondary.main",
color: dark ? "common.black" : "common.white",
"& .MuiListItemIcon-root": { color: "inherit" },
"&:hover": { bgcolor: "secondary.main" },
},
}}
>
<ListItemIcon sx={{ minWidth: 40 }}>{item.icon}</ListItemIcon>
<ListItemText primary={item.label} primaryTypographyProps={{ fontWeight: 500 }} />
</ListItemButton>
);
})}
</List>
<StatusChips timer={timer.data} policy={policy.data} />
</Drawer>
)}
<Box sx={{ flexGrow: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
{!wide && (
<AppBar position="sticky" sx={{ bgcolor: "background.default" }}>
<Toolbar sx={{ gap: 1 }}>
<Avatar sx={{ bgcolor: "primary.main", width: 32, height: 32 }}>🍅</Avatar>
<Typography variant="h6" sx={{ flexGrow: 1, color: "text.primary" }}>
{NAV[activeIndex]?.label ?? "Kadence"}
</Typography>
<StatusChips timer={timer.data} policy={policy.data} compact />
</Toolbar>
</AppBar>
)}
<Box
component="main"
sx={{
flexGrow: 1,
px: { xs: 2, md: 4 },
py: { xs: 2, md: 3 },
pb: { xs: 12, md: 4 },
maxWidth: 1280,
width: "100%",
mx: "auto",
}}
>
{children}
</Box>
{!wide && (
<BottomNavigation
value={activeIndex}
onChange={(_event, index) => navigate(NAV[index].to)}
showLabels
sx={{
position: "fixed",
bottom: 0,
left: 0,
right: 0,
borderTop: 1,
borderColor: "divider",
bgcolor: "background.paper",
zIndex: (zTheme) => zTheme.zIndex.appBar,
}}
>
{NAV.map((item) => (
<BottomNavigationAction key={item.to} label={item.label} icon={item.icon} />
))}
</BottomNavigation>
)}
</Box>
</Box>
);
}
function StatusChips({
timer,
policy,
compact = false,
}: {
timer: TimerState | null;
policy: BlockerPolicy | null;
compact?: boolean;
}) {
const current = timer?.current ?? null;
return (
<Stack direction={compact ? "row" : "column"} spacing={1} sx={{ px: compact ? 0 : 1 }}>
<Tooltip title={current ? `${current.kind} · ${current.status}` : "Timer idle"}>
<Chip
size="small"
color={current ? (current.kind === "work" ? "primary" : "success") : "default"}
variant={current ? "filled" : "outlined"}
label={current ? clock(current.remaining_seconds) : "idle"}
sx={{ fontVariantNumeric: "tabular-nums", fontWeight: 600 }}
/>
</Tooltip>
{!compact && timer && (
<Chip
size="small"
variant="outlined"
label={`${timer.completed_today}/${timer.goal} today`}
sx={{ justifyContent: "flex-start" }}
/>
)}
<Tooltip title={policy?.reason ?? "Blocker off"}>
<Badge color="error" variant="dot" invisible={!policy?.active}>
<Chip
size="small"
variant={policy?.active ? "filled" : "outlined"}
color={policy?.active ? "error" : "default"}
icon={<ShieldIcon fontSize="small" />}
label={policy?.active ? "blocking" : "open"}
/>
</Badge>
</Tooltip>
</Stack>
);
}
+43
View File
@@ -0,0 +1,43 @@
/**
* A number with its label. Not a chart: one value, read in a glance.
*/
import { Card, CardContent, Stack, Typography } from "@mui/material";
import type { ReactNode } from "react";
export default function StatTile({
label,
value,
caption,
icon,
accent,
}: {
label: string;
value: ReactNode;
caption?: ReactNode;
icon?: ReactNode;
accent?: string;
}) {
return (
<Card sx={{ height: "100%" }}>
<CardContent sx={{ py: 2 }}>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 0.5 }}>
{icon}
<Typography variant="overline" color="text.secondary" noWrap>
{label}
</Typography>
</Stack>
<Typography
variant="h4"
sx={{ fontVariantNumeric: "tabular-nums", color: accent ?? "text.primary" }}
>
{value}
</Typography>
{caption && (
<Typography variant="body2" color="text.secondary">
{caption}
</Typography>
)}
</CardContent>
</Card>
);
}
+110
View File
@@ -0,0 +1,110 @@
/**
* The countdown ring.
*
* A hero number with a progress arc around it: the number is what you read, the
* arc is the glanceable "how much is left". The track stays recessive and the
* arc carries the state colour, so the dial says work/break without a label.
*/
import { Box, Stack, Typography, useTheme } from "@mui/material";
import { clock } from "../lib/format";
const SIZE = 280;
const STROKE = 14;
export default function TimerDial({
remaining,
total,
label,
sublabel,
color,
paused,
}: {
remaining: number;
total: number;
label: string;
sublabel?: string;
color: string;
paused?: boolean;
}) {
const theme = useTheme();
const radius = (SIZE - STROKE) / 2;
const circumference = 2 * Math.PI * radius;
const progress = total > 0 ? Math.min(1, Math.max(0, 1 - remaining / total)) : 0;
return (
<Box
sx={{
position: "relative",
width: SIZE,
height: SIZE,
maxWidth: "100%",
aspectRatio: "1 / 1",
mx: "auto",
}}
>
<svg
viewBox={`0 0 ${SIZE} ${SIZE}`}
width="100%"
height="100%"
role="img"
aria-label={`${label}: ${clock(remaining)} remaining`}
>
<circle
cx={SIZE / 2}
cy={SIZE / 2}
r={radius}
fill="none"
stroke={theme.palette.action.hover}
strokeWidth={STROKE}
/>
<circle
cx={SIZE / 2}
cy={SIZE / 2}
r={radius}
fill="none"
stroke={color}
strokeWidth={STROKE}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={circumference * (1 - progress)}
transform={`rotate(-90 ${SIZE / 2} ${SIZE / 2})`}
opacity={paused ? 0.45 : 1}
style={{ transition: "stroke-dashoffset 1s linear, opacity .2s" }}
/>
</svg>
<Stack
spacing={0.5}
sx={{
position: "absolute",
inset: 0,
alignItems: "center",
justifyContent: "center",
textAlign: "center",
px: 4,
}}
>
<Typography variant="overline" color="text.secondary">
{label}
</Typography>
<Typography
sx={{
fontSize: { xs: 48, sm: 56 },
fontWeight: 300,
lineHeight: 1,
fontVariantNumeric: "tabular-nums",
fontFamily: '"Roboto Mono", ui-monospace, monospace',
opacity: paused ? 0.6 : 1,
}}
>
{clock(remaining)}
</Typography>
{sublabel && (
<Typography variant="body2" color="text.secondary" noWrap sx={{ maxWidth: "100%" }}>
{sublabel}
</Typography>
)}
</Stack>
</Box>
);
}
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff. Show more