init
This commit is contained in:
121 files changed
+17641
No files matched your search
@@ -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
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -0,0 +1,3 @@
|
||||
"""kadence backend: pomodoro engine, todo aggregation, reports."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,20 @@
|
||||
"""HTTP layer. Routers are thin; the thinking happens in `app.services`."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api import (
|
||||
routes_blocker,
|
||||
routes_reports,
|
||||
routes_settings,
|
||||
routes_tasks,
|
||||
routes_timer,
|
||||
)
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(routes_timer.router)
|
||||
api_router.include_router(routes_tasks.router)
|
||||
api_router.include_router(routes_blocker.router)
|
||||
api_router.include_router(routes_reports.router)
|
||||
api_router.include_router(routes_settings.router)
|
||||
|
||||
__all__ = ["api_router"]
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Shared dependencies: the database session and the optional shared secret."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import env_settings
|
||||
from app.db import get_session
|
||||
from app.services.settings_service import load_settings
|
||||
from app.settings_schema import AppSettings
|
||||
|
||||
DbSession = Annotated[Session, Depends(get_session)]
|
||||
|
||||
|
||||
def require_token(x_api_token: Annotated[str | None, Header()] = None) -> None:
|
||||
"""Guard every API call when `API_TOKEN` is set.
|
||||
|
||||
Off by default: on a laptop the backend is bound to localhost and a token is
|
||||
friction. Set it the moment the container is reachable from anywhere else.
|
||||
"""
|
||||
expected = env_settings().api_token
|
||||
if not expected:
|
||||
return
|
||||
if x_api_token != expected:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Bad or missing X-API-Token."
|
||||
)
|
||||
|
||||
|
||||
def get_settings(session: DbSession) -> AppSettings:
|
||||
return load_settings(session)
|
||||
|
||||
|
||||
Settings = Annotated[AppSettings, Depends(get_settings)]
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Blocker endpoints.
|
||||
|
||||
`GET /api/blocker/policy` is the one the desktop app polls every few seconds;
|
||||
everything else is for the UI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from app.api.deps import DbSession, Settings
|
||||
from app.api.schemas import BlockEventRequest
|
||||
from app.services import blocker_service
|
||||
from app.services.notify import notify
|
||||
|
||||
router = APIRouter(prefix="/api/blocker", tags=["blocker"])
|
||||
|
||||
|
||||
@router.get("/policy")
|
||||
def policy(session: DbSession, settings: Settings) -> dict:
|
||||
return blocker_service.policy(session, settings)
|
||||
|
||||
|
||||
@router.get("/events")
|
||||
def events(
|
||||
session: DbSession,
|
||||
settings: Settings,
|
||||
day: date | None = Query(default=None),
|
||||
) -> dict:
|
||||
return blocker_service.events_for_day(session, settings, day)
|
||||
|
||||
|
||||
@router.post("/events")
|
||||
def record(body: BlockEventRequest, session: DbSession, settings: Settings) -> dict:
|
||||
"""The desktop app reports every attempt it turned away.
|
||||
|
||||
These are what make the daily report fun ("you reached for youtube 12
|
||||
times"), so they are recorded even when the blocker only warned.
|
||||
"""
|
||||
event = blocker_service.record_event(
|
||||
session,
|
||||
settings,
|
||||
kind=body.kind,
|
||||
target=body.target,
|
||||
action=body.action,
|
||||
details=body.details,
|
||||
)
|
||||
if settings.blocker.notify_on_block and body.action == "killed":
|
||||
notify(settings, f"🛑 Closed <b>{body.target}</b> — you are in a work interval.")
|
||||
return {"id": event.id, "recorded": True}
|
||||
|
||||
|
||||
@router.get("/defaults")
|
||||
def defaults() -> dict:
|
||||
"""Starter lists, so the blocker page is not an empty text box."""
|
||||
return {"domains": blocker_service.DEFAULT_DOMAINS, "apps": blocker_service.DEFAULT_APPS}
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Report and statistics endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from app.api.deps import DbSession, Settings
|
||||
from app.api.schemas import SendReportRequest, TestNotificationRequest
|
||||
from app.services import notify as notify_service
|
||||
from app.services import report, scheduler, stats
|
||||
from app.timeutil import ensure_utc, local_date
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["reports"])
|
||||
|
||||
|
||||
@router.get("/reports/daily")
|
||||
def daily(
|
||||
session: DbSession,
|
||||
settings: Settings,
|
||||
day: date | None = Query(default=None),
|
||||
rebuild: bool = Query(default=True),
|
||||
) -> dict:
|
||||
target = day or local_date(settings.timezone)
|
||||
row, payload = report.get_or_build_daily(session, settings, target, rebuild=rebuild)
|
||||
return {
|
||||
"report": payload,
|
||||
"sent_at": ensure_utc(row.sent_at).isoformat() if row.sent_at else None,
|
||||
"telegram_preview": report.render_daily_telegram(payload, settings),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/reports/daily/send")
|
||||
def send_daily(body: SendReportRequest, session: DbSession, settings: Settings) -> dict:
|
||||
target = body.report_date or local_date(settings.timezone)
|
||||
return report.send_daily(session, settings, target, force=body.force)
|
||||
|
||||
|
||||
@router.get("/reports/weekly")
|
||||
def weekly(
|
||||
session: DbSession,
|
||||
settings: Settings,
|
||||
end: date | None = Query(default=None),
|
||||
) -> dict:
|
||||
target = end or local_date(settings.timezone)
|
||||
payload = report.build_weekly(session, settings, target)
|
||||
return {"report": payload, "telegram_preview": report.render_weekly_telegram(payload, settings)}
|
||||
|
||||
|
||||
@router.post("/reports/weekly/send")
|
||||
def send_weekly(session: DbSession, settings: Settings) -> dict:
|
||||
payload = report.build_weekly(session, settings, local_date(settings.timezone))
|
||||
text = report.render_weekly_telegram(payload, settings)
|
||||
deliveries = notify_service.notify(settings, text, urgent=True)
|
||||
return {"sent": any(d.ok for d in deliveries), "deliveries": [d.as_dict() for d in deliveries]}
|
||||
|
||||
|
||||
@router.post("/reports/briefing/send")
|
||||
def send_briefing(session: DbSession, settings: Settings) -> dict:
|
||||
text = report.render_morning_briefing(session, settings)
|
||||
deliveries = notify_service.notify(settings, text, urgent=True)
|
||||
return {
|
||||
"sent": any(d.ok for d in deliveries),
|
||||
"deliveries": [d.as_dict() for d in deliveries],
|
||||
"text": text,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/stats/summary")
|
||||
def summary(session: DbSession, settings: Settings, days: int = Query(default=30, ge=1, le=365)) -> dict:
|
||||
today = local_date(settings.timezone)
|
||||
return {
|
||||
"today": stats.day_metrics(session, settings, today),
|
||||
"streak": stats.streak(session, settings),
|
||||
"lifetime": stats.lifetime(session, settings),
|
||||
"average_7d": stats.averages(session, settings, 7, until=today),
|
||||
"range": stats.range_metrics(session, settings, today - timedelta(days=days - 1), today),
|
||||
"heatmap": stats.heatmap(session, settings, weeks=12),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/stats/day")
|
||||
def day_stats(session: DbSession, settings: Settings, day: date | None = Query(default=None)) -> dict:
|
||||
return stats.day_metrics(session, settings, day or local_date(settings.timezone))
|
||||
|
||||
|
||||
@router.post("/notifications/test")
|
||||
def test_notification(body: TestNotificationRequest, settings: Settings) -> dict:
|
||||
text = body.text or (
|
||||
"🍅 <b>Kadence</b>\nNotifications are wired up correctly."
|
||||
)
|
||||
deliveries = notify_service.notify(settings, text, urgent=True)
|
||||
return {"deliveries": [d.as_dict() for d in deliveries]}
|
||||
|
||||
|
||||
@router.get("/scheduler/jobs")
|
||||
def scheduler_jobs() -> dict:
|
||||
"""What the background jobs are, and when they next fire."""
|
||||
return {"jobs": scheduler.describe()}
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Settings endpoints.
|
||||
|
||||
The browser never sees a real token: `GET` masks every secret, and a `PUT` that
|
||||
sends the mask back untouched keeps the stored value.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.api.deps import DbSession, Settings
|
||||
from app.services import blocker_service, settings_service
|
||||
from app.settings_schema import AppSettings
|
||||
|
||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
def get_settings(settings: Settings) -> dict:
|
||||
return {
|
||||
"settings": settings_service.mask_secrets(settings.model_dump(mode="json")),
|
||||
"defaults": {
|
||||
"blocked_domains": blocker_service.DEFAULT_DOMAINS,
|
||||
"blocked_apps": blocker_service.DEFAULT_APPS,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.put("")
|
||||
def put_settings(session: DbSession, patch: dict[str, Any] = Body(...)) -> dict:
|
||||
try:
|
||||
updated = settings_service.apply_patch(session, patch)
|
||||
except ValidationError as exc:
|
||||
# `exc.errors()` can carry exception objects in `ctx`, which JSON cannot
|
||||
# take; pydantic's own `json()` renders them as text.
|
||||
raise HTTPException(status_code=422, detail=json.loads(exc.json())) from exc
|
||||
return {"settings": settings_service.mask_secrets(updated.model_dump(mode="json"))}
|
||||
|
||||
|
||||
@router.post("/reset")
|
||||
def reset_settings(session: DbSession) -> dict:
|
||||
"""Back to defaults, re-seeded from the environment."""
|
||||
updated = settings_service.save_settings(session, settings_service.settings_from_env())
|
||||
return {"settings": settings_service.mask_secrets(updated.model_dump(mode="json"))}
|
||||
|
||||
|
||||
@router.get("/schema")
|
||||
def settings_schema() -> dict:
|
||||
"""The JSON Schema, so a client can render the settings form generically."""
|
||||
return AppSettings.model_json_schema()
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Task endpoints: the aggregated list, refreshing it, and acting on it."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from app.api.deps import DbSession, Settings
|
||||
from app.api.schemas import TaskCreateRequest, TaskDoneRequest, TaskUpdateRequest
|
||||
from app.models import TaskRecord
|
||||
from app.services import tasks_service
|
||||
from app.sources.registry import SOURCE_LABELS, check_source
|
||||
from app.timeutil import ensure_utc, local_date
|
||||
|
||||
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
|
||||
|
||||
|
||||
def task_dict(record: TaskRecord, today) -> dict:
|
||||
return {
|
||||
"id": record.id,
|
||||
"source": record.source,
|
||||
"source_label": SOURCE_LABELS.get(record.source, record.source),
|
||||
"external_id": record.external_id,
|
||||
"title": record.title,
|
||||
"description": record.description,
|
||||
"url": record.url,
|
||||
"project": record.project,
|
||||
"status": record.status,
|
||||
"priority": record.priority,
|
||||
"due_date": record.due_date.isoformat() if record.due_date else None,
|
||||
"start_date": record.start_date.isoformat() if record.start_date else None,
|
||||
"labels": record.labels or [],
|
||||
"is_done": record.is_done,
|
||||
"completed_at": ensure_utc(record.completed_at).isoformat() if record.completed_at else None,
|
||||
"pinned": record.pinned,
|
||||
"stale": record.stale,
|
||||
"estimated_pomodoros": record.estimated_pomodoros,
|
||||
"days_overdue": (today - record.due_date).days if record.due_date and record.due_date < today else 0,
|
||||
"bucket": _bucket_of(record, today),
|
||||
}
|
||||
|
||||
|
||||
def _bucket_of(record: TaskRecord, today) -> str:
|
||||
if record.due_date is None:
|
||||
return "someday"
|
||||
if record.due_date < today:
|
||||
return "overdue"
|
||||
if record.due_date == today:
|
||||
return "today"
|
||||
return "upcoming"
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_tasks(
|
||||
session: DbSession,
|
||||
settings: Settings,
|
||||
include_done: bool = Query(default=False),
|
||||
include_stale: bool = Query(default=False),
|
||||
source: list[str] | None = Query(default=None),
|
||||
) -> dict:
|
||||
today = local_date(settings.timezone)
|
||||
records = tasks_service.list_tasks(
|
||||
session, include_done=include_done, include_stale=include_stale, sources=source
|
||||
)
|
||||
buckets = tasks_service.bucket_tasks(records, today)
|
||||
return {
|
||||
"date": today.isoformat(),
|
||||
"tasks": [task_dict(record, today) for record in records],
|
||||
"counts": {name: len(items) for name, items in buckets.items()},
|
||||
"sources": sorted({record.source for record in records}),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/refresh")
|
||||
def refresh(session: DbSession, settings: Settings) -> dict:
|
||||
"""Pull every enabled source now. Failures come back per source, not as a 500."""
|
||||
return tasks_service.refresh_tasks(session, settings)
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create(body: TaskCreateRequest, session: DbSession, settings: Settings) -> dict:
|
||||
record = tasks_service.create_local_task(
|
||||
session,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
due_date=body.due_date,
|
||||
priority=body.priority,
|
||||
project=body.project,
|
||||
estimated_pomodoros=body.estimated_pomodoros,
|
||||
)
|
||||
return task_dict(record, local_date(settings.timezone))
|
||||
|
||||
|
||||
@router.patch("/{task_id}")
|
||||
def update(task_id: int, body: TaskUpdateRequest, session: DbSession, settings: Settings) -> dict:
|
||||
try:
|
||||
record = tasks_service.update_task(session, task_id, **body.model_dump(exclude_unset=True))
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
return task_dict(record, local_date(settings.timezone))
|
||||
|
||||
|
||||
@router.post("/{task_id}/done")
|
||||
def mark_done(task_id: int, body: TaskDoneRequest, session: DbSession, settings: Settings) -> dict:
|
||||
try:
|
||||
record, warning = tasks_service.complete_task(session, settings, task_id, done=body.done)
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
return {"task": task_dict(record, local_date(settings.timezone)), "warning": warning}
|
||||
|
||||
|
||||
@router.delete("/{task_id}")
|
||||
def delete(task_id: int, session: DbSession) -> dict:
|
||||
tasks_service.delete_task(session, task_id)
|
||||
return {"deleted": task_id}
|
||||
|
||||
|
||||
@router.post("/sources/{name}/test")
|
||||
def test_source(name: str, settings: Settings) -> dict:
|
||||
"""What the settings page's "Test connection" button calls."""
|
||||
ok, detail = check_source(settings, name)
|
||||
return {"source": name, "ok": ok, "detail": detail}
|
||||
@@ -0,0 +1,155 @@
|
||||
"""The timer endpoints. Thin: every decision lives in `services.pomodoro`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from app.api.deps import DbSession, Settings
|
||||
from app.api.schemas import (
|
||||
AbandonRequest,
|
||||
AttachTaskRequest,
|
||||
CompleteTimerRequest,
|
||||
ExtendRequest,
|
||||
StartTimerRequest,
|
||||
)
|
||||
from app.services import pomodoro, report, stats
|
||||
from app.services.notify import count_sent, notify, record_sent
|
||||
from app.timeutil import local_date
|
||||
|
||||
router = APIRouter(prefix="/api/timer", tags=["timer"])
|
||||
|
||||
|
||||
@router.get("/state")
|
||||
def get_state(session: DbSession, settings: Settings) -> dict:
|
||||
return pomodoro.state(session, settings)
|
||||
|
||||
|
||||
@router.post("/start")
|
||||
def start(body: StartTimerRequest, session: DbSession, settings: Settings) -> dict:
|
||||
try:
|
||||
pomodoro.start(
|
||||
session,
|
||||
settings,
|
||||
kind=body.kind,
|
||||
minutes=body.minutes,
|
||||
task_id=body.task_id,
|
||||
note=body.note,
|
||||
)
|
||||
except pomodoro.TimerError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return pomodoro.state(session, settings)
|
||||
|
||||
|
||||
@router.post("/pause")
|
||||
def pause(session: DbSession, settings: Settings) -> dict:
|
||||
try:
|
||||
pomodoro.pause(session, settings)
|
||||
except pomodoro.TimerError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return pomodoro.state(session, settings)
|
||||
|
||||
|
||||
@router.post("/resume")
|
||||
def resume(session: DbSession, settings: Settings) -> dict:
|
||||
try:
|
||||
pomodoro.resume(session)
|
||||
except pomodoro.TimerError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return pomodoro.state(session, settings)
|
||||
|
||||
|
||||
@router.post("/complete")
|
||||
def complete(body: CompleteTimerRequest, session: DbSession, settings: Settings) -> dict:
|
||||
try:
|
||||
finished, follow_on = pomodoro.complete(session, settings, auto_next=body.auto_next)
|
||||
except pomodoro.TimerError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
celebration = _maybe_celebrate(session, settings) if finished.is_work else None
|
||||
state = pomodoro.state(session, settings)
|
||||
state["finished"] = pomodoro.session_dict(finished)
|
||||
state["auto_started"] = pomodoro.session_dict(follow_on)
|
||||
state["celebration"] = celebration
|
||||
return state
|
||||
|
||||
|
||||
@router.post("/abandon")
|
||||
def abandon(body: AbandonRequest, session: DbSession, settings: Settings) -> dict:
|
||||
try:
|
||||
pomodoro.abandon(session, settings, reason=body.reason)
|
||||
except pomodoro.TimerError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return pomodoro.state(session, settings)
|
||||
|
||||
|
||||
@router.post("/interrupt")
|
||||
def interrupt(session: DbSession, settings: Settings) -> dict:
|
||||
try:
|
||||
pomodoro.add_interruption(session)
|
||||
except pomodoro.TimerError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return pomodoro.state(session, settings)
|
||||
|
||||
|
||||
@router.post("/extend")
|
||||
def extend(body: ExtendRequest, session: DbSession, settings: Settings) -> dict:
|
||||
try:
|
||||
pomodoro.extend(session, body.minutes)
|
||||
except pomodoro.TimerError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return pomodoro.state(session, settings)
|
||||
|
||||
|
||||
@router.post("/task")
|
||||
def attach_task(body: AttachTaskRequest, session: DbSession, settings: Settings) -> dict:
|
||||
try:
|
||||
pomodoro.attach_task(session, body.task_id)
|
||||
except pomodoro.TimerError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return pomodoro.state(session, settings)
|
||||
|
||||
|
||||
@router.get("/sessions")
|
||||
def sessions(
|
||||
session: DbSession,
|
||||
settings: Settings,
|
||||
day: date | None = Query(default=None, description="Local date; defaults to today."),
|
||||
) -> dict:
|
||||
target = day or local_date(settings.timezone)
|
||||
rows = pomodoro.sessions_for_day(session, settings, target)
|
||||
return {
|
||||
"date": target.isoformat(),
|
||||
"sessions": [pomodoro.session_dict(row) for row in rows],
|
||||
"metrics": stats.day_metrics(session, settings, target),
|
||||
}
|
||||
|
||||
|
||||
def _maybe_celebrate(session, settings) -> str | None:
|
||||
"""Cheer in Telegram every N pomodoros — the cheapest dopamine there is."""
|
||||
every = settings.notifications.telegram.celebrate_every_n_pomodoros
|
||||
if not (settings.notifications.telegram.enabled and every):
|
||||
return None
|
||||
|
||||
done = pomodoro.completed_work_today(session, settings)
|
||||
if done == 0 or done % every != 0:
|
||||
return None
|
||||
|
||||
today = local_date(settings.timezone)
|
||||
kind = f"celebrate:{done}"
|
||||
if count_sent(session, kind, today):
|
||||
return None
|
||||
|
||||
ru = settings.notifications.report.language == "ru"
|
||||
goal = settings.pomodoro.daily_goal_pomodoros
|
||||
bar = report.progress_bar(done, goal)
|
||||
text = (
|
||||
f"🎉 <b>{done}</b> помидорок готово!\n{bar} {done}/{goal}\nДержи темп."
|
||||
if ru
|
||||
else f"🎉 <b>{done}</b> pomodoros done!\n{bar} {done}/{goal}\nKeep the rhythm."
|
||||
)
|
||||
deliveries = notify(settings, text)
|
||||
if any(delivery.ok for delivery in deliveries):
|
||||
record_sent(session, kind, today, {"pomodoros": done})
|
||||
return text
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Request bodies. Responses are plain dicts built by the services."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class StartTimerRequest(BaseModel):
|
||||
kind: Literal["work", "short_break", "long_break"] | None = None
|
||||
minutes: int | None = Field(default=None, ge=1, le=240)
|
||||
task_id: int | None = None
|
||||
note: str | None = None
|
||||
|
||||
|
||||
class AbandonRequest(BaseModel):
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
class ExtendRequest(BaseModel):
|
||||
minutes: int = Field(default=5, ge=1, le=120)
|
||||
|
||||
|
||||
class AttachTaskRequest(BaseModel):
|
||||
task_id: int | None = None
|
||||
|
||||
|
||||
class CompleteTimerRequest(BaseModel):
|
||||
auto_next: bool = True
|
||||
|
||||
|
||||
class TaskCreateRequest(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=500)
|
||||
description: str | None = None
|
||||
due_date: date | None = None
|
||||
priority: str | None = None
|
||||
project: str | None = None
|
||||
estimated_pomodoros: int | None = Field(default=None, ge=1, le=50)
|
||||
|
||||
|
||||
class TaskUpdateRequest(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
due_date: date | None = None
|
||||
priority: str | None = None
|
||||
project: str | None = None
|
||||
estimated_pomodoros: int | None = None
|
||||
pinned: bool | None = None
|
||||
|
||||
|
||||
class TaskDoneRequest(BaseModel):
|
||||
done: bool = True
|
||||
|
||||
|
||||
class BlockEventRequest(BaseModel):
|
||||
kind: Literal["domain", "app"] = "domain"
|
||||
target: str = Field(min_length=1, max_length=255)
|
||||
action: Literal["blocked", "killed", "warned"] = "blocked"
|
||||
details: str | None = None
|
||||
|
||||
|
||||
class SendReportRequest(BaseModel):
|
||||
report_date: date | None = None
|
||||
force: bool = True
|
||||
|
||||
|
||||
class TestNotificationRequest(BaseModel):
|
||||
text: str | None = None
|
||||
|
||||
|
||||
class SettingsPatch(BaseModel):
|
||||
"""A partial settings document; anything omitted keeps its stored value."""
|
||||
|
||||
model_config = {"extra": "allow"}
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return self.model_dump(exclude_unset=True)
|
||||
@@ -0,0 +1,76 @@
|
||||
"""`kadence` — run the server, or poke it from a terminal/cron."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from datetime import date
|
||||
|
||||
from app.db import init_db, session_scope
|
||||
from app.services import report, settings_service, stats, tasks_service
|
||||
from app.timeutil import local_date
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(prog="kadence")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
serve = sub.add_parser("serve", help="run the API (and the web UI)")
|
||||
serve.add_argument("--host", default="0.0.0.0")
|
||||
serve.add_argument("--port", type=int, default=8000)
|
||||
serve.add_argument("--reload", action="store_true")
|
||||
|
||||
sub.add_parser("refresh", help="pull every todo source once")
|
||||
sub.add_parser("briefing", help="send the morning briefing now")
|
||||
report_cmd = sub.add_parser("report", help="build and send today's report")
|
||||
report_cmd.add_argument("--date", type=date.fromisoformat, default=None)
|
||||
report_cmd.add_argument("--print", action="store_true", help="print, do not send")
|
||||
sub.add_parser("stats", help="print a summary as JSON")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "serve":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run("app.main:app", host=args.host, port=args.port, reload=args.reload)
|
||||
return 0
|
||||
|
||||
init_db()
|
||||
with session_scope() as session:
|
||||
settings = settings_service.load_settings(session)
|
||||
|
||||
if args.command == "refresh":
|
||||
print(json.dumps(tasks_service.refresh_tasks(session, settings), indent=2))
|
||||
elif args.command == "briefing":
|
||||
from app.services.notify import notify
|
||||
|
||||
text = report.render_morning_briefing(session, settings)
|
||||
print(text)
|
||||
print([d.as_dict() for d in notify(settings, text, urgent=True)])
|
||||
elif args.command == "report":
|
||||
day = args.date or local_date(settings.timezone)
|
||||
if args.print:
|
||||
_, payload = report.get_or_build_daily(session, settings, day, rebuild=True)
|
||||
print(report.render_daily_telegram(payload, settings))
|
||||
else:
|
||||
result = report.send_daily(session, settings, day, force=True)
|
||||
print(result.get("text", ""))
|
||||
print([d for d in result.get("deliveries", [])])
|
||||
elif args.command == "stats":
|
||||
today = local_date(settings.timezone)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"today": stats.day_metrics(session, settings, today),
|
||||
"streak": stats.streak(session, settings),
|
||||
"lifetime": stats.lifetime(session, settings),
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Process-level configuration: where the database is, who may call us, and the
|
||||
seed values for the integrations.
|
||||
|
||||
Two layers of configuration exist in this app and they do different jobs:
|
||||
|
||||
* **This module** — environment variables. Infrastructure plus *secrets*
|
||||
(API tokens), so they can live in a `.env` file or a Docker secret and never
|
||||
touch the database or the browser.
|
||||
* **`app.settings_schema.AppSettings`** — the user-editable settings stored in
|
||||
the database and edited in the UI (pomodoro lengths, blocked sites, report
|
||||
times...). On first boot the secrets below are copied into it as defaults.
|
||||
|
||||
So: `JIRA_API_TOKEN` in the environment gets you a working Jira source without
|
||||
ever opening the settings page, and anything you later change in the UI wins.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class EnvSettings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=(".env", "../.env"),
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# ---- infrastructure ----------------------------------------------------
|
||||
database_url: str = Field(
|
||||
default="sqlite:///./kadence.db",
|
||||
description="SQLAlchemy URL. Docker compose points this at Postgres.",
|
||||
)
|
||||
cors_origins: str = Field(
|
||||
default="*",
|
||||
description="Comma-separated list of allowed browser origins, or '*'.",
|
||||
)
|
||||
timezone: str = Field(
|
||||
default="Europe/Moscow",
|
||||
description="Fallback timezone for 'today' and for scheduled jobs.",
|
||||
)
|
||||
scheduler_enabled: bool = Field(
|
||||
default=True,
|
||||
description="Run the background jobs (daily report, nudges, refresh).",
|
||||
)
|
||||
telegram_bot_enabled: bool = Field(
|
||||
default=True,
|
||||
description="Run the Telegram command bot when a token is configured.",
|
||||
)
|
||||
api_token: str = Field(
|
||||
default="",
|
||||
description=(
|
||||
"Optional shared secret. When set, every /api call must send it as "
|
||||
"`X-API-Token` (the desktop app and the web UI both do)."
|
||||
),
|
||||
)
|
||||
|
||||
# ---- seeds for the editable settings -----------------------------------
|
||||
jira_base_url: str = ""
|
||||
jira_email: str = ""
|
||||
jira_api_token: str = ""
|
||||
jira_extra_jql: str = ""
|
||||
jira_start_date_field: str = "customfield_10015"
|
||||
|
||||
google_client_id: str = ""
|
||||
google_client_secret: str = ""
|
||||
google_refresh_token: str = ""
|
||||
google_tasks_lists: str = ""
|
||||
|
||||
todoist_api_token: str = ""
|
||||
todoist_projects: str = ""
|
||||
|
||||
laundry_session: str = ""
|
||||
laundry_base_url: str = "https://4ka.inbicst.ru"
|
||||
|
||||
telegram_bot_token: str = ""
|
||||
telegram_chat_id: str = ""
|
||||
slack_webhook_url: str = ""
|
||||
|
||||
@property
|
||||
def cors_origin_list(self) -> list[str]:
|
||||
if self.cors_origins.strip() == "*":
|
||||
return ["*"]
|
||||
return [item.strip() for item in self.cors_origins.split(",") if item.strip()]
|
||||
|
||||
|
||||
@lru_cache
|
||||
def env_settings() -> EnvSettings:
|
||||
return EnvSettings()
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Database engine and session plumbing.
|
||||
|
||||
Synchronous SQLAlchemy on purpose: the background scheduler, the Telegram
|
||||
polling bot and the request handlers all touch the same tables, and one boring
|
||||
threadsafe sessionmaker is far easier to reason about than three event loops.
|
||||
FastAPI runs plain `def` endpoints in a threadpool, so nothing blocks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
|
||||
from app.config import env_settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def _engine_kwargs(url: str) -> dict:
|
||||
if url.startswith("sqlite"):
|
||||
# One file, many threads (scheduler + bot + requests).
|
||||
return {"connect_args": {"check_same_thread": False}}
|
||||
return {"pool_pre_ping": True, "pool_size": 5, "max_overflow": 10}
|
||||
|
||||
|
||||
engine = create_engine(env_settings().database_url, **_engine_kwargs(env_settings().database_url))
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
"""Create any missing tables. The schema is additive, so this is enough."""
|
||||
from app import models # noqa: F401 (register the mappers)
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def session_scope() -> Iterator[Session]:
|
||||
"""A transactional session for background jobs and scripts."""
|
||||
session = SessionLocal()
|
||||
try:
|
||||
yield session
|
||||
session.commit()
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def get_session() -> Iterator[Session]:
|
||||
"""FastAPI dependency: one session per request, committed by the endpoint."""
|
||||
session = SessionLocal()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
@@ -0,0 +1,106 @@
|
||||
"""The ASGI application.
|
||||
|
||||
On start it creates the schema, seeds the settings from the environment, starts
|
||||
the background jobs and the Telegram bot, and — when a built frontend is present
|
||||
in `static/` — serves the web UI from the same origin, which is what the Docker
|
||||
image does.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import Depends, FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app import __version__
|
||||
from app.api import api_router
|
||||
from app.api.deps import require_token
|
||||
from app.config import env_settings
|
||||
from app.db import init_db, session_scope
|
||||
from app.services import scheduler, settings_service, telegram_bot
|
||||
|
||||
logging.basicConfig(
|
||||
level=os.environ.get("LOG_LEVEL", "INFO").upper(),
|
||||
format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
|
||||
)
|
||||
log = logging.getLogger("kadence")
|
||||
|
||||
STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
init_db()
|
||||
with session_scope() as session:
|
||||
settings = settings_service.load_settings(session)
|
||||
log.info("settings loaded (timezone=%s)", settings.timezone)
|
||||
|
||||
scheduler.start(settings)
|
||||
if settings.notifications.telegram.enabled:
|
||||
telegram_bot.start()
|
||||
# The bot has to come up later too, if Telegram is switched on in the UI.
|
||||
settings_service.on_change(
|
||||
lambda new: telegram_bot.start() if new.notifications.telegram.enabled else None
|
||||
)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
scheduler.shutdown()
|
||||
telegram_bot.shutdown()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Kadence",
|
||||
version=__version__,
|
||||
summary="Pomodoro timer, todo aggregation, blocking policy and daily reports.",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=env_settings().cors_origin_list,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(api_router, dependencies=[Depends(require_token)])
|
||||
|
||||
|
||||
@app.get("/api/health", tags=["meta"])
|
||||
def health() -> dict:
|
||||
return {"status": "ok", "version": __version__}
|
||||
|
||||
|
||||
@app.get("/api/info", tags=["meta"])
|
||||
def info() -> dict:
|
||||
"""What a client needs to know before it has a token: who we are, what is on."""
|
||||
env = env_settings()
|
||||
return {
|
||||
"version": __version__,
|
||||
"auth_required": bool(env.api_token),
|
||||
"scheduler": env.scheduler_enabled,
|
||||
"jobs": scheduler.describe(),
|
||||
}
|
||||
|
||||
|
||||
# ---- the web UI, when it has been built into ./static ----------------------
|
||||
if STATIC_DIR.is_dir():
|
||||
app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets")
|
||||
|
||||
@app.get("/{path:path}", include_in_schema=False)
|
||||
def spa(path: str):
|
||||
"""Serve the single-page app, letting the client router own the URL."""
|
||||
candidate = STATIC_DIR / path
|
||||
if path and candidate.is_file():
|
||||
return FileResponse(candidate)
|
||||
index = STATIC_DIR / "index.html"
|
||||
if index.is_file():
|
||||
return FileResponse(index)
|
||||
return JSONResponse({"detail": "Not found"}, status_code=404)
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Database tables.
|
||||
|
||||
The shape follows the three things the app records: **settings** (one JSON
|
||||
document), **what you did** (pomodoro sessions, task completions) and **what you
|
||||
were tempted by** (block events). Reports are derived from those and cached per
|
||||
day so a re-send shows the same numbers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
Date,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db import Base
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class SettingsRow(Base):
|
||||
"""The single settings document (`id = 1`), validated by `AppSettings`."""
|
||||
|
||||
__tablename__ = "settings"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
|
||||
data: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, onupdate=utcnow
|
||||
)
|
||||
|
||||
|
||||
class PomodoroSession(Base):
|
||||
"""One interval of the timer — work or break — as it actually happened.
|
||||
|
||||
`planned_seconds` is what the settings asked for, `actual_seconds` is what
|
||||
the clock recorded, and they differ whenever you finish early or abandon.
|
||||
"""
|
||||
|
||||
__tablename__ = "pomodoro_sessions"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
kind: Mapped[str] = mapped_column(String(16), index=True) # work|short_break|long_break
|
||||
status: Mapped[str] = mapped_column(String(16), index=True, default="running")
|
||||
# running | paused | completed | abandoned
|
||||
|
||||
started_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, index=True
|
||||
)
|
||||
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
local_date: Mapped[date] = mapped_column(Date, index=True)
|
||||
"""The day this interval belongs to in the user's timezone — reports group on it."""
|
||||
|
||||
planned_seconds: Mapped[int] = mapped_column(Integer, default=1500)
|
||||
actual_seconds: Mapped[int] = mapped_column(Integer, default=0)
|
||||
paused_seconds: Mapped[int] = mapped_column(Integer, default=0)
|
||||
paused_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
interruptions: Mapped[int] = mapped_column(Integer, default=0)
|
||||
cycle_index: Mapped[int] = mapped_column(Integer, default=1)
|
||||
"""Position in the current long-break cycle (1..cycles_before_long_break)."""
|
||||
|
||||
task_source: Mapped[str | None] = mapped_column(String(32))
|
||||
task_external_id: Mapped[str | None] = mapped_column(String(128))
|
||||
task_title: Mapped[str | None] = mapped_column(Text)
|
||||
note: Mapped[str | None] = mapped_column(Text)
|
||||
abandon_reason: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
block_events: Mapped[list["BlockEvent"]] = relationship(back_populates="session")
|
||||
|
||||
@property
|
||||
def is_work(self) -> bool:
|
||||
return self.kind == "work"
|
||||
|
||||
|
||||
class TaskRecord(Base):
|
||||
"""A todo item, wherever it came from.
|
||||
|
||||
External tasks are cached here on every refresh so the UI is instant and the
|
||||
daily report still works when Jira is down. Locally created tasks live here
|
||||
too, with `source = "local"` — they are the only ones we own outright.
|
||||
"""
|
||||
|
||||
__tablename__ = "tasks"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("source", "external_id", name="uq_task_source_external"),
|
||||
Index("ix_task_source_done", "source", "is_done"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
source: Mapped[str] = mapped_column(String(32), index=True)
|
||||
external_id: Mapped[str] = mapped_column(String(128))
|
||||
|
||||
title: Mapped[str] = mapped_column(Text)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
url: Mapped[str | None] = mapped_column(Text)
|
||||
project: Mapped[str | None] = mapped_column(Text)
|
||||
status: Mapped[str | None] = mapped_column(String(64))
|
||||
priority: Mapped[str | None] = mapped_column(String(32))
|
||||
due_date: Mapped[date | None] = mapped_column(Date, index=True)
|
||||
start_date: Mapped[date | None] = mapped_column(Date)
|
||||
labels: Mapped[list[str]] = mapped_column(JSON, default=list)
|
||||
raw: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
|
||||
is_done: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
completed_date: Mapped[date | None] = mapped_column(Date, index=True)
|
||||
completed_here: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
"""True when *this app* closed it — what the daily report counts."""
|
||||
|
||||
estimated_pomodoros: Mapped[int | None] = mapped_column(Integer)
|
||||
pinned: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
"""Shown at the top of the timer's task picker."""
|
||||
|
||||
first_seen_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow
|
||||
)
|
||||
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
stale: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
"""Set when a refresh no longer returns the task (closed elsewhere, or filtered)."""
|
||||
|
||||
|
||||
class BlockEvent(Base):
|
||||
"""A blocked site or app you reached for anyway. Pure gold for the report."""
|
||||
|
||||
__tablename__ = "block_events"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
kind: Mapped[str] = mapped_column(String(16), index=True) # domain | app
|
||||
target: Mapped[str] = mapped_column(String(255), index=True)
|
||||
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
|
||||
local_date: Mapped[date] = mapped_column(Date, index=True)
|
||||
action: Mapped[str] = mapped_column(String(16), default="blocked") # blocked|killed|warned
|
||||
details: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
session_id: Mapped[int | None] = mapped_column(ForeignKey("pomodoro_sessions.id"))
|
||||
session: Mapped[PomodoroSession | None] = relationship(back_populates="block_events")
|
||||
|
||||
|
||||
class DailyReport(Base):
|
||||
"""A generated report, kept so re-sends and the web view agree."""
|
||||
|
||||
__tablename__ = "daily_reports"
|
||||
__table_args__ = (UniqueConstraint("report_date", name="uq_report_date"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
report_date: Mapped[date] = mapped_column(Date, index=True)
|
||||
payload: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
generated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
sent_channel: Mapped[str | None] = mapped_column(String(32))
|
||||
score: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
|
||||
|
||||
class EventLog(Base):
|
||||
"""Small append-only log used for "did we already send this?" questions.
|
||||
|
||||
Nudges, celebrations and briefings all need to be idempotent per day, and a
|
||||
row here is cheaper than a column per message type.
|
||||
"""
|
||||
|
||||
__tablename__ = "event_log"
|
||||
__table_args__ = (Index("ix_eventlog_kind_date", "kind", "local_date"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
kind: Mapped[str] = mapped_column(String(48), index=True)
|
||||
local_date: Mapped[date] = mapped_column(Date, index=True)
|
||||
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
payload: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
@@ -0,0 +1 @@
|
||||
"""Domain services: settings, timer, task aggregation, reports, notifications."""
|
||||
@@ -0,0 +1,320 @@
|
||||
"""Badges and fun facts — the part of the report you actually read.
|
||||
|
||||
Rules are pure functions of a day's metrics plus a little history, so they are
|
||||
trivial to extend: add a `Rule` to `RULES` and it shows up in the web report and
|
||||
the Telegram message at once.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from typing import Any, Callable
|
||||
|
||||
Metrics = dict[str, Any]
|
||||
Context = dict[str, Any]
|
||||
|
||||
|
||||
def _plural(count: int, singular: str, plural: str | None = None) -> str:
|
||||
""""1 pomodoro" / "6 pomodoros" — the English side; Russian gets its own text."""
|
||||
return f"{count} {singular if count == 1 else (plural or singular + 's')}"
|
||||
|
||||
|
||||
def _ru_pomodoros(count: int) -> str:
|
||||
if count % 10 == 1 and count % 100 != 11:
|
||||
return f"{count} помидорка"
|
||||
if count % 10 in (2, 3, 4) and count % 100 not in (12, 13, 14):
|
||||
return f"{count} помидорки"
|
||||
return f"{count} помидорок"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Achievement:
|
||||
code: str
|
||||
emoji: str
|
||||
title_en: str
|
||||
title_ru: str
|
||||
detail_en: str = ""
|
||||
detail_ru: str = ""
|
||||
|
||||
def render(self, language: str) -> dict[str, str]:
|
||||
ru = language == "ru"
|
||||
return {
|
||||
"code": self.code,
|
||||
"emoji": self.emoji,
|
||||
"title": self.title_ru if ru else self.title_en,
|
||||
"detail": (self.detail_ru if ru else self.detail_en),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Rule:
|
||||
code: str
|
||||
emoji: str
|
||||
title_en: str
|
||||
title_ru: str
|
||||
test: Callable[[Metrics, Context], bool]
|
||||
detail: Callable[[Metrics, Context], tuple[str, str]] | None = None
|
||||
|
||||
|
||||
def _nothing(_m: Metrics, _c: Context) -> tuple[str, str]:
|
||||
return "", ""
|
||||
|
||||
|
||||
RULES: list[Rule] = [
|
||||
Rule(
|
||||
"goal_met",
|
||||
"🎯",
|
||||
"Daily goal met",
|
||||
"Дневная цель взята",
|
||||
lambda m, c: m["goal_met"],
|
||||
lambda m, c: (
|
||||
f"{m['pomodoros']}/{m['goal']} pomodoros.",
|
||||
f"{m['pomodoros']}/{m['goal']} помидорок.",
|
||||
),
|
||||
),
|
||||
Rule(
|
||||
"double_goal",
|
||||
"🔥",
|
||||
"Double the goal",
|
||||
"Двойная норма",
|
||||
lambda m, c: m["goal"] > 0 and m["pomodoros"] >= 2 * m["goal"],
|
||||
),
|
||||
Rule(
|
||||
"early_bird",
|
||||
"🌅",
|
||||
"Early bird",
|
||||
"Ранняя пташка",
|
||||
lambda m, c: bool(m["first_start"]) and m["first_start"] < "08:00",
|
||||
lambda m, c: (
|
||||
f"First pomodoro at {m['first_start']}.",
|
||||
f"Первая помидорка в {m['first_start']}.",
|
||||
),
|
||||
),
|
||||
Rule(
|
||||
"night_owl",
|
||||
"🦉",
|
||||
"Night owl",
|
||||
"Ночная сова",
|
||||
lambda m, c: bool(m["last_end"]) and m["last_end"] > "23:00",
|
||||
),
|
||||
Rule(
|
||||
"deep_work",
|
||||
"🧘",
|
||||
"Deep work",
|
||||
"Глубокая работа",
|
||||
lambda m, c: m["longest_stretch_minutes"] >= 90,
|
||||
lambda m, c: (
|
||||
f"{m['longest_stretch_minutes']} minutes without breaking the chain.",
|
||||
f"{m['longest_stretch_minutes']} минут без разрыва цепочки.",
|
||||
),
|
||||
),
|
||||
Rule(
|
||||
"flawless",
|
||||
"💎",
|
||||
"Flawless",
|
||||
"Безупречно",
|
||||
lambda m, c: m["pomodoros"] >= 4 and m["abandoned"] == 0 and m["interruptions"] == 0,
|
||||
),
|
||||
Rule(
|
||||
"task_crusher",
|
||||
"✅",
|
||||
"Task crusher",
|
||||
"Крушитель задач",
|
||||
lambda m, c: m["tasks_completed_count"] >= 5,
|
||||
lambda m, c: (
|
||||
_plural(m["tasks_completed_count"], "task") + " closed.",
|
||||
f"{m['tasks_completed_count']} задач закрыто.",
|
||||
),
|
||||
),
|
||||
Rule(
|
||||
"gatekeeper",
|
||||
"🛡️",
|
||||
"Gatekeeper",
|
||||
"Страж",
|
||||
lambda m, c: m["blocked_attempts"] >= 5,
|
||||
lambda m, c: (
|
||||
f"The blocker turned you away {m['blocked_attempts']} times. It won.",
|
||||
f"Блокировщик развернул тебя {m['blocked_attempts']} раз. И выиграл.",
|
||||
),
|
||||
),
|
||||
Rule(
|
||||
"well_rested",
|
||||
"☕",
|
||||
"Took the breaks",
|
||||
"Отдыхал как надо",
|
||||
lambda m, c: m["pomodoros"] >= 4 and m["breaks_taken"] >= m["pomodoros"] - 1,
|
||||
),
|
||||
Rule(
|
||||
"streak_3",
|
||||
"📆",
|
||||
"3-day streak",
|
||||
"Серия 3 дня",
|
||||
lambda m, c: c.get("streak", {}).get("current", 0) >= 3,
|
||||
lambda m, c: (
|
||||
f"{c['streak']['current']} days in a row with work logged.",
|
||||
f"{c['streak']['current']} дней подряд с работой.",
|
||||
),
|
||||
),
|
||||
Rule(
|
||||
"streak_7",
|
||||
"🏆",
|
||||
"Week-long streak",
|
||||
"Неделя подряд",
|
||||
lambda m, c: c.get("streak", {}).get("current", 0) >= 7,
|
||||
),
|
||||
Rule(
|
||||
"personal_best",
|
||||
"🚀",
|
||||
"Personal best",
|
||||
"Личный рекорд",
|
||||
lambda m, c: m["pomodoros"] > 0 and m["pomodoros"] > c.get("previous_best", 0),
|
||||
lambda m, c: (
|
||||
"Your best day ever: " + _plural(m["pomodoros"], "pomodoro") + ".",
|
||||
f"Лучший день: {_ru_pomodoros(m['pomodoros'])}.",
|
||||
),
|
||||
),
|
||||
Rule(
|
||||
"comeback",
|
||||
"🪃",
|
||||
"Comeback",
|
||||
"Возвращение",
|
||||
lambda m, c: m["pomodoros"] >= 4 and c.get("yesterday_pomodoros", 0) == 0,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def evaluate(metrics: Metrics, context: Context, language: str = "en") -> list[dict[str, str]]:
|
||||
earned: list[dict[str, str]] = []
|
||||
for rule in RULES:
|
||||
try:
|
||||
if not rule.test(metrics, context):
|
||||
continue
|
||||
except Exception: # pragma: no cover - a bad rule must not sink the report
|
||||
logging.getLogger(__name__).debug("rule %s failed", rule.code, exc_info=True)
|
||||
continue
|
||||
detail_en, detail_ru = (rule.detail or _nothing)(metrics, context)
|
||||
earned.append(
|
||||
Achievement(
|
||||
rule.code, rule.emoji, rule.title_en, rule.title_ru, detail_en, detail_ru
|
||||
).render(language)
|
||||
)
|
||||
return earned
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Fun facts
|
||||
# --------------------------------------------------------------------------
|
||||
def fun_facts(metrics: Metrics, context: Context, language: str = "en") -> list[str]:
|
||||
"""One-liners that make the numbers land. Only the true ones are returned."""
|
||||
ru = language == "ru"
|
||||
facts: list[str] = []
|
||||
focus = metrics["focus_minutes"]
|
||||
pomodoros = metrics["pomodoros"]
|
||||
|
||||
if focus:
|
||||
episodes = round(focus / 22, 1)
|
||||
facts.append(
|
||||
f"🍅 {focus} минут фокуса — это {episodes} серий ситкома, которые ты не посмотрел."
|
||||
if ru
|
||||
else f"🍅 {focus} minutes of focus — that is {episodes} sitcom episodes you did not watch."
|
||||
)
|
||||
if metrics["blocked_attempts"]:
|
||||
attempts = metrics["blocked_attempts"]
|
||||
top = metrics["blocked_top"][0]["target"] if metrics["blocked_top"] else "?"
|
||||
facts.append(
|
||||
f"🚫 {attempts} попыток сбежать, чаще всего в {top}. Не вышло."
|
||||
if ru
|
||||
else f"🚫 {attempts} escape attempts, mostly to {top}. None made it through."
|
||||
)
|
||||
if metrics["interruptions"]:
|
||||
facts.append(
|
||||
f"📵 {metrics['interruptions']} прерываний. Каждое стоит ~23 минуты концентрации — по науке."
|
||||
if ru
|
||||
else (
|
||||
"📵 " + _plural(metrics["interruptions"], "interruption")
|
||||
+ ". Research prices each one at ~23 minutes of refocusing."
|
||||
)
|
||||
)
|
||||
if metrics["peak_hour"] is not None and pomodoros >= 2:
|
||||
hour = metrics["peak_hour"]
|
||||
facts.append(
|
||||
f"⏰ Пик продуктивности — {hour:02d}:00. Защищай это время."
|
||||
if ru
|
||||
else f"⏰ Your peak hour was {hour:02d}:00. Defend it."
|
||||
)
|
||||
if metrics["wasted_minutes"] >= 10:
|
||||
facts.append(
|
||||
f"💨 {metrics['wasted_minutes']} минут ушло в брошенные помидорки."
|
||||
if ru
|
||||
else f"💨 {metrics['wasted_minutes']} minutes went into pomodoros you dropped."
|
||||
)
|
||||
if pomodoros and metrics["breaks_taken"] < pomodoros - 1:
|
||||
skipped = pomodoros - 1 - metrics["breaks_taken"]
|
||||
facts.append(
|
||||
f"⚠️ Пропущено перерывов: {skipped}. Мозг это запомнит."
|
||||
if ru
|
||||
else "⚠️ You skipped " + _plural(skipped, "break") + ". Your brain keeps score."
|
||||
)
|
||||
average = context.get("average_7d", {}).get("pomodoros", 0)
|
||||
if average and pomodoros:
|
||||
delta = round(100 * (pomodoros - average) / average)
|
||||
if abs(delta) >= 10:
|
||||
better = delta > 0
|
||||
facts.append(
|
||||
(f"📈 На {abs(delta)}% {'выше' if better else 'ниже'} твоего среднего за неделю.")
|
||||
if ru
|
||||
else (
|
||||
f"📈 {abs(delta)}% {'above' if better else 'below'} your 7-day average."
|
||||
)
|
||||
)
|
||||
lifetime_hours = context.get("lifetime", {}).get("focus_hours", 0)
|
||||
if lifetime_hours >= 1:
|
||||
facts.append(
|
||||
f"🏛️ Всего в приложении: {lifetime_hours} часов фокуса."
|
||||
if ru
|
||||
else f"🏛️ All-time focus logged here: {lifetime_hours} hours."
|
||||
)
|
||||
return facts
|
||||
|
||||
|
||||
def verdict(metrics: Metrics, tone: str, language: str = "en") -> str:
|
||||
"""The closing line. Four tones, because motivation is a personal taste."""
|
||||
ru = language == "ru"
|
||||
pct = metrics["goal_pct"]
|
||||
table = {
|
||||
"friendly": [
|
||||
(100, "Goal met. Enjoy the evening — you earned it. 🎉", "Цель взята. Отдыхай, заслужил. 🎉"),
|
||||
(60, "Solid day. Tomorrow you close the gap. 🙂", "Хороший день. Завтра добьёшь остаток. 🙂"),
|
||||
(1, "Something beats nothing. Start tomorrow with one pomodoro.", "Что-то лучше, чем ничего. Завтра начни с одной помидорки."),
|
||||
(0, "Nothing logged today. No drama — just start the timer tomorrow.", "Сегодня пусто. Без драмы — просто запусти таймер завтра."),
|
||||
],
|
||||
"coach": [
|
||||
(100, "Target hit. Now protect the streak. 💪", "Цель закрыта. Теперь держи серию. 💪"),
|
||||
(60, "Close. One more block would have done it. Plan it in.", "Почти. Ещё один блок — и всё. Запланируй."),
|
||||
(1, "You started, which is the hard part. Scale it tomorrow.", "Ты начал — это самое сложное. Завтра увеличивай."),
|
||||
(0, "Zero. Pick the smallest task and run one 10-minute timer tomorrow.", "Ноль. Завтра — самая мелкая задача и таймер на 10 минут."),
|
||||
],
|
||||
"drill_sergeant": [
|
||||
(100, "Acceptable. Do it again tomorrow. 🫡", "Принято. Завтра повторить. 🫡"),
|
||||
(60, "Half-measures. The goal was the goal.", "Полумеры. Цель была целью."),
|
||||
(1, "That was a warm-up, not a day of work.", "Это была разминка, а не рабочий день."),
|
||||
(0, "Nothing. Zero. The timer was right there.", "Ничего. Ноль. Таймер был прямо здесь."),
|
||||
],
|
||||
"dry": [
|
||||
(100, "Goal reached.", "Цель достигнута."),
|
||||
(60, "Goal partially reached.", "Цель достигнута частично."),
|
||||
(1, "Minimal activity recorded.", "Зафиксирована минимальная активность."),
|
||||
(0, "No activity recorded.", "Активность не зафиксирована."),
|
||||
],
|
||||
}
|
||||
rows = table.get(tone, table["friendly"])
|
||||
for threshold, text_en, text_ru in rows:
|
||||
if pct >= threshold:
|
||||
return text_ru if ru else text_en
|
||||
return ""
|
||||
|
||||
|
||||
def previous_best(day_counts: dict[date, int], until: date) -> int:
|
||||
return max((count for day, count in day_counts.items() if day < until), default=0)
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Blocking policy.
|
||||
|
||||
The backend decides *whether* blocking should be on right now and *what* is on
|
||||
the list; the desktop app is the thing that actually enforces it (it is the only
|
||||
piece running on your machine with the privileges to rewrite `/etc/hosts` or
|
||||
kill a process). Keeping the policy here means the web UI can edit the rules,
|
||||
and a second machine running the desktop app obeys the same list.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import BlockEvent
|
||||
from app.services import pomodoro
|
||||
from app.settings_schema import AppSettings
|
||||
from app.timeutil import ensure_utc, in_window, local_date, local_time
|
||||
|
||||
|
||||
def expand_domains(settings: AppSettings) -> list[str]:
|
||||
"""Every hostname the desktop app should point at the redirect IP.
|
||||
|
||||
A rule like `youtube.com` is meant as "that site", so `www.` and the common
|
||||
subdomains come along; anything in `allowed_domains` is removed again, which
|
||||
is how `youtube.com` blocked + `music.youtube.com` allowed works.
|
||||
"""
|
||||
blocker = settings.blocker
|
||||
allowed = {entry.strip().lower().lstrip(".") for entry in blocker.allowed_domains if entry.strip()}
|
||||
|
||||
hosts: list[str] = []
|
||||
for raw in blocker.blocked_domains:
|
||||
domain = raw.strip().lower().lstrip(".")
|
||||
if not domain or domain in allowed:
|
||||
continue
|
||||
for candidate in (domain, f"www.{domain}", f"m.{domain}"):
|
||||
if candidate not in allowed and candidate not in hosts:
|
||||
hosts.append(candidate)
|
||||
return hosts
|
||||
|
||||
|
||||
def schedule_active(settings: AppSettings, moment: datetime | None = None) -> bool:
|
||||
now_time = local_time(settings.timezone, moment)
|
||||
weekday = (moment or datetime.now()).weekday()
|
||||
for window in settings.blocker.schedule:
|
||||
if weekday in window.days and in_window(now_time, window.start, window.end):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def policy(session: Session, settings: AppSettings) -> dict[str, Any]:
|
||||
"""What the desktop app polls: should I block, and what."""
|
||||
blocker = settings.blocker
|
||||
current = pomodoro.current_session(session)
|
||||
active = False
|
||||
reason = "disabled"
|
||||
|
||||
if blocker.enabled:
|
||||
if blocker.mode == "always":
|
||||
active, reason = True, "always on"
|
||||
elif blocker.mode == "scheduled":
|
||||
active = schedule_active(settings)
|
||||
reason = "inside a blocking window" if active else "outside every window"
|
||||
else: # during_work
|
||||
if current is not None and current.kind == "work" and current.status in ("running", "paused"):
|
||||
active, reason = True, "a work interval is running"
|
||||
elif (
|
||||
current is not None
|
||||
and current.kind != "work"
|
||||
and blocker.block_during_breaks
|
||||
and current.status in ("running", "paused")
|
||||
):
|
||||
active, reason = True, "blocking stays on through breaks"
|
||||
else:
|
||||
reason = "no work interval is running"
|
||||
|
||||
return {
|
||||
"active": active,
|
||||
"reason": reason,
|
||||
"mode": blocker.mode,
|
||||
"enabled": blocker.enabled,
|
||||
"strict": blocker.strict,
|
||||
"redirect_ip": blocker.redirect_ip,
|
||||
"domains": expand_domains(settings) if active else [],
|
||||
"apps": [app.strip() for app in blocker.blocked_apps if app.strip()] if active else [],
|
||||
"kill_apps": blocker.kill_blocked_apps,
|
||||
"notify_on_block": blocker.notify_on_block,
|
||||
"poll_seconds": blocker.app_poll_seconds,
|
||||
"session_id": current.id if current else None,
|
||||
"all_domains": expand_domains(settings),
|
||||
"all_apps": [app.strip() for app in blocker.blocked_apps if app.strip()],
|
||||
}
|
||||
|
||||
|
||||
def record_event(
|
||||
session: Session,
|
||||
settings: AppSettings,
|
||||
*,
|
||||
kind: str,
|
||||
target: str,
|
||||
action: str = "blocked",
|
||||
details: str | None = None,
|
||||
) -> BlockEvent:
|
||||
current = pomodoro.current_session(session)
|
||||
event = BlockEvent(
|
||||
kind=kind,
|
||||
target=target.strip().lower(),
|
||||
action=action,
|
||||
details=details,
|
||||
local_date=local_date(settings.timezone),
|
||||
session_id=current.id if current else None,
|
||||
)
|
||||
session.add(event)
|
||||
session.commit()
|
||||
return event
|
||||
|
||||
|
||||
def events_for_day(session: Session, settings: AppSettings, day: date | None = None) -> dict[str, Any]:
|
||||
day = day or local_date(settings.timezone)
|
||||
rows = list(session.scalars(select(BlockEvent).where(BlockEvent.local_date == day)))
|
||||
by_target = dict(
|
||||
session.execute(
|
||||
select(BlockEvent.target, func.count(BlockEvent.id))
|
||||
.where(BlockEvent.local_date == day)
|
||||
.group_by(BlockEvent.target)
|
||||
.order_by(func.count(BlockEvent.id).desc())
|
||||
).all()
|
||||
)
|
||||
return {
|
||||
"date": day.isoformat(),
|
||||
"total": len(rows),
|
||||
"by_target": [{"target": target, "count": count} for target, count in by_target.items()],
|
||||
"events": [
|
||||
{
|
||||
"id": row.id,
|
||||
"kind": row.kind,
|
||||
"target": row.target,
|
||||
"action": row.action,
|
||||
"at": ensure_utc(row.at).isoformat(),
|
||||
"details": row.details,
|
||||
}
|
||||
for row in sorted(rows, key=lambda row: ensure_utc(row.at), reverse=True)[:100]
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
DEFAULT_DOMAINS = [
|
||||
"youtube.com",
|
||||
"twitter.com",
|
||||
"x.com",
|
||||
"instagram.com",
|
||||
"tiktok.com",
|
||||
"reddit.com",
|
||||
"facebook.com",
|
||||
"vk.com",
|
||||
"twitch.tv",
|
||||
"netflix.com",
|
||||
"9gag.com",
|
||||
"pikabu.ru",
|
||||
]
|
||||
|
||||
DEFAULT_APPS = ["steam", "discord", "telegram-desktop", "spotify"]
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Outbound messages: Telegram first, Slack next, the log as a last resort.
|
||||
|
||||
The chunking, the HTML parse mode and the "Telegram > Slack > console" order are
|
||||
jira-helper's, because they are the right answers. What is added here is quiet
|
||||
hours and a record of what was sent, so the nudges cannot turn into spam.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import EventLog
|
||||
from app.settings_schema import AppSettings
|
||||
from app.timeutil import in_window, local_date, local_time
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Telegram rejects messages longer than 4096 characters; leave headroom.
|
||||
_TELEGRAM_MAX_CHARS = 4000
|
||||
_TIMEOUT = 20
|
||||
|
||||
|
||||
class NotifyError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Delivery:
|
||||
channel: str
|
||||
ok: bool
|
||||
detail: str = ""
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {"channel": self.channel, "ok": self.ok, "detail": self.detail}
|
||||
|
||||
|
||||
def _chunk(text: str, size: int = _TELEGRAM_MAX_CHARS) -> list[str]:
|
||||
"""Split on line boundaries so a message never exceeds Telegram's limit."""
|
||||
chunks: list[str] = []
|
||||
current = ""
|
||||
for line in text.split("\n"):
|
||||
candidate = f"{current}\n{line}" if current else line
|
||||
if len(candidate) > size and current:
|
||||
chunks.append(current)
|
||||
current = line
|
||||
else:
|
||||
current = candidate
|
||||
if current:
|
||||
chunks.append(current)
|
||||
return chunks or [""]
|
||||
|
||||
|
||||
def send_telegram(
|
||||
settings: AppSettings, text: str, *, reply_markup: dict | None = None
|
||||
) -> Delivery:
|
||||
telegram = settings.notifications.telegram
|
||||
if not (telegram.enabled and telegram.bot_token and telegram.chat_id):
|
||||
return Delivery("telegram", False, "Telegram is not configured.")
|
||||
|
||||
url = f"https://api.telegram.org/bot{telegram.bot_token}/sendMessage"
|
||||
parts = _chunk(text)
|
||||
try:
|
||||
with httpx.Client(timeout=_TIMEOUT) as client:
|
||||
for index, part in enumerate(parts):
|
||||
payload = {
|
||||
"chat_id": telegram.chat_id,
|
||||
"text": part,
|
||||
"parse_mode": "HTML",
|
||||
"disable_web_page_preview": True,
|
||||
}
|
||||
# Buttons belong on the last chunk, where the message ends.
|
||||
if reply_markup and index == len(parts) - 1:
|
||||
payload["reply_markup"] = reply_markup
|
||||
resp = client.post(url, json=payload)
|
||||
if not resp.is_success:
|
||||
return Delivery(
|
||||
"telegram", False, f"{resp.status_code}: {resp.text[:200]}"
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
return Delivery("telegram", False, str(exc))
|
||||
return Delivery("telegram", True, f"{len(parts)} message(s)")
|
||||
|
||||
|
||||
def send_slack(settings: AppSettings, text: str) -> Delivery:
|
||||
slack = settings.notifications.slack
|
||||
if not (slack.enabled and slack.webhook_url):
|
||||
return Delivery("slack", False, "Slack is not configured.")
|
||||
try:
|
||||
with httpx.Client(timeout=_TIMEOUT) as client:
|
||||
resp = client.post(slack.webhook_url, json={"text": _strip_html(text)})
|
||||
if not resp.is_success:
|
||||
return Delivery("slack", False, f"{resp.status_code}: {resp.text[:200]}")
|
||||
except httpx.HTTPError as exc:
|
||||
return Delivery("slack", False, str(exc))
|
||||
return Delivery("slack", True, "ok")
|
||||
|
||||
|
||||
def _strip_html(text: str) -> str:
|
||||
import re
|
||||
|
||||
return re.sub(r"<[^>]+>", "", text)
|
||||
|
||||
|
||||
def in_quiet_hours(settings: AppSettings) -> bool:
|
||||
quiet = settings.notifications.telegram.quiet_hours
|
||||
if not quiet.enabled:
|
||||
return False
|
||||
return in_window(local_time(settings.timezone), quiet.start, quiet.end)
|
||||
|
||||
|
||||
def notify(
|
||||
settings: AppSettings,
|
||||
text: str,
|
||||
*,
|
||||
urgent: bool = False,
|
||||
reply_markup: dict | None = None,
|
||||
) -> list[Delivery]:
|
||||
"""Send to every configured channel. `urgent` ignores quiet hours."""
|
||||
if not urgent and in_quiet_hours(settings):
|
||||
return [Delivery("quiet-hours", False, "suppressed by quiet hours")]
|
||||
|
||||
deliveries: list[Delivery] = []
|
||||
telegram = send_telegram(settings, text, reply_markup=reply_markup)
|
||||
if telegram.ok or telegram.detail != "Telegram is not configured.":
|
||||
deliveries.append(telegram)
|
||||
slack = send_slack(settings, text)
|
||||
if slack.ok or slack.detail != "Slack is not configured.":
|
||||
deliveries.append(slack)
|
||||
if not deliveries:
|
||||
log.info("notification (no channel configured):\n%s", _strip_html(text))
|
||||
deliveries.append(Delivery("log", True, "written to the server log"))
|
||||
return deliveries
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# "Did we already send this today?"
|
||||
# --------------------------------------------------------------------------
|
||||
def already_sent(session: Session, kind: str, day: date) -> bool:
|
||||
return (
|
||||
session.scalar(
|
||||
select(EventLog.id).where(EventLog.kind == kind, EventLog.local_date == day)
|
||||
)
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def count_sent(session: Session, kind: str, day: date) -> int:
|
||||
return len(
|
||||
list(
|
||||
session.scalars(
|
||||
select(EventLog.id).where(EventLog.kind == kind, EventLog.local_date == day)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def record_sent(session: Session, kind: str, day: date, payload: dict | None = None) -> None:
|
||||
session.add(EventLog(kind=kind, local_date=day, payload=payload or {}))
|
||||
session.commit()
|
||||
|
||||
|
||||
def today(settings: AppSettings) -> date:
|
||||
return local_date(settings.timezone)
|
||||
@@ -0,0 +1,354 @@
|
||||
"""The timer engine.
|
||||
|
||||
The server owns the clock. A browser tab or the desktop window only renders
|
||||
`remaining_seconds`, computed from `started_at` — so reloading the page, closing
|
||||
the laptop lid or switching from the web UI to the desktop app all leave the
|
||||
same interval running, and the history is never a guess.
|
||||
|
||||
One interval is one row. Work and breaks are both intervals, which is what
|
||||
makes "you actually rested 3 of the 7 breaks you were owed" a thing the report
|
||||
can say.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Any, Literal
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import PomodoroSession, TaskRecord, utcnow
|
||||
from app.settings_schema import AppSettings
|
||||
from app.timeutil import day_bounds_utc, ensure_utc, local_date
|
||||
|
||||
Kind = Literal["work", "short_break", "long_break"]
|
||||
|
||||
ACTIVE_STATUSES = ("running", "paused")
|
||||
|
||||
|
||||
class TimerError(RuntimeError):
|
||||
"""An operation that does not fit the timer's current state."""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Reading state
|
||||
# --------------------------------------------------------------------------
|
||||
def current_session(session: Session) -> PomodoroSession | None:
|
||||
return session.scalar(
|
||||
select(PomodoroSession)
|
||||
.where(PomodoroSession.status.in_(ACTIVE_STATUSES))
|
||||
.order_by(PomodoroSession.started_at.desc())
|
||||
)
|
||||
|
||||
|
||||
def elapsed_seconds(row: PomodoroSession, now: datetime | None = None) -> int:
|
||||
"""Wall-clock seconds spent inside the interval, excluding paused time."""
|
||||
now = now or utcnow()
|
||||
if row.status == "paused" and row.paused_at is not None:
|
||||
end = ensure_utc(row.paused_at)
|
||||
elif row.ended_at is not None:
|
||||
end = ensure_utc(row.ended_at)
|
||||
else:
|
||||
end = now
|
||||
started = ensure_utc(row.started_at)
|
||||
return max(int((end - started).total_seconds()) - row.paused_seconds, 0)
|
||||
|
||||
|
||||
def remaining_seconds(row: PomodoroSession, now: datetime | None = None) -> int:
|
||||
return max(row.planned_seconds - elapsed_seconds(row, now), 0)
|
||||
|
||||
|
||||
def duration_for(settings: AppSettings, kind: Kind) -> int:
|
||||
minutes = {
|
||||
"work": settings.pomodoro.work_minutes,
|
||||
"short_break": settings.pomodoro.short_break_minutes,
|
||||
"long_break": settings.pomodoro.long_break_minutes,
|
||||
}[kind]
|
||||
return minutes * 60
|
||||
|
||||
|
||||
def completed_work_today(session: Session, settings: AppSettings, day: date | None = None) -> int:
|
||||
day = day or local_date(settings.timezone)
|
||||
return int(
|
||||
session.scalar(
|
||||
select(func.count(PomodoroSession.id)).where(
|
||||
PomodoroSession.local_date == day,
|
||||
PomodoroSession.kind == "work",
|
||||
PomodoroSession.status == "completed",
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
|
||||
def next_kind(session: Session, settings: AppSettings) -> Kind:
|
||||
"""What the timer should run next, following the long-break cycle.
|
||||
|
||||
After every `cycles_before_long_break` completed work intervals today the
|
||||
next break is the long one. The count resets with the day, so a long break
|
||||
is never "owed" from yesterday.
|
||||
"""
|
||||
last = session.scalar(
|
||||
select(PomodoroSession)
|
||||
.where(PomodoroSession.status.in_(("completed", "abandoned")))
|
||||
.order_by(PomodoroSession.started_at.desc())
|
||||
)
|
||||
if last is None or last.kind != "work" or last.status == "abandoned":
|
||||
return "work"
|
||||
done = completed_work_today(session, settings)
|
||||
if done and done % settings.pomodoro.cycles_before_long_break == 0:
|
||||
return "long_break"
|
||||
return "short_break"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Transitions
|
||||
# --------------------------------------------------------------------------
|
||||
def start(
|
||||
session: Session,
|
||||
settings: AppSettings,
|
||||
*,
|
||||
kind: Kind | None = None,
|
||||
minutes: int | None = None,
|
||||
task_id: int | None = None,
|
||||
note: str | None = None,
|
||||
) -> PomodoroSession:
|
||||
"""Begin an interval, abandoning whatever was still running."""
|
||||
running = current_session(session)
|
||||
if running is not None:
|
||||
_finish(session, settings, running, status="abandoned", reason="superseded")
|
||||
|
||||
kind = kind or next_kind(session, settings)
|
||||
planned = (minutes * 60) if minutes else duration_for(settings, kind)
|
||||
|
||||
task_source = task_external_id = task_title = None
|
||||
if task_id is not None:
|
||||
task = session.get(TaskRecord, task_id)
|
||||
if task is None:
|
||||
raise TimerError(f"no task {task_id}")
|
||||
task_source, task_external_id, task_title = task.source, task.external_id, task.title
|
||||
|
||||
done_today = completed_work_today(session, settings)
|
||||
row = PomodoroSession(
|
||||
kind=kind,
|
||||
status="running",
|
||||
started_at=utcnow(),
|
||||
local_date=local_date(settings.timezone),
|
||||
planned_seconds=planned,
|
||||
cycle_index=(done_today % settings.pomodoro.cycles_before_long_break) + 1,
|
||||
task_source=task_source,
|
||||
task_external_id=task_external_id,
|
||||
task_title=task_title,
|
||||
note=note,
|
||||
)
|
||||
session.add(row)
|
||||
session.commit()
|
||||
return row
|
||||
|
||||
|
||||
def pause(session: Session, settings: AppSettings) -> PomodoroSession:
|
||||
row = _require_active(session)
|
||||
if not settings.pomodoro.allow_pause:
|
||||
raise TimerError("Pausing is switched off in your settings.")
|
||||
if row.status == "paused":
|
||||
return row
|
||||
row.status = "paused"
|
||||
row.paused_at = utcnow()
|
||||
if settings.pomodoro.count_interruptions and row.is_work:
|
||||
row.interruptions += 1
|
||||
session.commit()
|
||||
return row
|
||||
|
||||
|
||||
def resume(session: Session) -> PomodoroSession:
|
||||
row = _require_active(session)
|
||||
if row.status != "paused" or row.paused_at is None:
|
||||
return row
|
||||
row.paused_seconds += max(int((utcnow() - ensure_utc(row.paused_at)).total_seconds()), 0)
|
||||
row.paused_at = None
|
||||
row.status = "running"
|
||||
session.commit()
|
||||
return row
|
||||
|
||||
|
||||
def complete(
|
||||
session: Session, settings: AppSettings, *, auto_next: bool = True
|
||||
) -> tuple[PomodoroSession, PomodoroSession | None]:
|
||||
"""Finish the current interval and, if configured, roll into the next one."""
|
||||
row = _require_active(session)
|
||||
finished = _finish(session, settings, row, status="completed")
|
||||
|
||||
follow_on: PomodoroSession | None = None
|
||||
if auto_next:
|
||||
wants_break = finished.is_work and settings.pomodoro.auto_start_breaks
|
||||
wants_work = (not finished.is_work) and settings.pomodoro.auto_start_work
|
||||
if wants_break or wants_work:
|
||||
follow_on = start(session, settings, kind=next_kind(session, settings))
|
||||
return finished, follow_on
|
||||
|
||||
|
||||
def abandon(
|
||||
session: Session, settings: AppSettings, *, reason: str | None = None
|
||||
) -> PomodoroSession:
|
||||
row = _require_active(session)
|
||||
if settings.pomodoro.strict_mode and row.is_work and not (reason or "").strip():
|
||||
raise TimerError("Strict mode: give a reason for dropping this pomodoro.")
|
||||
return _finish(session, settings, row, status="abandoned", reason=reason)
|
||||
|
||||
|
||||
def add_interruption(session: Session) -> PomodoroSession:
|
||||
""""The phone rang" — counted without stopping the clock."""
|
||||
row = _require_active(session)
|
||||
row.interruptions += 1
|
||||
session.commit()
|
||||
return row
|
||||
|
||||
|
||||
def extend(session: Session, minutes: int) -> PomodoroSession:
|
||||
"""Add time to the running interval ("just five more minutes")."""
|
||||
row = _require_active(session)
|
||||
row.planned_seconds = max(row.planned_seconds + minutes * 60, 60)
|
||||
session.commit()
|
||||
return row
|
||||
|
||||
|
||||
def attach_task(session: Session, task_id: int | None) -> PomodoroSession:
|
||||
row = _require_active(session)
|
||||
if task_id is None:
|
||||
row.task_source = row.task_external_id = row.task_title = None
|
||||
else:
|
||||
task = session.get(TaskRecord, task_id)
|
||||
if task is None:
|
||||
raise TimerError(f"no task {task_id}")
|
||||
row.task_source, row.task_external_id, row.task_title = (
|
||||
task.source,
|
||||
task.external_id,
|
||||
task.title,
|
||||
)
|
||||
session.commit()
|
||||
return row
|
||||
|
||||
|
||||
def _require_active(session: Session) -> PomodoroSession:
|
||||
row = current_session(session)
|
||||
if row is None:
|
||||
raise TimerError("No interval is running.")
|
||||
return row
|
||||
|
||||
|
||||
def _finish(
|
||||
session: Session,
|
||||
settings: AppSettings,
|
||||
row: PomodoroSession,
|
||||
*,
|
||||
status: str,
|
||||
reason: str | None = None,
|
||||
) -> PomodoroSession:
|
||||
now = utcnow()
|
||||
if row.status == "paused" and row.paused_at is not None:
|
||||
row.paused_seconds += max(int((now - ensure_utc(row.paused_at)).total_seconds()), 0)
|
||||
row.paused_at = None
|
||||
row.actual_seconds = elapsed_seconds(row, now)
|
||||
row.ended_at = now
|
||||
row.status = status
|
||||
if reason:
|
||||
row.abandon_reason = reason
|
||||
session.commit()
|
||||
return row
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Serialisation
|
||||
# --------------------------------------------------------------------------
|
||||
@dataclass(slots=True)
|
||||
class TimerState:
|
||||
"""What the clients render. Everything derived, nothing stored twice."""
|
||||
|
||||
session: dict[str, Any] | None
|
||||
next_kind: str
|
||||
completed_today: int
|
||||
goal: int
|
||||
settings_version: str
|
||||
|
||||
|
||||
def session_dict(row: PomodoroSession | None) -> dict[str, Any] | None:
|
||||
if row is None:
|
||||
return None
|
||||
return {
|
||||
"id": row.id,
|
||||
"kind": row.kind,
|
||||
"status": row.status,
|
||||
"started_at": ensure_utc(row.started_at).isoformat(),
|
||||
"ended_at": ensure_utc(row.ended_at).isoformat() if row.ended_at else None,
|
||||
"planned_seconds": row.planned_seconds,
|
||||
"elapsed_seconds": elapsed_seconds(row),
|
||||
"remaining_seconds": remaining_seconds(row),
|
||||
"paused_seconds": row.paused_seconds,
|
||||
"interruptions": row.interruptions,
|
||||
"cycle_index": row.cycle_index,
|
||||
"task": (
|
||||
{
|
||||
"source": row.task_source,
|
||||
"external_id": row.task_external_id,
|
||||
"title": row.task_title,
|
||||
}
|
||||
if row.task_title
|
||||
else None
|
||||
),
|
||||
"note": row.note,
|
||||
}
|
||||
|
||||
|
||||
def state(session: Session, settings: AppSettings) -> dict[str, Any]:
|
||||
row = current_session(session)
|
||||
done = completed_work_today(session, settings)
|
||||
return {
|
||||
"current": session_dict(row),
|
||||
"next_kind": next_kind(session, settings),
|
||||
"completed_today": done,
|
||||
"goal": settings.pomodoro.daily_goal_pomodoros,
|
||||
"cycle_position": (done % settings.pomodoro.cycles_before_long_break) + 1,
|
||||
"cycles_before_long_break": settings.pomodoro.cycles_before_long_break,
|
||||
"durations": {
|
||||
"work": settings.pomodoro.work_minutes,
|
||||
"short_break": settings.pomodoro.short_break_minutes,
|
||||
"long_break": settings.pomodoro.long_break_minutes,
|
||||
},
|
||||
"server_time": utcnow().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def sessions_for_day(session: Session, settings: AppSettings, day: date) -> list[PomodoroSession]:
|
||||
start_utc, end_utc = day_bounds_utc(day, settings.timezone)
|
||||
return list(
|
||||
session.scalars(
|
||||
select(PomodoroSession)
|
||||
.where(PomodoroSession.started_at >= start_utc, PomodoroSession.started_at < end_utc)
|
||||
.order_by(PomodoroSession.started_at.asc())
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def reap_stale(session: Session, settings: AppSettings, *, max_overrun_hours: int = 6) -> int:
|
||||
"""Close intervals left running by a crash or a machine that went to sleep.
|
||||
|
||||
Without this one forgotten tab would report a 14-hour pomodoro and poison
|
||||
every average in the stats.
|
||||
"""
|
||||
cutoff = utcnow() - timedelta(hours=max_overrun_hours)
|
||||
stale = session.scalars(
|
||||
select(PomodoroSession).where(
|
||||
PomodoroSession.status.in_(ACTIVE_STATUSES),
|
||||
PomodoroSession.started_at < cutoff,
|
||||
)
|
||||
).all()
|
||||
for row in stale:
|
||||
row.status = "abandoned"
|
||||
row.ended_at = ensure_utc(row.started_at) + timedelta(seconds=row.planned_seconds)
|
||||
row.actual_seconds = row.planned_seconds
|
||||
row.abandon_reason = "auto-closed: left running"
|
||||
if stale:
|
||||
session.commit()
|
||||
return len(stale)
|
||||
@@ -0,0 +1,441 @@
|
||||
"""Reports: the daily wrap-up, the morning briefing, the weekly review.
|
||||
|
||||
A report is built once, stored as JSON, and then *rendered* — to HTML for
|
||||
Telegram or to a structure the web UI draws cards from. Because both come from
|
||||
the same payload, the message in your chat and the page in the browser can
|
||||
never tell you different numbers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
from datetime import date, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import DailyReport, PomodoroSession, utcnow
|
||||
from app.services import achievements as ach
|
||||
from app.services import notify, stats, tasks_service
|
||||
from app.settings_schema import AppSettings
|
||||
from app.sources.registry import SOURCE_LABELS
|
||||
from app.timeutil import humanize_minutes, local_date
|
||||
|
||||
BAR_FULL = "█"
|
||||
BAR_EMPTY = "░"
|
||||
|
||||
|
||||
def _days(count: int, ru: bool) -> str:
|
||||
""""1 day" / "3 days" / "1 день" / "3 дня" — plurals, cheaply."""
|
||||
if ru:
|
||||
if count % 10 == 1 and count % 100 != 11:
|
||||
return f"{count} день"
|
||||
if count % 10 in (2, 3, 4) and count % 100 not in (12, 13, 14):
|
||||
return f"{count} дня"
|
||||
return f"{count} дней"
|
||||
return f"{count} day" if count == 1 else f"{count} days"
|
||||
|
||||
|
||||
def _esc(value: Any) -> str:
|
||||
"""Escape borrowed text for Telegram's HTML parse mode.
|
||||
|
||||
Without this one task summary containing `&` makes Telegram reject the
|
||||
entire message — jira-helper learned that the hard way.
|
||||
"""
|
||||
return html.escape(str(value), quote=False)
|
||||
|
||||
|
||||
def progress_bar(value: int, total: int, width: int = 10) -> str:
|
||||
if total <= 0:
|
||||
return BAR_EMPTY * width
|
||||
filled = min(width, round(width * value / total))
|
||||
return BAR_FULL * filled + BAR_EMPTY * (width - filled)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Daily report
|
||||
# --------------------------------------------------------------------------
|
||||
def build_daily(session: Session, settings: AppSettings, day: date) -> dict[str, Any]:
|
||||
"""Assemble everything the daily report shows, as a plain dict."""
|
||||
metrics = stats.day_metrics(session, settings, day)
|
||||
streak = stats.streak(session, settings, until=day)
|
||||
average_7d = stats.averages(session, settings, 7, until=day)
|
||||
lifetime = stats.lifetime(session, settings)
|
||||
|
||||
day_counts = dict(
|
||||
session.execute(
|
||||
select(PomodoroSession.local_date, func.count(PomodoroSession.id))
|
||||
.where(
|
||||
PomodoroSession.kind == "work",
|
||||
PomodoroSession.status == "completed",
|
||||
)
|
||||
.group_by(PomodoroSession.local_date)
|
||||
).all()
|
||||
)
|
||||
yesterday = day - timedelta(days=1)
|
||||
|
||||
context = {
|
||||
"streak": streak,
|
||||
"average_7d": average_7d,
|
||||
"lifetime": lifetime,
|
||||
"previous_best": ach.previous_best(day_counts, day),
|
||||
"yesterday_pomodoros": day_counts.get(yesterday, 0),
|
||||
}
|
||||
|
||||
report_settings = settings.notifications.report
|
||||
language = report_settings.language
|
||||
|
||||
open_tasks = tasks_service.list_tasks(session)
|
||||
buckets = tasks_service.bucket_tasks(open_tasks, day)
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"date": day.isoformat(),
|
||||
"generated_at": utcnow().isoformat(),
|
||||
"timezone": settings.timezone,
|
||||
"metrics": metrics,
|
||||
"streak": streak,
|
||||
"average_7d": average_7d,
|
||||
"lifetime": lifetime,
|
||||
"comparison": {
|
||||
"yesterday": day_counts.get(yesterday, 0),
|
||||
"previous_best": context["previous_best"],
|
||||
},
|
||||
"achievements": (
|
||||
ach.evaluate(metrics, context, language)
|
||||
if report_settings.include_achievements
|
||||
else []
|
||||
),
|
||||
"fun_facts": (
|
||||
ach.fun_facts(metrics, context, language)
|
||||
if report_settings.include_funny_stats
|
||||
else []
|
||||
),
|
||||
"verdict": ach.verdict(metrics, report_settings.tone, language),
|
||||
"open_tasks": {
|
||||
"overdue": len(buckets["overdue"]),
|
||||
"today": len(buckets["today"]),
|
||||
"upcoming": len(buckets["upcoming"]),
|
||||
"someday": len(buckets["someday"]),
|
||||
},
|
||||
"score": _score(metrics, streak),
|
||||
}
|
||||
return payload
|
||||
|
||||
|
||||
def _score(metrics: dict[str, Any], streak: dict[str, int]) -> float:
|
||||
"""A single 0-100 number for the dashboard's big dial.
|
||||
|
||||
Goal progress is most of it; finishing tasks, resisting the blocker and
|
||||
keeping a streak nudge it up, dropped pomodoros pull it down.
|
||||
"""
|
||||
score = min(metrics["goal_pct"], 120) * 0.6
|
||||
score += min(metrics["tasks_completed_count"], 6) * 3
|
||||
score += min(streak.get("current", 0), 7) * 1.5
|
||||
score += min(metrics["longest_stretch_minutes"] / 30, 4) * 2
|
||||
score -= metrics["abandoned"] * 3
|
||||
score -= max(metrics["interruptions"] - 2, 0) * 1.5
|
||||
return round(max(0.0, min(100.0, score)), 1)
|
||||
|
||||
|
||||
def render_daily_telegram(payload: dict[str, Any], settings: AppSettings) -> str:
|
||||
"""The daily report as Telegram HTML."""
|
||||
report_settings = settings.notifications.report
|
||||
ru = report_settings.language == "ru"
|
||||
metrics = payload["metrics"]
|
||||
lines: list[str] = []
|
||||
|
||||
header = "🍅 Отчёт за день" if ru else "🍅 Daily report"
|
||||
lines.append(f"<b>{header} — {payload['date']}</b>")
|
||||
lines.append("")
|
||||
|
||||
bar = progress_bar(metrics["pomodoros"], metrics["goal"])
|
||||
goal_line = (
|
||||
f"{bar} <b>{metrics['pomodoros']}/{metrics['goal']}</b> "
|
||||
+ ("помидорок" if ru else "pomodoros")
|
||||
)
|
||||
lines.append(goal_line)
|
||||
lines.append(
|
||||
("⏱ Фокус: " if ru else "⏱ Focus: ")
|
||||
+ f"<b>{humanize_minutes(metrics['focus_minutes'])}</b>"
|
||||
+ (" · перерывы: " if ru else " · breaks: ")
|
||||
+ humanize_minutes(metrics["break_minutes"])
|
||||
)
|
||||
|
||||
detail_bits: list[str] = []
|
||||
if metrics["longest_stretch_minutes"]:
|
||||
detail_bits.append(
|
||||
("🧠 дольше всего без разрыва: " if ru else "🧠 longest stretch: ")
|
||||
+ humanize_minutes(metrics["longest_stretch_minutes"])
|
||||
)
|
||||
if metrics["first_start"]:
|
||||
detail_bits.append(
|
||||
("🕘 с " if ru else "🕘 from ")
|
||||
+ f"{metrics['first_start']}"
|
||||
+ (" до " if ru else " to ")
|
||||
+ f"{metrics['last_end']}"
|
||||
)
|
||||
if metrics["abandoned"]:
|
||||
detail_bits.append(
|
||||
("💨 брошено: " if ru else "💨 dropped: ") + str(metrics["abandoned"])
|
||||
)
|
||||
if metrics["interruptions"]:
|
||||
detail_bits.append(
|
||||
("📵 прерываний: " if ru else "📵 interruptions: ") + str(metrics["interruptions"])
|
||||
)
|
||||
if detail_bits:
|
||||
lines.append(" · ".join(detail_bits))
|
||||
|
||||
streak = payload["streak"]
|
||||
if streak.get("current"):
|
||||
lines.append(
|
||||
(f"🔥 Серия: {_days(streak['current'], True)} (рекорд {streak['best']})")
|
||||
if ru
|
||||
else f"🔥 Streak: {_days(streak['current'], False)} (best {streak['best']})"
|
||||
)
|
||||
|
||||
if report_settings.include_comparison:
|
||||
avg = payload["average_7d"]["pomodoros"]
|
||||
if avg:
|
||||
lines.append(
|
||||
(f"📊 Среднее за 7 дней: {avg} · вчера: {payload['comparison']['yesterday']}")
|
||||
if ru
|
||||
else (
|
||||
f"📊 7-day average: {avg} · yesterday: {payload['comparison']['yesterday']}"
|
||||
)
|
||||
)
|
||||
|
||||
tasks = metrics["tasks_completed"]
|
||||
if report_settings.include_task_list and tasks:
|
||||
lines.append("")
|
||||
lines.append(
|
||||
f"<b>{'✅ Закрытые задачи' if ru else '✅ Tasks closed'} ({len(tasks)})</b>"
|
||||
)
|
||||
for task in tasks[: report_settings.max_tasks_listed]:
|
||||
label = SOURCE_LABELS.get(task["source"], task["source"])
|
||||
title = _esc(task["title"])
|
||||
if task.get("url"):
|
||||
lines.append(f"• <a href=\"{_esc(task['url'])}\">{title}</a> <i>({label})</i>")
|
||||
else:
|
||||
lines.append(f"• {title} <i>({label})</i>")
|
||||
if len(tasks) > report_settings.max_tasks_listed:
|
||||
rest = len(tasks) - report_settings.max_tasks_listed
|
||||
lines.append(("… и ещё " if ru else "… and ") + f"{rest}" + ("" if ru else " more"))
|
||||
|
||||
if report_settings.include_blocked_sites and metrics["blocked_top"]:
|
||||
lines.append("")
|
||||
lines.append(f"<b>{'🚫 Соблазны' if ru else '🚫 Temptations blocked'}</b>")
|
||||
for entry in metrics["blocked_top"][:5]:
|
||||
lines.append(f"• {_esc(entry['target'])} × {entry['count']}")
|
||||
|
||||
if payload["achievements"]:
|
||||
lines.append("")
|
||||
lines.append(f"<b>{'🏅 Достижения' if ru else '🏅 Achievements'}</b>")
|
||||
for item in payload["achievements"]:
|
||||
detail = f" — {_esc(item['detail'])}" if item.get("detail") else ""
|
||||
lines.append(f"{item['emoji']} <b>{_esc(item['title'])}</b>{detail}")
|
||||
|
||||
if payload["fun_facts"]:
|
||||
lines.append("")
|
||||
for fact in payload["fun_facts"]:
|
||||
lines.append(_esc(fact))
|
||||
|
||||
open_tasks = payload["open_tasks"]
|
||||
if open_tasks["overdue"] or open_tasks["today"]:
|
||||
lines.append("")
|
||||
lines.append(
|
||||
(f"📋 Осталось: {open_tasks['overdue']} просрочено, {open_tasks['today']} на сегодня")
|
||||
if ru
|
||||
else (
|
||||
f"📋 Still open: {open_tasks['overdue']} overdue, "
|
||||
f"{open_tasks['today']} due today"
|
||||
)
|
||||
)
|
||||
|
||||
if payload["verdict"]:
|
||||
lines.append("")
|
||||
lines.append(f"<i>{_esc(payload['verdict'])}</i>")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def get_or_build_daily(
|
||||
session: Session, settings: AppSettings, day: date, *, rebuild: bool = False
|
||||
) -> tuple[DailyReport, dict[str, Any]]:
|
||||
row = session.scalar(select(DailyReport).where(DailyReport.report_date == day))
|
||||
if row is not None and not rebuild:
|
||||
return row, row.payload
|
||||
|
||||
payload = build_daily(session, settings, day)
|
||||
if row is None:
|
||||
row = DailyReport(report_date=day, payload=payload, score=payload["score"])
|
||||
session.add(row)
|
||||
else:
|
||||
row.payload = payload
|
||||
row.score = payload["score"]
|
||||
row.generated_at = utcnow()
|
||||
session.commit()
|
||||
return row, payload
|
||||
|
||||
|
||||
def send_daily(
|
||||
session: Session, settings: AppSettings, day: date, *, force: bool = False
|
||||
) -> dict[str, Any]:
|
||||
"""Build (or reuse) the day's report and push it to the channels."""
|
||||
row, payload = get_or_build_daily(session, settings, day, rebuild=True)
|
||||
if row.sent_at is not None and not force:
|
||||
return {"sent": False, "reason": "already sent", "payload": payload}
|
||||
|
||||
text = render_daily_telegram(payload, settings)
|
||||
deliveries = notify.notify(settings, text, urgent=True)
|
||||
ok = any(delivery.ok for delivery in deliveries)
|
||||
if ok:
|
||||
row.sent_at = utcnow()
|
||||
row.sent_channel = ",".join(d.channel for d in deliveries if d.ok)
|
||||
session.commit()
|
||||
return {
|
||||
"sent": ok,
|
||||
"deliveries": [d.as_dict() for d in deliveries],
|
||||
"payload": payload,
|
||||
"text": text,
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Morning briefing
|
||||
# --------------------------------------------------------------------------
|
||||
def render_morning_briefing(session: Session, settings: AppSettings) -> str:
|
||||
""""Here is your day" — the todo list, the streak, and the goal to beat."""
|
||||
ru = settings.notifications.report.language == "ru"
|
||||
today = local_date(settings.timezone)
|
||||
tasks = tasks_service.list_tasks(session)
|
||||
buckets = tasks_service.bucket_tasks(tasks, today)
|
||||
streak = stats.streak(session, settings, until=today - timedelta(days=1))
|
||||
limit = settings.notifications.report.max_tasks_listed
|
||||
|
||||
lines = [f"<b>{'☀️ Доброе утро!' if ru else '☀️ Good morning!'}</b>", ""]
|
||||
goal = settings.pomodoro.daily_goal_pomodoros
|
||||
lines.append(
|
||||
(f"🎯 Цель на сегодня: <b>{goal}</b> помидорок "
|
||||
f"(~{humanize_minutes(goal * settings.pomodoro.work_minutes)} фокуса)")
|
||||
if ru
|
||||
else (
|
||||
f"🎯 Today's goal: <b>{goal}</b> pomodoros "
|
||||
f"(~{humanize_minutes(goal * settings.pomodoro.work_minutes)} of focus)"
|
||||
)
|
||||
)
|
||||
if streak.get("current"):
|
||||
lines.append(
|
||||
f"🔥 {'Серия' if ru else 'Streak'}: {_days(streak['current'], ru)}"
|
||||
+ (" — не разрывай" if ru else " — keep it alive")
|
||||
)
|
||||
|
||||
def section(title: str, records: list, emoji: str) -> None:
|
||||
if not records:
|
||||
return
|
||||
lines.append("")
|
||||
lines.append(f"<b>{emoji} {title} ({len(records)})</b>")
|
||||
for record in records[:limit]:
|
||||
label = SOURCE_LABELS.get(record.source, record.source)
|
||||
title_text = _esc(record.title)
|
||||
due = ""
|
||||
if record.due_date and record.due_date < today:
|
||||
days = (today - record.due_date).days
|
||||
due = (
|
||||
f" — <b>{days} дн. просрочки</b>" if ru else f" — <b>{days}d overdue</b>"
|
||||
)
|
||||
if record.url:
|
||||
lines.append(
|
||||
f"• <a href=\"{_esc(record.url)}\">{title_text}</a> <i>({label})</i>{due}"
|
||||
)
|
||||
else:
|
||||
lines.append(f"• {title_text} <i>({label})</i>{due}")
|
||||
if len(records) > limit:
|
||||
lines.append(("… и ещё " if ru else "… and ") + f"{len(records) - limit}")
|
||||
|
||||
section("Просрочено" if ru else "Overdue", buckets["overdue"], "🔴")
|
||||
section("На сегодня" if ru else "Due today", buckets["today"], "🟡")
|
||||
section("Скоро" if ru else "Coming up", buckets["upcoming"][:5], "🔵")
|
||||
|
||||
laundry = [task for task in tasks if task.source == "laundry"]
|
||||
if laundry:
|
||||
lines.append("")
|
||||
lines.append(f"<b>{'🧺 Стирка' if ru else '🧺 Laundry'}</b>")
|
||||
for slot in laundry:
|
||||
lines.append(f"• {_esc(slot.title)}")
|
||||
|
||||
if not any(buckets.values()):
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"✨ Входящих нет. Отличный день, чтобы поработать над чем-то важным."
|
||||
if ru
|
||||
else "✨ Nothing queued. A good day to work on something that matters."
|
||||
)
|
||||
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"<i>/start_work — запустить помидорку, /today — список, /report — отчёт</i>"
|
||||
if ru
|
||||
else "<i>/start_work to begin, /today for the list, /report for the numbers</i>"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Weekly review
|
||||
# --------------------------------------------------------------------------
|
||||
def build_weekly(session: Session, settings: AppSettings, end_day: date) -> dict[str, Any]:
|
||||
start_day = end_day - timedelta(days=6)
|
||||
days = stats.range_metrics(session, settings, start_day, end_day)
|
||||
total = sum(day["pomodoros"] for day in days)
|
||||
focus = sum(day["focus_minutes"] for day in days)
|
||||
best = max(days, key=lambda day: day["pomodoros"]) if days else None
|
||||
goal_days = sum(1 for day in days if day["goal_met"])
|
||||
return {
|
||||
"start": start_day.isoformat(),
|
||||
"end": end_day.isoformat(),
|
||||
"days": days,
|
||||
"pomodoros": total,
|
||||
"focus_minutes": focus,
|
||||
"best_day": best,
|
||||
"goal_days": goal_days,
|
||||
"average": round(total / len(days), 1) if days else 0,
|
||||
"streak": stats.streak(session, settings, until=end_day),
|
||||
"lifetime": stats.lifetime(session, settings),
|
||||
}
|
||||
|
||||
|
||||
def render_weekly_telegram(payload: dict[str, Any], settings: AppSettings) -> str:
|
||||
ru = settings.notifications.report.language == "ru"
|
||||
lines = [
|
||||
f"<b>{'📅 Итоги недели' if ru else '📅 Weekly review'} "
|
||||
f"({payload['start']} → {payload['end']})</b>",
|
||||
"",
|
||||
("🍅 Помидорок: " if ru else "🍅 Pomodoros: ")
|
||||
+ f"<b>{payload['pomodoros']}</b>"
|
||||
+ (" · в среднем " if ru else " · average ")
|
||||
+ f"{payload['average']}/"
|
||||
+ ("день" if ru else "day"),
|
||||
("⏱ Фокус: " if ru else "⏱ Focus: ") + humanize_minutes(payload["focus_minutes"]),
|
||||
("🎯 Дней с выполненной целью: " if ru else "🎯 Days the goal was met: ")
|
||||
+ f"{payload['goal_days']}/7",
|
||||
]
|
||||
if payload["best_day"] and payload["best_day"]["pomodoros"]:
|
||||
best = payload["best_day"]
|
||||
lines.append(
|
||||
("🏆 Лучший день: " if ru else "🏆 Best day: ")
|
||||
+ f"{best['date']} ({best['pomodoros']} 🍅)"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append(f"<b>{'По дням' if ru else 'Day by day'}</b>")
|
||||
for day in payload["days"]:
|
||||
bar = progress_bar(day["pomodoros"], settings.pomodoro.daily_goal_pomodoros, width=8)
|
||||
lines.append(f"<code>{day['date'][5:]} {bar}</code> {day['pomodoros']}")
|
||||
streak = payload["streak"]
|
||||
if streak.get("current"):
|
||||
lines.append("")
|
||||
lines.append(
|
||||
f"🔥 {'Серия' if ru else 'Streak'}: {_days(streak['current'], ru)}"
|
||||
+ f" · {'рекорд' if ru else 'best'}: {streak['best']}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,318 @@
|
||||
"""Background jobs: the part that makes this an instrument instead of a timer.
|
||||
|
||||
Five jobs, all re-planned whenever the settings change:
|
||||
|
||||
* refresh every todo source on an interval;
|
||||
* close intervals a crashed client left running;
|
||||
* the morning briefing, the daily report and the weekly review, at your times;
|
||||
* the nudge — "you have not started anything in 90 minutes" — which is the one
|
||||
that actually changes behaviour.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import env_settings
|
||||
from app.db import session_scope
|
||||
from app.models import PomodoroSession, utcnow
|
||||
from app.services import notify, pomodoro, report, settings_service, stats, tasks_service
|
||||
from app.settings_schema import AppSettings
|
||||
from app.timeutil import ensure_utc, in_window, local_date, local_time, to_local
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_scheduler: BackgroundScheduler | None = None
|
||||
|
||||
JOB_REFRESH = "refresh_tasks"
|
||||
JOB_REAP = "reap_sessions"
|
||||
JOB_MORNING = "morning_briefing"
|
||||
JOB_DAILY = "daily_report"
|
||||
JOB_WEEKLY = "weekly_report"
|
||||
JOB_NUDGE = "nudge"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Jobs
|
||||
# --------------------------------------------------------------------------
|
||||
def job_refresh_tasks() -> None:
|
||||
with session_scope() as session:
|
||||
settings = settings_service.load_settings(session)
|
||||
try:
|
||||
result = tasks_service.refresh_tasks(session, settings)
|
||||
failed = [item for item in result["sources"] if not item["ok"]]
|
||||
if failed:
|
||||
log.warning("task refresh: %s", "; ".join(f"{i['label']}: {i['error']}" for i in failed))
|
||||
except Exception: # pragma: no cover
|
||||
log.exception("task refresh failed")
|
||||
|
||||
|
||||
def job_reap_sessions() -> None:
|
||||
with session_scope() as session:
|
||||
settings = settings_service.load_settings(session)
|
||||
closed = pomodoro.reap_stale(session, settings)
|
||||
if closed:
|
||||
log.info("auto-closed %s abandoned interval(s)", closed)
|
||||
|
||||
|
||||
def job_morning_briefing() -> None:
|
||||
with session_scope() as session:
|
||||
settings = settings_service.load_settings(session)
|
||||
telegram = settings.notifications.telegram
|
||||
if not (telegram.enabled and telegram.morning_briefing):
|
||||
return
|
||||
today = local_date(settings.timezone)
|
||||
if notify.already_sent(session, JOB_MORNING, today):
|
||||
return
|
||||
text = report.render_morning_briefing(session, settings)
|
||||
deliveries = notify.notify(settings, text, urgent=True)
|
||||
if any(delivery.ok for delivery in deliveries):
|
||||
notify.record_sent(session, JOB_MORNING, today)
|
||||
|
||||
|
||||
def job_daily_report() -> None:
|
||||
with session_scope() as session:
|
||||
settings = settings_service.load_settings(session)
|
||||
telegram = settings.notifications.telegram
|
||||
if not (telegram.enabled and telegram.daily_report):
|
||||
return
|
||||
today = local_date(settings.timezone)
|
||||
report.send_daily(session, settings, today)
|
||||
|
||||
|
||||
def job_weekly_report() -> None:
|
||||
with session_scope() as session:
|
||||
settings = settings_service.load_settings(session)
|
||||
telegram = settings.notifications.telegram
|
||||
if not (telegram.enabled and telegram.weekly_report):
|
||||
return
|
||||
today = local_date(settings.timezone)
|
||||
if today.weekday() != telegram.weekly_report_weekday:
|
||||
return
|
||||
if notify.already_sent(session, JOB_WEEKLY, today):
|
||||
return
|
||||
payload = report.build_weekly(session, settings, today)
|
||||
text = report.render_weekly_telegram(payload, settings)
|
||||
deliveries = notify.notify(settings, text, urgent=True)
|
||||
if any(delivery.ok for delivery in deliveries):
|
||||
notify.record_sent(session, JOB_WEEKLY, today)
|
||||
|
||||
|
||||
def job_nudge() -> None:
|
||||
"""Poke the user when the day is passing without any work on it."""
|
||||
with session_scope() as session:
|
||||
settings = settings_service.load_settings(session)
|
||||
telegram = settings.notifications.telegram
|
||||
if not (telegram.enabled and telegram.nudge_enabled):
|
||||
return
|
||||
now_local = local_time(settings.timezone)
|
||||
if not in_window(now_local, telegram.nudge_window_start, telegram.nudge_window_end):
|
||||
return
|
||||
if notify.in_quiet_hours(settings):
|
||||
return
|
||||
|
||||
today = local_date(settings.timezone)
|
||||
if notify.count_sent(session, JOB_NUDGE, today) >= telegram.nudge_max_per_day:
|
||||
return
|
||||
if pomodoro.current_session(session) is not None:
|
||||
return # already working — the last thing they need is a message
|
||||
|
||||
idle_for = _idle_minutes(session, settings)
|
||||
if idle_for is None or idle_for < telegram.nudge_after_idle_minutes:
|
||||
return
|
||||
|
||||
text = _nudge_text(session, settings, idle_for)
|
||||
deliveries = notify.notify(settings, text)
|
||||
if any(delivery.ok for delivery in deliveries):
|
||||
notify.record_sent(session, JOB_NUDGE, today, {"idle_minutes": idle_for})
|
||||
|
||||
|
||||
def _idle_minutes(session, settings: AppSettings) -> int | None:
|
||||
"""Minutes since the last work interval ended, or since the nudge window opened."""
|
||||
today = local_date(settings.timezone)
|
||||
last = session.scalar(
|
||||
select(PomodoroSession)
|
||||
.where(
|
||||
PomodoroSession.kind == "work",
|
||||
PomodoroSession.local_date == today,
|
||||
PomodoroSession.status.in_(("completed", "abandoned")),
|
||||
)
|
||||
.order_by(PomodoroSession.started_at.desc())
|
||||
)
|
||||
now = utcnow()
|
||||
if last is not None and last.ended_at is not None:
|
||||
return int((now - ensure_utc(last.ended_at)).total_seconds() // 60)
|
||||
|
||||
window_start = settings.notifications.telegram.nudge_window_start
|
||||
local_now = to_local(now, settings.timezone)
|
||||
opened = local_now.replace(
|
||||
hour=window_start.hour, minute=window_start.minute, second=0, microsecond=0
|
||||
)
|
||||
if local_now < opened:
|
||||
return None
|
||||
return int((local_now - opened).total_seconds() // 60)
|
||||
|
||||
|
||||
def _nudge_text(session, settings: AppSettings, idle_minutes: int) -> str:
|
||||
ru = settings.notifications.report.language == "ru"
|
||||
tone = settings.notifications.report.tone
|
||||
today = local_date(settings.timezone)
|
||||
done = pomodoro.completed_work_today(session, settings)
|
||||
goal = settings.pomodoro.daily_goal_pomodoros
|
||||
streak = stats.streak(session, settings, until=today)
|
||||
|
||||
tasks = tasks_service.list_tasks(session)
|
||||
buckets = tasks_service.bucket_tasks(tasks, today)
|
||||
candidate = (buckets["overdue"] or buckets["today"] or buckets["upcoming"] or tasks)
|
||||
suggestion = candidate[0] if candidate else None
|
||||
|
||||
openers = {
|
||||
"friendly": (
|
||||
f"👋 Прошло {idle_minutes} минут без работы. Запустим одну помидорку?",
|
||||
f"👋 {idle_minutes} minutes without a pomodoro. Shall we start one?",
|
||||
),
|
||||
"coach": (
|
||||
f"⏳ {idle_minutes} минут простоя. {done}/{goal} сделано — ещё один блок, и догоняешь план.",
|
||||
f"⏳ {idle_minutes} idle minutes. You are at {done}/{goal} — one block puts you back on plan.",
|
||||
),
|
||||
"drill_sergeant": (
|
||||
f"🫡 {idle_minutes} минут безделья. {done}/{goal}. Таймер. Сейчас.",
|
||||
f"🫡 {idle_minutes} minutes of nothing. {done}/{goal}. Timer. Now.",
|
||||
),
|
||||
"dry": (
|
||||
f"Простой: {idle_minutes} мин. Выполнено {done} из {goal}.",
|
||||
f"Idle for {idle_minutes} min. {done} of {goal} pomodoros done.",
|
||||
),
|
||||
}
|
||||
opener_ru, opener_en = openers.get(tone, openers["friendly"])
|
||||
lines = [opener_ru if ru else opener_en]
|
||||
|
||||
if suggestion is not None:
|
||||
label = suggestion.title
|
||||
lines.append("")
|
||||
lines.append(
|
||||
(f"Предлагаю: <b>{report._esc(label)}</b>")
|
||||
if ru
|
||||
else f"Suggested next: <b>{report._esc(label)}</b>"
|
||||
)
|
||||
if streak.get("current"):
|
||||
lines.append(
|
||||
(f"🔥 Серия {streak['current']} дн. на кону.")
|
||||
if ru
|
||||
else f"🔥 A {streak['current']}-day streak is on the line."
|
||||
)
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"<i>/start_work — запустить таймер</i>" if ru else "<i>/start_work to begin</i>"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Wiring
|
||||
# --------------------------------------------------------------------------
|
||||
def _plan(scheduler: BackgroundScheduler, settings: AppSettings) -> None:
|
||||
tz_name = settings.timezone
|
||||
telegram = settings.notifications.telegram
|
||||
|
||||
scheduler.add_job(
|
||||
job_refresh_tasks,
|
||||
IntervalTrigger(minutes=settings.sources.refresh_interval_minutes),
|
||||
id=JOB_REFRESH,
|
||||
replace_existing=True,
|
||||
next_run_time=utcnow() + timedelta(seconds=20),
|
||||
max_instances=1,
|
||||
coalesce=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
job_reap_sessions,
|
||||
IntervalTrigger(minutes=15),
|
||||
id=JOB_REAP,
|
||||
replace_existing=True,
|
||||
max_instances=1,
|
||||
coalesce=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
job_morning_briefing,
|
||||
CronTrigger(
|
||||
hour=telegram.morning_briefing_time.hour,
|
||||
minute=telegram.morning_briefing_time.minute,
|
||||
timezone=tz_name,
|
||||
),
|
||||
id=JOB_MORNING,
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
job_daily_report,
|
||||
CronTrigger(
|
||||
hour=telegram.daily_report_time.hour,
|
||||
minute=telegram.daily_report_time.minute,
|
||||
timezone=tz_name,
|
||||
),
|
||||
id=JOB_DAILY,
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
job_weekly_report,
|
||||
CronTrigger(
|
||||
day_of_week=telegram.weekly_report_weekday,
|
||||
hour=telegram.daily_report_time.hour,
|
||||
minute=min(telegram.daily_report_time.minute + 5, 59),
|
||||
timezone=tz_name,
|
||||
),
|
||||
id=JOB_WEEKLY,
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
job_nudge,
|
||||
IntervalTrigger(minutes=10),
|
||||
id=JOB_NUDGE,
|
||||
replace_existing=True,
|
||||
max_instances=1,
|
||||
coalesce=True,
|
||||
)
|
||||
|
||||
|
||||
def start(settings: AppSettings) -> BackgroundScheduler | None:
|
||||
"""Start the scheduler and re-plan it on every settings change."""
|
||||
global _scheduler
|
||||
if not env_settings().scheduler_enabled:
|
||||
log.info("scheduler disabled by configuration")
|
||||
return None
|
||||
if _scheduler is not None:
|
||||
return _scheduler
|
||||
|
||||
scheduler = BackgroundScheduler(timezone=settings.timezone)
|
||||
_plan(scheduler, settings)
|
||||
scheduler.start()
|
||||
_scheduler = scheduler
|
||||
|
||||
settings_service.on_change(lambda new_settings: _plan(scheduler, new_settings))
|
||||
log.info("scheduler started with %s jobs", len(scheduler.get_jobs()))
|
||||
return scheduler
|
||||
|
||||
|
||||
def shutdown() -> None:
|
||||
global _scheduler
|
||||
if _scheduler is not None:
|
||||
_scheduler.shutdown(wait=False)
|
||||
_scheduler = None
|
||||
|
||||
|
||||
def describe() -> list[dict[str, str]]:
|
||||
if _scheduler is None:
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"id": job.id,
|
||||
"next_run": job.next_run_time.isoformat() if job.next_run_time else "",
|
||||
"trigger": str(job.trigger),
|
||||
}
|
||||
for job in _scheduler.get_jobs()
|
||||
]
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Reading and writing the settings document.
|
||||
|
||||
Three jobs:
|
||||
|
||||
* seed the document from environment variables the first time the app boots, so
|
||||
a filled-in `.env` (the same variables jira-helper uses) is a working setup;
|
||||
* hand the API a copy with secrets masked, and merge an edited copy back without
|
||||
the UI ever having to know a real token;
|
||||
* notify the rest of the process when something changed, so the scheduler can
|
||||
re-plan its jobs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Any, Callable
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import env_settings
|
||||
from app.models import SettingsRow
|
||||
from app.settings_schema import SECRET_FIELDS, SECRET_MASK, AppSettings
|
||||
|
||||
_SETTINGS_ID = 1
|
||||
_listeners: list[Callable[[AppSettings], None]] = []
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def on_change(listener: Callable[[AppSettings], None]) -> None:
|
||||
"""Register a callback fired after every successful settings save."""
|
||||
_listeners.append(listener)
|
||||
|
||||
|
||||
def _csv(value: str) -> list[str]:
|
||||
return [part.strip() for part in value.split(",") if part.strip()]
|
||||
|
||||
|
||||
def settings_from_env() -> AppSettings:
|
||||
"""Build the initial document out of the environment.
|
||||
|
||||
The variable names are deliberately the ones from `~/code/jira-helper`, so
|
||||
an existing `.env` can be copied across untouched.
|
||||
"""
|
||||
env = env_settings()
|
||||
settings = AppSettings(timezone=env.timezone)
|
||||
|
||||
jira = settings.sources.jira
|
||||
if env.jira_base_url and env.jira_email and env.jira_api_token:
|
||||
jira.enabled = True
|
||||
jira.base_url = env.jira_base_url.rstrip("/")
|
||||
jira.email = env.jira_email
|
||||
jira.api_token = env.jira_api_token
|
||||
jira.extra_jql = env.jira_extra_jql
|
||||
jira.start_date_field = env.jira_start_date_field or jira.start_date_field
|
||||
|
||||
google = settings.sources.google_tasks
|
||||
if env.google_client_id and env.google_client_secret and env.google_refresh_token:
|
||||
google.enabled = True
|
||||
google.client_id = env.google_client_id
|
||||
google.client_secret = env.google_client_secret
|
||||
google.refresh_token = env.google_refresh_token
|
||||
google.lists = _csv(env.google_tasks_lists)
|
||||
|
||||
todoist = settings.sources.todoist
|
||||
if env.todoist_api_token:
|
||||
todoist.enabled = True
|
||||
todoist.api_token = env.todoist_api_token
|
||||
todoist.projects = _csv(env.todoist_projects)
|
||||
|
||||
laundry = settings.sources.laundry
|
||||
if env.laundry_session:
|
||||
laundry.enabled = True
|
||||
laundry.session_cookie = env.laundry_session
|
||||
laundry.base_url = env.laundry_base_url.rstrip("/")
|
||||
|
||||
telegram = settings.notifications.telegram
|
||||
if env.telegram_bot_token and env.telegram_chat_id:
|
||||
telegram.enabled = True
|
||||
telegram.bot_token = env.telegram_bot_token
|
||||
telegram.chat_id = env.telegram_chat_id
|
||||
|
||||
if env.slack_webhook_url:
|
||||
settings.notifications.slack.enabled = True
|
||||
settings.notifications.slack.webhook_url = env.slack_webhook_url
|
||||
|
||||
return settings
|
||||
|
||||
|
||||
def load_settings(session: Session) -> AppSettings:
|
||||
"""The current settings, creating the row from the environment if absent."""
|
||||
row = session.get(SettingsRow, _SETTINGS_ID)
|
||||
if row is None:
|
||||
settings = settings_from_env()
|
||||
row = SettingsRow(id=_SETTINGS_ID, data=settings.model_dump(mode="json"))
|
||||
session.add(row)
|
||||
session.commit()
|
||||
return settings
|
||||
try:
|
||||
return AppSettings.model_validate(row.data)
|
||||
except Exception:
|
||||
# A settings document written by an older build can be missing keys or
|
||||
# carry ones that no longer exist; defaults fill the gaps rather than
|
||||
# taking the whole app down.
|
||||
return AppSettings.model_validate(_merge(AppSettings().model_dump(mode="json"), row.data))
|
||||
|
||||
|
||||
def save_settings(session: Session, settings: AppSettings) -> AppSettings:
|
||||
row = session.get(SettingsRow, _SETTINGS_ID)
|
||||
data = settings.model_dump(mode="json")
|
||||
if row is None:
|
||||
row = SettingsRow(id=_SETTINGS_ID, data=data)
|
||||
session.add(row)
|
||||
else:
|
||||
row.data = data
|
||||
session.commit()
|
||||
|
||||
with _lock:
|
||||
listeners = list(_listeners)
|
||||
for listener in listeners:
|
||||
try:
|
||||
listener(settings)
|
||||
except Exception: # pragma: no cover - a bad listener must not block saving
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).exception("settings listener failed")
|
||||
return settings
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Secret masking
|
||||
# --------------------------------------------------------------------------
|
||||
def _walk(node: Any, fn: Callable[[str, Any], Any]) -> Any:
|
||||
if isinstance(node, dict):
|
||||
return {key: fn(key, _walk(value, fn)) for key, value in node.items()}
|
||||
if isinstance(node, list):
|
||||
return [_walk(item, fn) for item in node]
|
||||
return node
|
||||
|
||||
|
||||
def mask_secrets(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Replace every configured secret that has a value with the mask."""
|
||||
|
||||
def mask(key: str, value: Any) -> Any:
|
||||
if key in SECRET_FIELDS and isinstance(value, str) and value:
|
||||
return SECRET_MASK
|
||||
return value
|
||||
|
||||
return _walk(data, mask)
|
||||
|
||||
|
||||
def _merge(base: Any, incoming: Any) -> Any:
|
||||
"""Deep-merge `incoming` onto `base`, keeping `base` where a key is missing."""
|
||||
if isinstance(base, dict) and isinstance(incoming, dict):
|
||||
merged = dict(base)
|
||||
for key, value in incoming.items():
|
||||
merged[key] = _merge(base.get(key), value) if key in base else value
|
||||
return merged
|
||||
return incoming
|
||||
|
||||
|
||||
def unmask_secrets(stored: dict[str, Any], incoming: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Put the stored secret back wherever the client sent the mask untouched."""
|
||||
|
||||
def restore(node_stored: Any, node_in: Any) -> Any:
|
||||
if isinstance(node_in, dict):
|
||||
out: dict[str, Any] = {}
|
||||
for key, value in node_in.items():
|
||||
old = node_stored.get(key) if isinstance(node_stored, dict) else None
|
||||
if key in SECRET_FIELDS and value == SECRET_MASK:
|
||||
out[key] = old or ""
|
||||
else:
|
||||
out[key] = restore(old, value)
|
||||
return out
|
||||
return node_in
|
||||
|
||||
return restore(stored, incoming)
|
||||
|
||||
|
||||
def apply_patch(session: Session, patch: dict[str, Any]) -> AppSettings:
|
||||
"""Merge a partial settings document (as the UI sends it) and store it."""
|
||||
current = load_settings(session).model_dump(mode="json")
|
||||
patch = unmask_secrets(current, patch)
|
||||
merged = _merge(current, patch)
|
||||
return save_settings(session, AppSettings.model_validate(merged))
|
||||
@@ -0,0 +1,284 @@
|
||||
"""Turning rows into numbers: a day, a range, streaks, and a heatmap.
|
||||
|
||||
Everything the report and the dashboard show comes from here, so the web UI and
|
||||
the Telegram message can never disagree about how many pomodoros you did.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import BlockEvent, PomodoroSession, TaskRecord
|
||||
from app.settings_schema import AppSettings
|
||||
from app.timeutil import local_date, to_local
|
||||
|
||||
# Two work intervals closer together than this count as one unbroken stretch.
|
||||
_STREAK_GAP_SECONDS = 20 * 60
|
||||
|
||||
|
||||
def _focus_blocks(rows: list[PomodoroSession], tz_name: str) -> list[tuple[datetime, datetime]]:
|
||||
blocks: list[tuple[datetime, datetime]] = []
|
||||
for row in rows:
|
||||
if row.kind != "work" or row.status != "completed" or row.ended_at is None:
|
||||
continue
|
||||
blocks.append((to_local(row.started_at, tz_name), to_local(row.ended_at, tz_name)))
|
||||
return sorted(blocks)
|
||||
|
||||
|
||||
def longest_focus_stretch_minutes(rows: list[PomodoroSession], tz_name: str) -> int:
|
||||
"""The longest run of back-to-back pomodoros, in minutes of actual focus."""
|
||||
blocks = _focus_blocks(rows, tz_name)
|
||||
if not blocks:
|
||||
return 0
|
||||
best = current = (blocks[0][1] - blocks[0][0]).total_seconds()
|
||||
previous_end = blocks[0][1]
|
||||
for start, end in blocks[1:]:
|
||||
gap = (start - previous_end).total_seconds()
|
||||
length = (end - start).total_seconds()
|
||||
current = current + length if gap <= _STREAK_GAP_SECONDS else length
|
||||
best = max(best, current)
|
||||
previous_end = end
|
||||
return int(best // 60)
|
||||
|
||||
|
||||
def day_metrics(session: Session, settings: AppSettings, day: date) -> dict[str, Any]:
|
||||
"""Everything measurable about one day."""
|
||||
tz_name = settings.timezone
|
||||
rows = list(
|
||||
session.scalars(
|
||||
select(PomodoroSession)
|
||||
.where(PomodoroSession.local_date == day)
|
||||
.order_by(PomodoroSession.started_at.asc())
|
||||
)
|
||||
)
|
||||
|
||||
work_done = [r for r in rows if r.kind == "work" and r.status == "completed"]
|
||||
work_dropped = [r for r in rows if r.kind == "work" and r.status == "abandoned"]
|
||||
breaks_done = [r for r in rows if r.kind != "work" and r.status == "completed"]
|
||||
|
||||
focus_seconds = sum(r.actual_seconds for r in work_done)
|
||||
break_seconds = sum(r.actual_seconds for r in breaks_done)
|
||||
dropped_seconds = sum(r.actual_seconds for r in work_dropped)
|
||||
|
||||
by_hour = Counter(to_local(r.started_at, tz_name).hour for r in work_done)
|
||||
tasks = list(
|
||||
session.scalars(
|
||||
select(TaskRecord).where(
|
||||
TaskRecord.completed_date == day, TaskRecord.completed_here.is_(True)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
blocks = list(
|
||||
session.scalars(select(BlockEvent).where(BlockEvent.local_date == day))
|
||||
)
|
||||
block_counter = Counter(event.target for event in blocks)
|
||||
|
||||
pomodoro_time_by_task: Counter[str] = Counter()
|
||||
for row in work_done:
|
||||
label = row.task_title or "Unassigned"
|
||||
pomodoro_time_by_task[label] += row.actual_seconds
|
||||
|
||||
first = to_local(work_done[0].started_at, tz_name) if work_done else None
|
||||
last_row = work_done[-1] if work_done else None
|
||||
last = to_local(last_row.ended_at or last_row.started_at, tz_name) if last_row else None
|
||||
|
||||
goal = settings.pomodoro.daily_goal_pomodoros
|
||||
completed = len(work_done)
|
||||
planned_seconds = sum(r.planned_seconds for r in work_done)
|
||||
|
||||
return {
|
||||
"date": day.isoformat(),
|
||||
"pomodoros": completed,
|
||||
"goal": goal,
|
||||
"goal_pct": round(100 * completed / goal) if goal else 0,
|
||||
"goal_met": completed >= goal,
|
||||
"focus_minutes": round(focus_seconds / 60),
|
||||
"break_minutes": round(break_seconds / 60),
|
||||
"wasted_minutes": round(dropped_seconds / 60),
|
||||
"abandoned": len(work_dropped),
|
||||
"abandon_reasons": [r.abandon_reason for r in work_dropped if r.abandon_reason],
|
||||
"breaks_taken": len(breaks_done),
|
||||
"breaks_owed": completed,
|
||||
"interruptions": sum(r.interruptions for r in rows),
|
||||
"completion_rate": (
|
||||
round(100 * focus_seconds / planned_seconds) if planned_seconds else 0
|
||||
),
|
||||
"longest_stretch_minutes": longest_focus_stretch_minutes(rows, tz_name),
|
||||
"first_start": first.strftime("%H:%M") if first else None,
|
||||
"last_end": last.strftime("%H:%M") if last else None,
|
||||
"by_hour": [{"hour": hour, "count": by_hour.get(hour, 0)} for hour in range(24)],
|
||||
"peak_hour": (by_hour.most_common(1)[0][0] if by_hour else None),
|
||||
"tasks_completed": [
|
||||
{
|
||||
"id": task.id,
|
||||
"title": task.title,
|
||||
"source": task.source,
|
||||
"project": task.project,
|
||||
"url": task.url,
|
||||
}
|
||||
for task in tasks
|
||||
],
|
||||
"tasks_completed_count": len(tasks),
|
||||
"time_by_task": [
|
||||
{"title": title, "minutes": round(seconds / 60)}
|
||||
for title, seconds in pomodoro_time_by_task.most_common(10)
|
||||
],
|
||||
"blocked_attempts": sum(block_counter.values()),
|
||||
"blocked_top": [
|
||||
{"target": target, "count": count} for target, count in block_counter.most_common(8)
|
||||
],
|
||||
"sessions": [
|
||||
{
|
||||
"id": row.id,
|
||||
"kind": row.kind,
|
||||
"status": row.status,
|
||||
"started_at": to_local(row.started_at, tz_name).isoformat(),
|
||||
"minutes": round(row.actual_seconds / 60),
|
||||
"task": row.task_title,
|
||||
"interruptions": row.interruptions,
|
||||
}
|
||||
for row in rows
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def range_metrics(
|
||||
session: Session, settings: AppSettings, start: date, end: date
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Per-day metrics for `[start, end]`, inclusive, cheaply (one query)."""
|
||||
rows = list(
|
||||
session.scalars(
|
||||
select(PomodoroSession).where(
|
||||
PomodoroSession.local_date >= start,
|
||||
PomodoroSession.local_date <= end,
|
||||
PomodoroSession.kind == "work",
|
||||
PomodoroSession.status == "completed",
|
||||
)
|
||||
)
|
||||
)
|
||||
per_day: dict[date, list[PomodoroSession]] = {}
|
||||
for row in rows:
|
||||
per_day.setdefault(row.local_date, []).append(row)
|
||||
|
||||
out: list[dict[str, Any]] = []
|
||||
cursor = start
|
||||
while cursor <= end:
|
||||
day_rows = per_day.get(cursor, [])
|
||||
out.append(
|
||||
{
|
||||
"date": cursor.isoformat(),
|
||||
"pomodoros": len(day_rows),
|
||||
"focus_minutes": round(sum(r.actual_seconds for r in day_rows) / 60),
|
||||
"goal_met": len(day_rows) >= settings.pomodoro.daily_goal_pomodoros,
|
||||
}
|
||||
)
|
||||
cursor += timedelta(days=1)
|
||||
return out
|
||||
|
||||
|
||||
def averages(session: Session, settings: AppSettings, days: int, *, until: date) -> dict[str, float]:
|
||||
"""Mean pomodoros/focus over the `days` days ending the day before `until`."""
|
||||
start = until - timedelta(days=days)
|
||||
end = until - timedelta(days=1)
|
||||
if end < start:
|
||||
return {"pomodoros": 0.0, "focus_minutes": 0.0}
|
||||
rows = range_metrics(session, settings, start, end)
|
||||
if not rows:
|
||||
return {"pomodoros": 0.0, "focus_minutes": 0.0}
|
||||
return {
|
||||
"pomodoros": round(sum(r["pomodoros"] for r in rows) / len(rows), 1),
|
||||
"focus_minutes": round(sum(r["focus_minutes"] for r in rows) / len(rows), 1),
|
||||
}
|
||||
|
||||
|
||||
def streak(session: Session, settings: AppSettings, *, until: date | None = None) -> dict[str, int]:
|
||||
"""Consecutive days ending at `until` with at least one pomodoro, and with the goal met.
|
||||
|
||||
Today only breaks a streak once it is over, so a day with nothing on it yet
|
||||
keeps yesterday's streak alive.
|
||||
"""
|
||||
until = until or local_date(settings.timezone)
|
||||
days = list(
|
||||
session.scalars(
|
||||
select(PomodoroSession.local_date)
|
||||
.where(
|
||||
PomodoroSession.kind == "work",
|
||||
PomodoroSession.status == "completed",
|
||||
PomodoroSession.local_date <= until,
|
||||
)
|
||||
.group_by(PomodoroSession.local_date)
|
||||
)
|
||||
)
|
||||
counts = dict(
|
||||
session.execute(
|
||||
select(PomodoroSession.local_date, func.count(PomodoroSession.id))
|
||||
.where(
|
||||
PomodoroSession.kind == "work",
|
||||
PomodoroSession.status == "completed",
|
||||
PomodoroSession.local_date <= until,
|
||||
)
|
||||
.group_by(PomodoroSession.local_date)
|
||||
).all()
|
||||
)
|
||||
active = set(days)
|
||||
|
||||
def run(from_day: date, predicate) -> int:
|
||||
length, cursor = 0, from_day
|
||||
while predicate(cursor):
|
||||
length += 1
|
||||
cursor -= timedelta(days=1)
|
||||
return length
|
||||
|
||||
goal = settings.pomodoro.daily_goal_pomodoros
|
||||
anchor = until if until in active else until - timedelta(days=1)
|
||||
goal_anchor = (
|
||||
until if counts.get(until, 0) >= goal else until - timedelta(days=1)
|
||||
)
|
||||
return {
|
||||
"current": run(anchor, lambda day: day in active),
|
||||
"goal_streak": run(goal_anchor, lambda day: counts.get(day, 0) >= goal),
|
||||
"best": _best_streak(sorted(active)),
|
||||
"total_days": len(active),
|
||||
}
|
||||
|
||||
|
||||
def _best_streak(days: list[date]) -> int:
|
||||
best = run = 0
|
||||
previous: date | None = None
|
||||
for day in days:
|
||||
run = run + 1 if previous is not None and (day - previous).days == 1 else 1
|
||||
best = max(best, run)
|
||||
previous = day
|
||||
return best
|
||||
|
||||
|
||||
def heatmap(session: Session, settings: AppSettings, weeks: int = 12) -> list[dict[str, Any]]:
|
||||
"""Per-day counts for the last `weeks` weeks, for the dashboard's calendar."""
|
||||
end = local_date(settings.timezone)
|
||||
start = end - timedelta(weeks=weeks)
|
||||
return range_metrics(session, settings, start, end)
|
||||
|
||||
|
||||
def lifetime(session: Session, settings: AppSettings) -> dict[str, Any]:
|
||||
total, seconds = session.execute(
|
||||
select(func.count(PomodoroSession.id), func.coalesce(func.sum(PomodoroSession.actual_seconds), 0)).where(
|
||||
PomodoroSession.kind == "work", PomodoroSession.status == "completed"
|
||||
)
|
||||
).one()
|
||||
tasks = session.scalar(
|
||||
select(func.count(TaskRecord.id)).where(TaskRecord.completed_here.is_(True))
|
||||
)
|
||||
blocked = session.scalar(select(func.count(BlockEvent.id)))
|
||||
return {
|
||||
"pomodoros": int(total or 0),
|
||||
"focus_minutes": round(int(seconds or 0) / 60),
|
||||
"focus_hours": round(int(seconds or 0) / 3600, 1),
|
||||
"tasks_completed": int(tasks or 0),
|
||||
"blocked_attempts": int(blocked or 0),
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
"""The task list: refreshing it from every source, and acting on it.
|
||||
|
||||
External tasks are mirrored into the `tasks` table on each refresh. The mirror
|
||||
buys three things a live call cannot: the UI stays instant, the daily report
|
||||
still works when a source is down, and "I finished this" is recorded locally
|
||||
even if the write-back to Jira fails.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import date
|
||||
from typing import Any, Iterable
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import TaskRecord, utcnow
|
||||
from app.settings_schema import AppSettings
|
||||
from app.sources.base import SourceResult, TodoItem
|
||||
from app.sources.registry import SOURCE_LABELS, complete_remote, fetch_all
|
||||
from app.timeutil import local_date
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
LOCAL_SOURCE = "local"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Refresh
|
||||
# --------------------------------------------------------------------------
|
||||
def _upsert(session: Session, item: TodoItem) -> TaskRecord:
|
||||
record = session.scalar(
|
||||
select(TaskRecord).where(
|
||||
TaskRecord.source == item.source,
|
||||
TaskRecord.external_id == item.external_id,
|
||||
)
|
||||
)
|
||||
if record is None:
|
||||
record = TaskRecord(source=item.source, external_id=item.external_id)
|
||||
session.add(record)
|
||||
|
||||
record.title = item.title
|
||||
record.description = item.description
|
||||
record.url = item.url
|
||||
record.project = item.project
|
||||
record.status = item.status
|
||||
record.priority = item.priority
|
||||
record.due_date = item.due_date
|
||||
record.start_date = item.start_date
|
||||
record.labels = item.labels
|
||||
record.raw = item.raw
|
||||
record.last_seen_at = utcnow()
|
||||
record.stale = False
|
||||
if item.is_done and not record.is_done:
|
||||
# Closed in the source, not here: it counts as done but not as "mine".
|
||||
record.is_done = True
|
||||
record.completed_at = record.completed_at or utcnow()
|
||||
return record
|
||||
|
||||
|
||||
def refresh_tasks(session: Session, settings: AppSettings) -> dict[str, Any]:
|
||||
"""Pull every enabled source and reconcile the mirror.
|
||||
|
||||
Tasks a source stops returning are flagged `stale` rather than deleted: the
|
||||
history behind "you finished 6 things today" has to survive a source that
|
||||
quietly drops an item.
|
||||
"""
|
||||
results: list[SourceResult] = fetch_all(settings)
|
||||
today = local_date(settings.timezone)
|
||||
summary: list[dict[str, Any]] = []
|
||||
|
||||
for result in results:
|
||||
if not result.ok:
|
||||
summary.append(
|
||||
{
|
||||
"source": result.source,
|
||||
"label": SOURCE_LABELS.get(result.source, result.source),
|
||||
"ok": False,
|
||||
"error": result.error,
|
||||
"count": 0,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
seen_ids: set[str] = set()
|
||||
for item in result.items:
|
||||
_upsert(session, item)
|
||||
seen_ids.add(item.external_id)
|
||||
|
||||
existing = session.scalars(
|
||||
select(TaskRecord).where(
|
||||
TaskRecord.source == result.source, TaskRecord.is_done.is_(False)
|
||||
)
|
||||
).all()
|
||||
for record in existing:
|
||||
if record.external_id not in seen_ids:
|
||||
record.stale = True
|
||||
|
||||
summary.append(
|
||||
{
|
||||
"source": result.source,
|
||||
"label": SOURCE_LABELS.get(result.source, result.source),
|
||||
"ok": True,
|
||||
"error": None,
|
||||
"count": len(result.items),
|
||||
}
|
||||
)
|
||||
|
||||
session.commit()
|
||||
return {
|
||||
"refreshed_at": utcnow().isoformat(),
|
||||
"date": today.isoformat(),
|
||||
"sources": summary,
|
||||
"total": sum(entry["count"] for entry in summary),
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Queries
|
||||
# --------------------------------------------------------------------------
|
||||
def list_tasks(
|
||||
session: Session,
|
||||
*,
|
||||
include_done: bool = False,
|
||||
include_stale: bool = False,
|
||||
sources: Iterable[str] | None = None,
|
||||
) -> list[TaskRecord]:
|
||||
query = select(TaskRecord)
|
||||
if not include_done:
|
||||
query = query.where(TaskRecord.is_done.is_(False))
|
||||
if not include_stale:
|
||||
query = query.where(TaskRecord.stale.is_(False))
|
||||
if sources:
|
||||
query = query.where(TaskRecord.source.in_(list(sources)))
|
||||
records = list(session.scalars(query))
|
||||
records.sort(key=_sort_key)
|
||||
return records
|
||||
|
||||
|
||||
def _sort_key(record: TaskRecord) -> tuple:
|
||||
"""Pinned first, then by deadline (undated last), then by source and title."""
|
||||
return (
|
||||
not record.pinned,
|
||||
record.due_date is None,
|
||||
record.due_date or date.max,
|
||||
record.source,
|
||||
(record.title or "").lower(),
|
||||
)
|
||||
|
||||
|
||||
def bucket_tasks(records: Iterable[TaskRecord], today: date) -> dict[str, list[TaskRecord]]:
|
||||
"""Group a task list the way the UI and the report both want it."""
|
||||
buckets: dict[str, list[TaskRecord]] = {
|
||||
"overdue": [],
|
||||
"today": [],
|
||||
"upcoming": [],
|
||||
"someday": [],
|
||||
}
|
||||
for record in records:
|
||||
if record.due_date is None:
|
||||
buckets["someday"].append(record)
|
||||
elif record.due_date < today:
|
||||
buckets["overdue"].append(record)
|
||||
elif record.due_date == today:
|
||||
buckets["today"].append(record)
|
||||
else:
|
||||
buckets["upcoming"].append(record)
|
||||
return buckets
|
||||
|
||||
|
||||
def completed_on(session: Session, day: date, *, only_here: bool = True) -> list[TaskRecord]:
|
||||
query = select(TaskRecord).where(TaskRecord.completed_date == day)
|
||||
if only_here:
|
||||
query = query.where(TaskRecord.completed_here.is_(True))
|
||||
return list(session.scalars(query))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Mutations
|
||||
# --------------------------------------------------------------------------
|
||||
def create_local_task(
|
||||
session: Session,
|
||||
*,
|
||||
title: str,
|
||||
description: str | None = None,
|
||||
due_date: date | None = None,
|
||||
priority: str | None = None,
|
||||
project: str | None = None,
|
||||
estimated_pomodoros: int | None = None,
|
||||
) -> TaskRecord:
|
||||
"""A task that lives only here — the quick "and this too" you type at 9am."""
|
||||
# A random id, not a timestamp: two tasks added in the same millisecond
|
||||
# (a double-clicked Add button) would otherwise collide on the unique key.
|
||||
external_id = f"local-{uuid4().hex}"
|
||||
record = TaskRecord(
|
||||
source=LOCAL_SOURCE,
|
||||
external_id=external_id,
|
||||
title=title.strip(),
|
||||
description=description,
|
||||
due_date=due_date,
|
||||
priority=priority,
|
||||
project=project or "My list",
|
||||
estimated_pomodoros=estimated_pomodoros,
|
||||
)
|
||||
session.add(record)
|
||||
session.commit()
|
||||
return record
|
||||
|
||||
|
||||
def complete_task(
|
||||
session: Session, settings: AppSettings, task_id: int, *, done: bool = True
|
||||
) -> tuple[TaskRecord, str | None]:
|
||||
"""Mark a task done (or undone) here, and try to mirror it to its source.
|
||||
|
||||
Returns the record plus a warning when the write-back failed — the local
|
||||
state still changes, because the alternative is losing the fact that you
|
||||
finished something because Jira hiccupped.
|
||||
"""
|
||||
record = session.get(TaskRecord, task_id)
|
||||
if record is None:
|
||||
raise LookupError(f"no task {task_id}")
|
||||
|
||||
warning: str | None = None
|
||||
if done:
|
||||
record.is_done = True
|
||||
record.completed_here = True
|
||||
record.completed_at = utcnow()
|
||||
record.completed_date = local_date(settings.timezone)
|
||||
if record.source != LOCAL_SOURCE:
|
||||
item = TodoItem(
|
||||
source=record.source,
|
||||
external_id=record.external_id,
|
||||
title=record.title,
|
||||
raw=record.raw or {},
|
||||
)
|
||||
wrote, error = complete_remote(settings, item)
|
||||
if error:
|
||||
warning = (
|
||||
f"Marked done here, but {SOURCE_LABELS.get(record.source, record.source)}"
|
||||
f" did not accept it: {error}"
|
||||
)
|
||||
elif not wrote:
|
||||
warning = (
|
||||
f"Marked done here only — write-back is off for "
|
||||
f"{SOURCE_LABELS.get(record.source, record.source)}."
|
||||
)
|
||||
else:
|
||||
record.is_done = False
|
||||
record.completed_here = False
|
||||
record.completed_at = None
|
||||
record.completed_date = None
|
||||
|
||||
session.commit()
|
||||
return record, warning
|
||||
|
||||
|
||||
def update_task(session: Session, task_id: int, **changes: Any) -> TaskRecord:
|
||||
record = session.get(TaskRecord, task_id)
|
||||
if record is None:
|
||||
raise LookupError(f"no task {task_id}")
|
||||
for key, value in changes.items():
|
||||
if value is not None and hasattr(record, key):
|
||||
setattr(record, key, value)
|
||||
session.commit()
|
||||
return record
|
||||
|
||||
|
||||
def delete_task(session: Session, task_id: int) -> None:
|
||||
record = session.get(TaskRecord, task_id)
|
||||
if record is None:
|
||||
return
|
||||
session.delete(record)
|
||||
session.commit()
|
||||
@@ -0,0 +1,367 @@
|
||||
"""A long-polling Telegram bot, so the chat is a remote control, not just an inbox.
|
||||
|
||||
Runs in a daemon thread next to the API. Commands do exactly what the buttons in
|
||||
the UI do — they call the same services — so starting a pomodoro from your phone
|
||||
and finishing it on the desktop is one unbroken interval.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time as time_module
|
||||
from typing import Any, Callable
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import env_settings
|
||||
from app.db import session_scope
|
||||
from app.services import (
|
||||
blocker_service,
|
||||
pomodoro,
|
||||
report,
|
||||
settings_service,
|
||||
stats,
|
||||
tasks_service,
|
||||
)
|
||||
from app.settings_schema import AppSettings
|
||||
from app.timeutil import humanize_minutes, local_date
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_thread: threading.Thread | None = None
|
||||
_stop = threading.Event()
|
||||
_POLL_TIMEOUT = 25
|
||||
|
||||
HELP_EN = """<b>Kadence</b>
|
||||
|
||||
/start_work [minutes] — start a pomodoro
|
||||
/break — start a break
|
||||
/stop — abandon the current interval
|
||||
/pause · /resume — hold the clock
|
||||
/status — what is running right now
|
||||
/today — your todo list, grouped
|
||||
/done <number> — close task N from /today
|
||||
/report — today's report, now
|
||||
/week — the weekly review
|
||||
/streak — streak and all-time totals
|
||||
/block — what the blocker is doing
|
||||
/help — this message"""
|
||||
|
||||
HELP_RU = """<b>Kadence</b>
|
||||
|
||||
/start_work [минуты] — запустить помидорку
|
||||
/break — перерыв
|
||||
/stop — бросить текущий интервал
|
||||
/pause · /resume — пауза и продолжение
|
||||
/status — что идёт сейчас
|
||||
/today — список задач по группам
|
||||
/done <номер> — закрыть задачу N из /today
|
||||
/report — отчёт за сегодня
|
||||
/week — итоги недели
|
||||
/streak — серия и общая статистика
|
||||
/block — что делает блокировщик
|
||||
/help — это сообщение"""
|
||||
|
||||
|
||||
def _api(token: str, method: str, payload: dict[str, Any], timeout: int = 30) -> dict[str, Any]:
|
||||
url = f"https://api.telegram.org/bot{token}/{method}"
|
||||
with httpx.Client(timeout=timeout) as client:
|
||||
resp = client.post(url, json=payload)
|
||||
if not resp.is_success:
|
||||
raise RuntimeError(f"{method} -> {resp.status_code}: {resp.text[:200]}")
|
||||
return resp.json() or {}
|
||||
|
||||
|
||||
def _reply(settings: AppSettings, chat_id: str, text: str, buttons: list[list[str]] | None = None) -> None:
|
||||
telegram = settings.notifications.telegram
|
||||
payload: dict[str, Any] = {
|
||||
"chat_id": chat_id,
|
||||
"text": text,
|
||||
"parse_mode": "HTML",
|
||||
"disable_web_page_preview": True,
|
||||
}
|
||||
if buttons:
|
||||
payload["reply_markup"] = {
|
||||
"inline_keyboard": [
|
||||
[{"text": label, "callback_data": label} for label in row] for row in buttons
|
||||
]
|
||||
}
|
||||
try:
|
||||
_api(telegram.bot_token, "sendMessage", payload)
|
||||
except Exception as exc: # pragma: no cover
|
||||
log.warning("telegram reply failed: %s", exc)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Command handlers
|
||||
# --------------------------------------------------------------------------
|
||||
def _cmd_status(session, settings: AppSettings, _args: str) -> str:
|
||||
ru = settings.notifications.report.language == "ru"
|
||||
state = pomodoro.state(session, settings)
|
||||
current = state["current"]
|
||||
done, goal = state["completed_today"], state["goal"]
|
||||
header = (
|
||||
f"🍅 {done}/{goal} " + ("сегодня" if ru else "today")
|
||||
+ f" · {report.progress_bar(done, goal)}"
|
||||
)
|
||||
if current is None:
|
||||
return header + "\n\n" + ("Таймер стоит." if ru else "The timer is idle.")
|
||||
remaining = humanize_minutes(current["remaining_seconds"] / 60)
|
||||
kind = {
|
||||
"work": "работа" if ru else "work",
|
||||
"short_break": "короткий перерыв" if ru else "short break",
|
||||
"long_break": "длинный перерыв" if ru else "long break",
|
||||
}[current["kind"]]
|
||||
task = f"\n📌 {report._esc(current['task']['title'])}" if current.get("task") else ""
|
||||
status = ("на паузе" if ru else "paused") if current["status"] == "paused" else ("идёт" if ru else "running")
|
||||
return f"{header}\n\n▶️ <b>{kind}</b> ({status}) — {remaining} " + ("осталось" if ru else "left") + task
|
||||
|
||||
|
||||
def _cmd_start_work(session, settings: AppSettings, args: str) -> str:
|
||||
ru = settings.notifications.report.language == "ru"
|
||||
minutes: int | None = None
|
||||
if args.strip().isdigit():
|
||||
minutes = max(1, min(240, int(args.strip())))
|
||||
row = pomodoro.start(session, settings, kind="work", minutes=minutes)
|
||||
length = humanize_minutes(row.planned_seconds / 60)
|
||||
return (
|
||||
f"▶️ Помидорка на {length} пошла. Удачи!" if ru else f"▶️ {length} pomodoro started. Go."
|
||||
)
|
||||
|
||||
|
||||
def _cmd_break(session, settings: AppSettings, _args: str) -> str:
|
||||
ru = settings.notifications.report.language == "ru"
|
||||
kind = pomodoro.next_kind(session, settings)
|
||||
if kind == "work":
|
||||
kind = "short_break"
|
||||
row = pomodoro.start(session, settings, kind=kind)
|
||||
return (
|
||||
f"☕ Перерыв {humanize_minutes(row.planned_seconds / 60)}."
|
||||
if ru
|
||||
else f"☕ {humanize_minutes(row.planned_seconds / 60)} break."
|
||||
)
|
||||
|
||||
|
||||
def _cmd_stop(session, settings: AppSettings, args: str) -> str:
|
||||
ru = settings.notifications.report.language == "ru"
|
||||
try:
|
||||
pomodoro.abandon(session, settings, reason=args.strip() or "stopped from Telegram")
|
||||
except pomodoro.TimerError as exc:
|
||||
return str(exc)
|
||||
return "⏹ Остановлено." if ru else "⏹ Stopped."
|
||||
|
||||
|
||||
def _cmd_pause(session, settings: AppSettings, _args: str) -> str:
|
||||
ru = settings.notifications.report.language == "ru"
|
||||
try:
|
||||
pomodoro.pause(session, settings)
|
||||
except pomodoro.TimerError as exc:
|
||||
return str(exc)
|
||||
return "⏸ Пауза." if ru else "⏸ Paused."
|
||||
|
||||
|
||||
def _cmd_resume(session, settings: AppSettings, _args: str) -> str:
|
||||
ru = settings.notifications.report.language == "ru"
|
||||
try:
|
||||
pomodoro.resume(session)
|
||||
except pomodoro.TimerError as exc:
|
||||
return str(exc)
|
||||
return "▶️ Продолжаем." if ru else "▶️ Resumed."
|
||||
|
||||
|
||||
def _cmd_today(session, settings: AppSettings, _args: str) -> str:
|
||||
"""The todo list, numbered so `/done 3` has something to refer to."""
|
||||
ru = settings.notifications.report.language == "ru"
|
||||
today = local_date(settings.timezone)
|
||||
tasks = tasks_service.list_tasks(session)
|
||||
buckets = tasks_service.bucket_tasks(tasks, today)
|
||||
ordered = buckets["overdue"] + buckets["today"] + buckets["upcoming"] + buckets["someday"]
|
||||
if not ordered:
|
||||
return "✨ Задач нет." if ru else "✨ Nothing on the list."
|
||||
|
||||
_INDEX[settings.notifications.telegram.chat_id] = [task.id for task in ordered]
|
||||
limit = settings.notifications.report.max_tasks_listed
|
||||
lines = [f"<b>{'📋 Задачи' if ru else '📋 Your tasks'}</b>"]
|
||||
marks = {"overdue": "🔴", "today": "🟡", "upcoming": "🔵", "someday": "⚪"}
|
||||
position = 0
|
||||
for bucket_name in ("overdue", "today", "upcoming", "someday"):
|
||||
for task in buckets[bucket_name]:
|
||||
position += 1
|
||||
if position > limit:
|
||||
continue
|
||||
due = f" ({task.due_date.isoformat()})" if task.due_date else ""
|
||||
lines.append(
|
||||
f"{marks[bucket_name]} <b>{position}.</b> {report._esc(task.title)}{due}"
|
||||
)
|
||||
if position > limit:
|
||||
lines.append(("… и ещё " if ru else "… and ") + f"{position - limit}")
|
||||
lines.append("")
|
||||
lines.append("<i>/done N</i>")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
_INDEX: dict[str, list[int]] = {}
|
||||
|
||||
|
||||
def _cmd_done(session, settings: AppSettings, args: str) -> str:
|
||||
ru = settings.notifications.report.language == "ru"
|
||||
chat_id = settings.notifications.telegram.chat_id
|
||||
index = _INDEX.get(chat_id)
|
||||
if not args.strip().isdigit():
|
||||
return "Укажи номер: /done 3" if ru else "Give me a number: /done 3"
|
||||
if not index:
|
||||
return "Сначала /today." if ru else "Run /today first."
|
||||
position = int(args.strip())
|
||||
if not 1 <= position <= len(index):
|
||||
return "Нет такого номера." if ru else "No task with that number."
|
||||
record, warning = tasks_service.complete_task(session, settings, index[position - 1])
|
||||
text = f"✅ {report._esc(record.title)}"
|
||||
return text + (f"\n⚠️ {report._esc(warning)}" if warning else "")
|
||||
|
||||
|
||||
def _cmd_report(session, settings: AppSettings, _args: str) -> str:
|
||||
today = local_date(settings.timezone)
|
||||
_, payload = report.get_or_build_daily(session, settings, today, rebuild=True)
|
||||
return report.render_daily_telegram(payload, settings)
|
||||
|
||||
|
||||
def _cmd_week(session, settings: AppSettings, _args: str) -> str:
|
||||
today = local_date(settings.timezone)
|
||||
payload = report.build_weekly(session, settings, today)
|
||||
return report.render_weekly_telegram(payload, settings)
|
||||
|
||||
|
||||
def _cmd_streak(session, settings: AppSettings, _args: str) -> str:
|
||||
ru = settings.notifications.report.language == "ru"
|
||||
streak = stats.streak(session, settings)
|
||||
lifetime = stats.lifetime(session, settings)
|
||||
return "\n".join(
|
||||
[
|
||||
f"🔥 <b>{'Серия' if ru else 'Streak'}</b>: {streak['current']} "
|
||||
+ ("дн." if ru else "days"),
|
||||
f"🏆 {'Рекорд' if ru else 'Best'}: {streak['best']} · "
|
||||
f"{'дней с целью' if ru else 'goal streak'}: {streak['goal_streak']}",
|
||||
"",
|
||||
f"🍅 {'Всего помидорок' if ru else 'All-time pomodoros'}: {lifetime['pomodoros']}",
|
||||
f"⏱ {'Всего фокуса' if ru else 'All-time focus'}: {lifetime['focus_hours']}h",
|
||||
f"✅ {'Закрыто задач' if ru else 'Tasks closed'}: {lifetime['tasks_completed']}",
|
||||
f"🚫 {'Заблокировано попыток' if ru else 'Blocked attempts'}: {lifetime['blocked_attempts']}",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _cmd_block(session, settings: AppSettings, _args: str) -> str:
|
||||
ru = settings.notifications.report.language == "ru"
|
||||
policy = blocker_service.policy(session, settings)
|
||||
events = blocker_service.events_for_day(session, settings)
|
||||
state = ("включён" if ru else "ON") if policy["active"] else ("выключен" if ru else "OFF")
|
||||
lines = [
|
||||
f"🛡 <b>{'Блокировщик' if ru else 'Blocker'}</b>: {state} ({report._esc(policy['reason'])})",
|
||||
f"{'Сайтов' if ru else 'Domains'}: {len(policy['all_domains'])} · "
|
||||
f"{'приложений' if ru else 'apps'}: {len(policy['all_apps'])}",
|
||||
f"{'Попыток сегодня' if ru else 'Attempts today'}: {events['total']}",
|
||||
]
|
||||
for entry in events["by_target"][:5]:
|
||||
lines.append(f"• {report._esc(entry['target'])} × {entry['count']}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _cmd_help(_session, settings: AppSettings, _args: str) -> str:
|
||||
return HELP_RU if settings.notifications.report.language == "ru" else HELP_EN
|
||||
|
||||
|
||||
COMMANDS: dict[str, Callable] = {
|
||||
"start": _cmd_help,
|
||||
"help": _cmd_help,
|
||||
"status": _cmd_status,
|
||||
"start_work": _cmd_start_work,
|
||||
"work": _cmd_start_work,
|
||||
"break": _cmd_break,
|
||||
"stop": _cmd_stop,
|
||||
"pause": _cmd_pause,
|
||||
"resume": _cmd_resume,
|
||||
"today": _cmd_today,
|
||||
"tasks": _cmd_today,
|
||||
"done": _cmd_done,
|
||||
"report": _cmd_report,
|
||||
"week": _cmd_week,
|
||||
"streak": _cmd_streak,
|
||||
"stats": _cmd_streak,
|
||||
"block": _cmd_block,
|
||||
}
|
||||
|
||||
|
||||
def handle_text(text: str) -> str | None:
|
||||
"""Run one command and return the reply, or None when it is not for us."""
|
||||
text = (text or "").strip()
|
||||
if not text.startswith("/"):
|
||||
return None
|
||||
head, _, args = text[1:].partition(" ")
|
||||
name = head.split("@", 1)[0].lower()
|
||||
handler = COMMANDS.get(name)
|
||||
if handler is None:
|
||||
return None
|
||||
|
||||
with session_scope() as session:
|
||||
settings = settings_service.load_settings(session)
|
||||
try:
|
||||
return handler(session, settings, args)
|
||||
except Exception as exc: # pragma: no cover - a bad command must not kill the bot
|
||||
log.exception("command /%s failed", name)
|
||||
return f"⚠️ {exc}"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Polling loop
|
||||
# --------------------------------------------------------------------------
|
||||
def _loop() -> None:
|
||||
offset: int | None = None
|
||||
while not _stop.is_set():
|
||||
try:
|
||||
with session_scope() as session:
|
||||
settings = settings_service.load_settings(session)
|
||||
telegram = settings.notifications.telegram
|
||||
if not (telegram.enabled and telegram.commands_enabled and telegram.bot_token):
|
||||
_stop.wait(30)
|
||||
continue
|
||||
|
||||
payload: dict[str, Any] = {"timeout": _POLL_TIMEOUT}
|
||||
if offset is not None:
|
||||
payload["offset"] = offset
|
||||
data = _api(
|
||||
telegram.bot_token, "getUpdates", payload, timeout=_POLL_TIMEOUT + 10
|
||||
)
|
||||
for update in data.get("result", []):
|
||||
offset = update["update_id"] + 1
|
||||
message = update.get("message") or update.get("edited_message") or {}
|
||||
chat_id = str((message.get("chat") or {}).get("id", ""))
|
||||
if telegram.chat_id and chat_id and chat_id != str(telegram.chat_id):
|
||||
continue # only the configured chat may drive the timer
|
||||
reply = handle_text(message.get("text", ""))
|
||||
if reply:
|
||||
with session_scope() as session:
|
||||
fresh = settings_service.load_settings(session)
|
||||
_reply(fresh, chat_id or telegram.chat_id, reply)
|
||||
except Exception as exc: # pragma: no cover
|
||||
log.warning("telegram polling hiccup: %s", exc)
|
||||
_stop.wait(10)
|
||||
else:
|
||||
time_module.sleep(0.3)
|
||||
|
||||
|
||||
def start() -> None:
|
||||
"""Start the polling thread if the environment allows it."""
|
||||
global _thread
|
||||
if not env_settings().telegram_bot_enabled or _thread is not None:
|
||||
return
|
||||
_stop.clear()
|
||||
_thread = threading.Thread(target=_loop, name="telegram-bot", daemon=True)
|
||||
_thread.start()
|
||||
log.info("telegram command bot started")
|
||||
|
||||
|
||||
def shutdown() -> None:
|
||||
global _thread
|
||||
_stop.set()
|
||||
_thread = None
|
||||
@@ -0,0 +1,300 @@
|
||||
"""The user-editable settings, as one validated tree.
|
||||
|
||||
Everything the UI can change lives here: timer lengths, which todo sources are
|
||||
on, what the blocker blocks, when Telegram gets poked and in what tone. It is
|
||||
stored as a single JSON row (see `app.models.SettingsRow`), which keeps
|
||||
"add a new option" to a one-line change instead of a migration.
|
||||
|
||||
Defaults are deliberately a complete, working configuration: a fresh install is
|
||||
a classic 25/5/15 pomodoro with no integrations and nothing blocked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import time
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Timer
|
||||
# --------------------------------------------------------------------------
|
||||
class PomodoroSettings(BaseModel):
|
||||
"""Lengths and automation of the timer itself. All durations in minutes."""
|
||||
|
||||
work_minutes: int = Field(default=25, ge=1, le=240)
|
||||
short_break_minutes: int = Field(default=5, ge=1, le=120)
|
||||
long_break_minutes: int = Field(default=15, ge=1, le=240)
|
||||
cycles_before_long_break: int = Field(default=4, ge=1, le=12)
|
||||
|
||||
auto_start_breaks: bool = True
|
||||
"""Roll straight into the break when a work interval ends."""
|
||||
|
||||
auto_start_work: bool = False
|
||||
"""Roll straight into the next work interval when a break ends."""
|
||||
|
||||
daily_goal_pomodoros: int = Field(default=8, ge=1, le=40)
|
||||
"""The bar the daily report measures you against."""
|
||||
|
||||
strict_mode: bool = False
|
||||
"""Abandoning a running work interval requires a reason, and is counted."""
|
||||
|
||||
allow_pause: bool = True
|
||||
"""When false the only ways out of a work interval are finishing or abandoning."""
|
||||
|
||||
count_interruptions: bool = True
|
||||
|
||||
tick_sound: bool = False
|
||||
alarm_sound: bool = True
|
||||
alarm_volume: int = Field(default=70, ge=0, le=100)
|
||||
|
||||
desktop_notifications: bool = True
|
||||
keep_on_top_during_work: bool = False
|
||||
"""Desktop app: float the window above everything while a work interval runs."""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Todo sources
|
||||
# --------------------------------------------------------------------------
|
||||
class JiraSourceSettings(BaseModel):
|
||||
"""Jira Cloud, authenticated with account email + API token (Basic auth)."""
|
||||
|
||||
enabled: bool = False
|
||||
base_url: str = ""
|
||||
email: str = ""
|
||||
api_token: str = ""
|
||||
|
||||
extra_jql: str = ""
|
||||
"""ANDed onto every query, e.g. `assignee = currentUser()`."""
|
||||
|
||||
start_date_field: str = "customfield_10015"
|
||||
"""Jira's "Start date"; its id differs per site (see /rest/api/3/field)."""
|
||||
|
||||
include_all_open: bool = True
|
||||
"""Fetch every open issue, not only the ones due today or overdue."""
|
||||
|
||||
max_issues: int = Field(default=200, ge=1, le=2000)
|
||||
|
||||
writeback_done: bool = True
|
||||
"""Completing a Jira task here transitions the issue to Done in Jira."""
|
||||
|
||||
done_transition_name: str = "Done"
|
||||
"""Preferred transition name when writing back; falls back to any 'done' one."""
|
||||
|
||||
|
||||
class GoogleTasksSourceSettings(BaseModel):
|
||||
"""Google Tasks, via an OAuth refresh token (see jira-helper's README)."""
|
||||
|
||||
enabled: bool = False
|
||||
client_id: str = ""
|
||||
client_secret: str = ""
|
||||
refresh_token: str = ""
|
||||
lists: list[str] = Field(default_factory=list)
|
||||
"""Titles or ids to limit to. Empty = every list in the account."""
|
||||
|
||||
include_undated: bool = True
|
||||
"""Keep tasks with no due date (jira-helper drops them; here they are todos)."""
|
||||
|
||||
writeback_done: bool = True
|
||||
|
||||
|
||||
class TodoistSourceSettings(BaseModel):
|
||||
"""Todoist, via a personal API token."""
|
||||
|
||||
enabled: bool = False
|
||||
api_token: str = ""
|
||||
projects: list[str] = Field(default_factory=list)
|
||||
"""Names or ids to limit to. Empty = every project."""
|
||||
|
||||
filter_query: str = ""
|
||||
"""Override the server-side filter. Empty = all open tasks."""
|
||||
|
||||
writeback_done: bool = True
|
||||
|
||||
|
||||
class LaundrySourceSettings(BaseModel):
|
||||
"""Campus laundry bookings — not a todo, but a thing that owns your time."""
|
||||
|
||||
enabled: bool = False
|
||||
base_url: str = "https://4ka.inbicst.ru"
|
||||
session_cookie: str = ""
|
||||
"""The `sessionid` cookie, or a whole `name=value; ...` cookie string."""
|
||||
|
||||
|
||||
class SourcesSettings(BaseModel):
|
||||
jira: JiraSourceSettings = Field(default_factory=JiraSourceSettings)
|
||||
google_tasks: GoogleTasksSourceSettings = Field(
|
||||
default_factory=GoogleTasksSourceSettings
|
||||
)
|
||||
todoist: TodoistSourceSettings = Field(default_factory=TodoistSourceSettings)
|
||||
laundry: LaundrySourceSettings = Field(default_factory=LaundrySourceSettings)
|
||||
|
||||
refresh_interval_minutes: int = Field(default=15, ge=1, le=1440)
|
||||
"""How often the background job re-pulls every source."""
|
||||
|
||||
request_timeout_seconds: int = Field(default=30, ge=5, le=120)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Blocker (enforced by the desktop app)
|
||||
# --------------------------------------------------------------------------
|
||||
class BlockWindow(BaseModel):
|
||||
"""A recurring time window in which blocking is on regardless of the timer."""
|
||||
|
||||
label: str = ""
|
||||
start: time = time(9, 0)
|
||||
end: time = time(18, 0)
|
||||
days: list[int] = Field(default_factory=lambda: [0, 1, 2, 3, 4])
|
||||
"""ISO weekdays, Monday = 0 .. Sunday = 6."""
|
||||
|
||||
|
||||
class BlockerSettings(BaseModel):
|
||||
enabled: bool = False
|
||||
|
||||
mode: Literal["during_work", "always", "scheduled"] = "during_work"
|
||||
"""`during_work` blocks only while a work interval runs — the useful default."""
|
||||
|
||||
block_during_breaks: bool = False
|
||||
blocked_domains: list[str] = Field(default_factory=list)
|
||||
"""Bare hostnames, e.g. `youtube.com`. `www.` and subdomains are added for you."""
|
||||
|
||||
allowed_domains: list[str] = Field(default_factory=list)
|
||||
"""Exceptions that survive a wildcard entry, e.g. `music.youtube.com`."""
|
||||
|
||||
blocked_apps: list[str] = Field(default_factory=list)
|
||||
"""Process names, e.g. `steam`, `discord`, `Telegram`."""
|
||||
|
||||
kill_blocked_apps: bool = True
|
||||
"""Terminate a blocked app when it appears; otherwise only warn and log."""
|
||||
|
||||
schedule: list[BlockWindow] = Field(default_factory=list)
|
||||
strict: bool = False
|
||||
"""The desktop app refuses to switch blocking off while a work interval runs."""
|
||||
|
||||
redirect_ip: str = "127.0.0.1"
|
||||
app_poll_seconds: int = Field(default=5, ge=1, le=60)
|
||||
notify_on_block: bool = True
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Notifications and reports
|
||||
# --------------------------------------------------------------------------
|
||||
class QuietHours(BaseModel):
|
||||
enabled: bool = False
|
||||
start: time = time(23, 0)
|
||||
end: time = time(8, 0)
|
||||
|
||||
|
||||
class TelegramSettings(BaseModel):
|
||||
"""Everything about the bot that nags, congratulates and reports."""
|
||||
|
||||
enabled: bool = False
|
||||
bot_token: str = ""
|
||||
chat_id: str = ""
|
||||
|
||||
commands_enabled: bool = True
|
||||
"""Run a long-polling bot so /today, /report, /stats work in the chat."""
|
||||
|
||||
daily_report: bool = True
|
||||
daily_report_time: time = time(21, 0)
|
||||
|
||||
morning_briefing: bool = True
|
||||
"""A "here is your day" message with the todo list and yesterday's streak."""
|
||||
|
||||
morning_briefing_time: time = time(9, 0)
|
||||
|
||||
weekly_report: bool = True
|
||||
weekly_report_weekday: int = Field(default=6, ge=0, le=6)
|
||||
"""ISO weekday for the weekly wrap-up, Monday = 0 (default Sunday)."""
|
||||
|
||||
celebrate_every_n_pomodoros: int = Field(default=4, ge=0, le=50)
|
||||
"""Cheer in the chat every N completed pomodoros today. 0 = never."""
|
||||
|
||||
nudge_enabled: bool = True
|
||||
nudge_after_idle_minutes: int = Field(default=90, ge=10, le=600)
|
||||
"""Poke you when you have not run a pomodoro for this long during the day."""
|
||||
|
||||
nudge_window_start: time = time(10, 0)
|
||||
nudge_window_end: time = time(20, 0)
|
||||
nudge_max_per_day: int = Field(default=4, ge=0, le=24)
|
||||
|
||||
goal_warning: bool = True
|
||||
"""Warn in the evening when the daily goal is still out of reach."""
|
||||
|
||||
quiet_hours: QuietHours = Field(default_factory=QuietHours)
|
||||
|
||||
|
||||
class SlackSettings(BaseModel):
|
||||
enabled: bool = False
|
||||
webhook_url: str = ""
|
||||
|
||||
|
||||
class ReportSettings(BaseModel):
|
||||
tone: Literal["friendly", "coach", "drill_sergeant", "dry"] = "friendly"
|
||||
language: Literal["en", "ru"] = "en"
|
||||
include_funny_stats: bool = True
|
||||
include_task_list: bool = True
|
||||
include_blocked_sites: bool = True
|
||||
include_achievements: bool = True
|
||||
include_comparison: bool = True
|
||||
"""Compare today against your 7-day average."""
|
||||
|
||||
max_tasks_listed: int = Field(default=15, ge=1, le=100)
|
||||
|
||||
|
||||
class NotificationsSettings(BaseModel):
|
||||
telegram: TelegramSettings = Field(default_factory=TelegramSettings)
|
||||
slack: SlackSettings = Field(default_factory=SlackSettings)
|
||||
report: ReportSettings = Field(default_factory=ReportSettings)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Root
|
||||
# --------------------------------------------------------------------------
|
||||
class AppearanceSettings(BaseModel):
|
||||
theme: Literal["light", "dark", "system"] = "system"
|
||||
accent: str = "#6750A4"
|
||||
"""Material 3 seed colour; the whole palette is derived from it."""
|
||||
|
||||
compact: bool = False
|
||||
|
||||
|
||||
class AppSettings(BaseModel):
|
||||
"""The single settings document stored in the database."""
|
||||
|
||||
timezone: str = "Europe/Moscow"
|
||||
week_starts_monday: bool = True
|
||||
|
||||
pomodoro: PomodoroSettings = Field(default_factory=PomodoroSettings)
|
||||
sources: SourcesSettings = Field(default_factory=SourcesSettings)
|
||||
blocker: BlockerSettings = Field(default_factory=BlockerSettings)
|
||||
notifications: NotificationsSettings = Field(default_factory=NotificationsSettings)
|
||||
appearance: AppearanceSettings = Field(default_factory=AppearanceSettings)
|
||||
|
||||
@field_validator("timezone")
|
||||
@classmethod
|
||||
def _known_timezone(cls, value: str) -> str:
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
try:
|
||||
ZoneInfo(value)
|
||||
except (ZoneInfoNotFoundError, ValueError) as exc: # pragma: no cover
|
||||
raise ValueError(f"unknown timezone: {value!r}") from exc
|
||||
return value
|
||||
|
||||
|
||||
# Keys whose values are never sent to a browser in full; the API replaces them
|
||||
# with a masked placeholder and the UI sends the placeholder back unchanged.
|
||||
SECRET_FIELDS = frozenset(
|
||||
{
|
||||
"api_token",
|
||||
"client_secret",
|
||||
"refresh_token",
|
||||
"session_cookie",
|
||||
"bot_token",
|
||||
"webhook_url",
|
||||
}
|
||||
)
|
||||
|
||||
SECRET_MASK = "••••••••"
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Todo sources.
|
||||
|
||||
Each module here turns one external service into `TodoItem`s and, where the
|
||||
service allows it, closes a task back in that service. The auth schemes and the
|
||||
pagination quirks are the ones worked out in `~/code/jira-helper`; what is new
|
||||
is that these fetch **everything open**, not just what is due today, because a
|
||||
pomodoro app needs something to point the timer at.
|
||||
"""
|
||||
|
||||
from app.sources.base import SourceError, SourceResult, TodoItem
|
||||
|
||||
__all__ = ["SourceError", "SourceResult", "TodoItem"]
|
||||
@@ -0,0 +1,70 @@
|
||||
"""The contract every todo source implements."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
from typing import Any, Protocol
|
||||
|
||||
|
||||
class SourceError(RuntimeError):
|
||||
"""A source could not be read. Carries a message meant for the user."""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TodoItem:
|
||||
"""One todo, normalised across Jira / Google Tasks / Todoist / local."""
|
||||
|
||||
source: str
|
||||
external_id: str
|
||||
title: str
|
||||
|
||||
description: str | None = None
|
||||
url: str | None = None
|
||||
project: str | None = None
|
||||
status: str | None = None
|
||||
priority: str | None = None
|
||||
due_date: date | None = None
|
||||
start_date: date | None = None
|
||||
labels: list[str] = field(default_factory=list)
|
||||
is_done: bool = False
|
||||
raw: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def days_overdue(self, today: date) -> int:
|
||||
"""Days past the due date, or 0 when due today / not dated."""
|
||||
if self.due_date is None:
|
||||
return 0
|
||||
return max((today - self.due_date).days, 0)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SourceResult:
|
||||
"""What one source returned, including the failure if it had one.
|
||||
|
||||
A source that is down costs you that source, never the whole page — the same
|
||||
rule jira-helper applies to its message sections.
|
||||
"""
|
||||
|
||||
source: str
|
||||
items: list[TodoItem] = field(default_factory=list)
|
||||
ok: bool = True
|
||||
error: str | None = None
|
||||
|
||||
@classmethod
|
||||
def failed(cls, source: str, error: str) -> "SourceResult":
|
||||
return cls(source=source, items=[], ok=False, error=error)
|
||||
|
||||
|
||||
class TodoSource(Protocol):
|
||||
"""A configured, ready-to-call source."""
|
||||
|
||||
name: str
|
||||
|
||||
def fetch(self) -> list[TodoItem]:
|
||||
"""Every open todo this source knows about."""
|
||||
|
||||
def complete(self, item: TodoItem) -> bool:
|
||||
"""Close the task in the external system. False = not supported."""
|
||||
|
||||
def check(self) -> str:
|
||||
"""Verify credentials; return a short human description of the account."""
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Google Tasks as a todo source.
|
||||
|
||||
OAuth 2.0: the long-lived refresh token from the settings is exchanged for an
|
||||
access token per run (cached for the life of the object). Obtain the refresh
|
||||
token with jira-helper's `uv run google-tasks-auth` — the credentials are
|
||||
interchangeable, so an existing setup works here unchanged.
|
||||
|
||||
Unlike jira-helper, undated tasks are kept by default: "someday" items are
|
||||
still things you can spend a pomodoro on.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Any, Iterator
|
||||
|
||||
import httpx
|
||||
|
||||
from app.settings_schema import GoogleTasksSourceSettings
|
||||
from app.sources.base import SourceError, TodoItem
|
||||
|
||||
_TOKEN_URL = "https://oauth2.googleapis.com/token"
|
||||
_API_BASE = "https://tasks.googleapis.com/tasks/v1"
|
||||
_PAGE_SIZE = 100
|
||||
|
||||
|
||||
class GoogleTasksSource:
|
||||
name = "google_tasks"
|
||||
|
||||
def __init__(self, config: GoogleTasksSourceSettings, *, timeout: int = 30) -> None:
|
||||
if not (config.client_id and config.client_secret and config.refresh_token):
|
||||
raise SourceError(
|
||||
"Google Tasks needs a client id, client secret and refresh token."
|
||||
)
|
||||
self._config = config
|
||||
self._client = httpx.Client(timeout=timeout)
|
||||
self._access_token: str | None = None
|
||||
|
||||
# ---- plumbing ----------------------------------------------------------
|
||||
|
||||
def _token(self) -> str:
|
||||
if self._access_token:
|
||||
return self._access_token
|
||||
try:
|
||||
resp = self._client.post(
|
||||
_TOKEN_URL,
|
||||
data={
|
||||
"client_id": self._config.client_id,
|
||||
"client_secret": self._config.client_secret,
|
||||
"refresh_token": self._config.refresh_token,
|
||||
"grant_type": "refresh_token",
|
||||
},
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise SourceError(f"Google is unreachable: {exc}") from exc
|
||||
if not resp.is_success:
|
||||
raise SourceError(
|
||||
"Could not refresh the Google access token "
|
||||
f"({resp.status_code}). Re-run the Google auth flow for a fresh "
|
||||
"refresh token."
|
||||
)
|
||||
token = (resp.json() or {}).get("access_token")
|
||||
if not token:
|
||||
raise SourceError("Google's token response carried no access_token.")
|
||||
self._access_token = token
|
||||
return token
|
||||
|
||||
def _get(self, path: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
resp = self._client.get(
|
||||
f"{_API_BASE}{path}",
|
||||
params=params,
|
||||
headers={"Authorization": f"Bearer {self._token()}"},
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise SourceError(f"Google is unreachable: {exc}") from exc
|
||||
if not resp.is_success:
|
||||
raise SourceError(f"Google GET {path} -> {resp.status_code}: {resp.text[:300]}")
|
||||
return resp.json() or {}
|
||||
|
||||
def _paginate(self, path: str, params: dict[str, Any]) -> Iterator[dict[str, Any]]:
|
||||
params = dict(params)
|
||||
while True:
|
||||
data = self._get(path, params)
|
||||
yield from data.get("items", [])
|
||||
token = data.get("nextPageToken")
|
||||
if not token:
|
||||
return
|
||||
params["pageToken"] = token
|
||||
|
||||
def _task_lists(self) -> list[dict[str, Any]]:
|
||||
lists = list(self._paginate("/users/@me/lists", {"maxResults": _PAGE_SIZE}))
|
||||
if not self._config.lists:
|
||||
return lists
|
||||
wanted = set(self._config.lists)
|
||||
return [
|
||||
task_list
|
||||
for task_list in lists
|
||||
if wanted & {task_list.get("id", ""), task_list.get("title", "")}
|
||||
]
|
||||
|
||||
# ---- source API --------------------------------------------------------
|
||||
|
||||
def fetch(self) -> list[TodoItem]:
|
||||
items: list[TodoItem] = []
|
||||
for task_list in self._task_lists():
|
||||
list_id = task_list.get("id")
|
||||
if not list_id:
|
||||
continue
|
||||
list_title = task_list.get("title") or "(untitled list)"
|
||||
for raw in self._paginate(
|
||||
f"/lists/{list_id}/tasks",
|
||||
{
|
||||
"maxResults": _PAGE_SIZE,
|
||||
"showCompleted": "false",
|
||||
"showHidden": "false",
|
||||
"showDeleted": "false",
|
||||
},
|
||||
):
|
||||
if raw.get("status") == "completed":
|
||||
continue
|
||||
due = _parse_due(raw.get("due"))
|
||||
if due is None and not self._config.include_undated:
|
||||
continue
|
||||
task_id = raw.get("id") or ""
|
||||
items.append(
|
||||
TodoItem(
|
||||
source=self.name,
|
||||
# The list id is part of every write path, so it has to
|
||||
# survive the round-trip through our database.
|
||||
external_id=f"{list_id}:{task_id}",
|
||||
title=(raw.get("title") or "").strip() or "(no title)",
|
||||
description=(raw.get("notes") or "").strip() or None,
|
||||
url=raw.get("webViewLink"),
|
||||
project=list_title,
|
||||
due_date=due,
|
||||
raw={"list_id": list_id, "task_id": task_id},
|
||||
)
|
||||
)
|
||||
return items
|
||||
|
||||
def check(self) -> str:
|
||||
return f"{len(self._task_lists())} Google task list(s) visible"
|
||||
|
||||
def complete(self, item: TodoItem) -> bool:
|
||||
"""PATCH the task's status — Google Tasks has no dedicated close verb."""
|
||||
if not self._config.writeback_done:
|
||||
return False
|
||||
list_id, _, task_id = item.external_id.partition(":")
|
||||
if not (list_id and task_id):
|
||||
raise SourceError(f"Unusable Google task id: {item.external_id!r}")
|
||||
try:
|
||||
resp = self._client.patch(
|
||||
f"{_API_BASE}/lists/{list_id}/tasks/{task_id}",
|
||||
json={"status": "completed"},
|
||||
headers={"Authorization": f"Bearer {self._token()}"},
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise SourceError(f"Google is unreachable: {exc}") from exc
|
||||
if not resp.is_success:
|
||||
raise SourceError(f"Google could not complete the task: {resp.status_code}")
|
||||
return True
|
||||
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
|
||||
|
||||
def _parse_due(raw: Any) -> date | None:
|
||||
"""Google returns RFC-3339, but only the date part is ever meaningful."""
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return date.fromisoformat(str(raw)[:10])
|
||||
except ValueError:
|
||||
return None
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Jira Cloud as a todo source.
|
||||
|
||||
Auth is HTTP Basic with an Atlassian account email + API token, and search goes
|
||||
through the token-paginated `/rest/api/3/search/jql` endpoint — both carried
|
||||
over from jira-helper's client. The difference is the query: jira-helper asks
|
||||
"what is due today", this asks "what is open at all", because you need
|
||||
something to start a pomodoro on even when nothing is due.
|
||||
|
||||
Writing back is new here: finishing a Jira-backed pomodoro task transitions the
|
||||
issue to Done, so the app is not a second place to keep your task list in sync.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import date
|
||||
from typing import Any, Iterator
|
||||
|
||||
import httpx
|
||||
|
||||
from app.settings_schema import JiraSourceSettings
|
||||
from app.sources.base import SourceError, TodoItem
|
||||
|
||||
_SEARCH_PATH = "/rest/api/3/search/jql"
|
||||
_BASE_FIELDS = ["summary", "duedate", "status", "assignee", "priority", "project", "labels"]
|
||||
_OPEN_CONDITION = "statusCategory != Done"
|
||||
_CUSTOM_FIELD_RE = re.compile(r"^customfield_(\d+)$")
|
||||
|
||||
|
||||
def start_date_clause(field_id: str) -> str:
|
||||
"""`customfield_10015` has to be written `cf[10015]` in JQL; names get quoted."""
|
||||
match = _CUSTOM_FIELD_RE.match(field_id)
|
||||
return f"cf[{match.group(1)}]" if match else f'"{field_id}"'
|
||||
|
||||
|
||||
class JiraSource:
|
||||
name = "jira"
|
||||
|
||||
def __init__(self, config: JiraSourceSettings, *, timeout: int = 30) -> None:
|
||||
if not (config.base_url and config.email and config.api_token):
|
||||
raise SourceError("Jira needs a base URL, an account email and an API token.")
|
||||
self._config = config
|
||||
self._base_url = config.base_url.rstrip("/")
|
||||
self._client = httpx.Client(
|
||||
base_url=self._base_url,
|
||||
auth=(config.email, config.api_token),
|
||||
headers={"Accept": "application/json", "Content-Type": "application/json"},
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
# ---- plumbing ----------------------------------------------------------
|
||||
|
||||
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
|
||||
try:
|
||||
resp = self._client.request(method, path, **kwargs)
|
||||
except httpx.HTTPError as exc:
|
||||
raise SourceError(f"Jira is unreachable: {exc}") from exc
|
||||
if resp.status_code in (401, 403):
|
||||
raise SourceError(
|
||||
"Jira rejected the credentials (check the account email and the "
|
||||
"API token — a token, never your password)."
|
||||
)
|
||||
if not resp.is_success:
|
||||
raise SourceError(f"Jira {method} {path} -> {resp.status_code}: {resp.text[:300]}")
|
||||
if resp.status_code == 204 or not resp.content:
|
||||
return None
|
||||
return resp.json()
|
||||
|
||||
def _fields(self) -> list[str]:
|
||||
return [*_BASE_FIELDS, self._config.start_date_field]
|
||||
|
||||
def _jql(self) -> str:
|
||||
"""Open issues, newest deadlines first, narrowed by the user's extra JQL."""
|
||||
jql = _OPEN_CONDITION
|
||||
if not self._config.include_all_open:
|
||||
clause = start_date_clause(self._config.start_date_field)
|
||||
jql = (
|
||||
f"({_OPEN_CONDITION}) AND "
|
||||
f"(duedate <= startOfDay('+7d') OR {clause} <= startOfDay())"
|
||||
)
|
||||
if self._config.extra_jql.strip():
|
||||
jql = f"({jql}) AND ({self._config.extra_jql.strip()})"
|
||||
return f"{jql} ORDER BY duedate ASC, priority DESC, updated DESC"
|
||||
|
||||
def _search(self, jql: str) -> Iterator[dict[str, Any]]:
|
||||
payload: dict[str, Any] = {
|
||||
"jql": jql,
|
||||
"maxResults": 100,
|
||||
"fields": self._fields(),
|
||||
}
|
||||
seen = 0
|
||||
while True:
|
||||
data = self._request("POST", _SEARCH_PATH, json=payload) or {}
|
||||
for issue in data.get("issues", []):
|
||||
yield issue
|
||||
seen += 1
|
||||
if seen >= self._config.max_issues:
|
||||
return
|
||||
token = data.get("nextPageToken")
|
||||
if not token:
|
||||
return
|
||||
payload["nextPageToken"] = token
|
||||
|
||||
# ---- source API --------------------------------------------------------
|
||||
|
||||
def fetch(self) -> list[TodoItem]:
|
||||
return [self._to_item(issue) for issue in self._search(self._jql())]
|
||||
|
||||
def check(self) -> str:
|
||||
me = self._request("GET", "/rest/api/3/myself") or {}
|
||||
return f"{me.get('displayName', 'unknown')} on {self._base_url}"
|
||||
|
||||
def complete(self, item: TodoItem) -> bool:
|
||||
"""Transition the issue into a Done-category status.
|
||||
|
||||
Jira has no generic "close" verb: every project defines its own workflow,
|
||||
so we ask the issue which transitions it offers and pick the one named in
|
||||
the settings, else any transition landing in the Done category.
|
||||
"""
|
||||
if not self._config.writeback_done:
|
||||
return False
|
||||
key = item.external_id
|
||||
data = self._request("GET", f"/rest/api/3/issue/{key}/transitions") or {}
|
||||
transitions = data.get("transitions", [])
|
||||
wanted = self._config.done_transition_name.strip().lower()
|
||||
|
||||
chosen = next(
|
||||
(t for t in transitions if (t.get("name") or "").lower() == wanted),
|
||||
None,
|
||||
) or next(
|
||||
(
|
||||
t
|
||||
for t in transitions
|
||||
if ((t.get("to") or {}).get("statusCategory") or {}).get("key") == "done"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if chosen is None:
|
||||
raise SourceError(
|
||||
f"{key} has no transition to a Done status"
|
||||
+ (f" named {self._config.done_transition_name!r}" if wanted else "")
|
||||
+ ". Available: "
|
||||
+ ", ".join(t.get("name", "?") for t in transitions)
|
||||
)
|
||||
self._request(
|
||||
"POST",
|
||||
f"/rest/api/3/issue/{key}/transitions",
|
||||
json={"transition": {"id": chosen["id"]}},
|
||||
)
|
||||
return True
|
||||
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
|
||||
# ---- mapping -----------------------------------------------------------
|
||||
|
||||
def _to_item(self, issue: dict[str, Any]) -> TodoItem:
|
||||
fields = issue.get("fields") or {}
|
||||
key = issue.get("key", "")
|
||||
status = (fields.get("status") or {}).get("name")
|
||||
category = ((fields.get("status") or {}).get("statusCategory") or {}).get("key")
|
||||
return TodoItem(
|
||||
source=self.name,
|
||||
external_id=key,
|
||||
title=(fields.get("summary") or "").strip() or key,
|
||||
url=f"{self._base_url}/browse/{key}",
|
||||
project=((fields.get("project") or {}).get("name")),
|
||||
status=status,
|
||||
priority=(fields.get("priority") or {}).get("name"),
|
||||
due_date=_parse_date(fields.get("duedate")),
|
||||
start_date=_parse_date(fields.get(self._config.start_date_field)),
|
||||
labels=list(fields.get("labels") or []),
|
||||
is_done=category == "done",
|
||||
raw={"key": key, "statusCategory": category},
|
||||
)
|
||||
|
||||
|
||||
def _parse_date(raw: Any) -> date | None:
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return date.fromisoformat(str(raw)[:10])
|
||||
except ValueError:
|
||||
return None
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Campus laundry bookings (4ka.inbicst.ru) as time commitments.
|
||||
|
||||
The site has no API and only offers Yandex SSO, so authentication *is* the
|
||||
session cookie you copy out of your browser — the approach jira-helper settled
|
||||
on. On the booking grid every slot is a `div.period`, and the ones you booked
|
||||
carry an extra `my` class, which is the only thing that makes "my laundry
|
||||
today" findable.
|
||||
|
||||
These are not todos you tick off: they are blocks of time that will interrupt
|
||||
your pomodoros, so the UI shows them on the timer page and the daily report
|
||||
mentions them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, time
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from app.settings_schema import LaundrySourceSettings
|
||||
from app.sources.base import SourceError, TodoItem
|
||||
|
||||
_PROFILE_PATH = "/profile/"
|
||||
_GRID_PATH = "/"
|
||||
_SESSION_COOKIE = "sessionid"
|
||||
_COOKIE_PAIR_RE = re.compile(r"^[A-Za-z0-9_.\-]+=")
|
||||
_TIME_RANGE_RE = re.compile(r"(\d{1,2}):(\d{2})\s*[-–—]\s*(\d{1,2}):(\d{2})")
|
||||
_USER_AGENT = "Mozilla/5.0 (compatible; kadence laundry reader)"
|
||||
|
||||
_EXPIRED_HINT = (
|
||||
"the laundry session cookie is no longer valid. Sign in at {base_url} in "
|
||||
"your browser, copy the fresh `sessionid` cookie and paste it into "
|
||||
"Settings → Sources → Laundry."
|
||||
)
|
||||
|
||||
|
||||
def parse_cookie_header(raw: str) -> dict[str, str]:
|
||||
"""Accept either a bare `sessionid` value or a whole copied cookie string."""
|
||||
if not _COOKIE_PAIR_RE.match(raw):
|
||||
return {_SESSION_COOKIE: raw}
|
||||
cookies: dict[str, str] = {}
|
||||
for part in raw.split(";"):
|
||||
name, _, value = part.strip().partition("=")
|
||||
if name.strip() and value.strip():
|
||||
cookies[name.strip()] = value.strip()
|
||||
return cookies
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class LaundrySlot:
|
||||
location: str
|
||||
machine: str
|
||||
time_range: str
|
||||
start: time | None
|
||||
finished: bool
|
||||
current: bool
|
||||
refunded: bool
|
||||
|
||||
@property
|
||||
def is_upcoming(self) -> bool:
|
||||
return not self.finished and not self.refunded
|
||||
|
||||
|
||||
class LaundrySource:
|
||||
name = "laundry"
|
||||
|
||||
def __init__(self, config: LaundrySourceSettings, *, timeout: int = 30) -> None:
|
||||
if not config.session_cookie:
|
||||
raise SourceError("The laundry reader needs a `sessionid` cookie.")
|
||||
cookies = parse_cookie_header(config.session_cookie)
|
||||
if _SESSION_COOKIE not in cookies:
|
||||
raise SourceError(
|
||||
f"The laundry cookie has no {_SESSION_COOKIE!r} value in it."
|
||||
)
|
||||
self._base_url = config.base_url.rstrip("/")
|
||||
host = urlsplit(self._base_url).hostname or ""
|
||||
jar = httpx.Cookies()
|
||||
for key, value in cookies.items():
|
||||
jar.set(key, value, domain=host, path="/")
|
||||
self._client = httpx.Client(
|
||||
base_url=self._base_url,
|
||||
cookies=jar,
|
||||
headers={"User-Agent": _USER_AGENT},
|
||||
timeout=timeout,
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
def _check_session(self) -> None:
|
||||
"""An expired cookie silently serves the anonymous page, where none of
|
||||
the slots are marked as yours — indistinguishable from "nothing booked".
|
||||
So verify first."""
|
||||
try:
|
||||
resp = self._client.get(_PROFILE_PATH)
|
||||
except httpx.HTTPError as exc:
|
||||
raise SourceError(f"The laundry site is unreachable: {exc}") from exc
|
||||
if resp.status_code != 200:
|
||||
raise SourceError(_EXPIRED_HINT.format(base_url=self._base_url))
|
||||
|
||||
def fetch(self) -> list[TodoItem]:
|
||||
self._check_session()
|
||||
try:
|
||||
resp = self._client.get(_GRID_PATH)
|
||||
except httpx.HTTPError as exc:
|
||||
raise SourceError(f"The laundry site is unreachable: {exc}") from exc
|
||||
if not resp.is_success:
|
||||
raise SourceError(f"Laundry grid -> {resp.status_code}")
|
||||
|
||||
_, slots = parse_my_slots(resp.text)
|
||||
today = date.today()
|
||||
return [
|
||||
TodoItem(
|
||||
source=self.name,
|
||||
external_id=f"{today.isoformat()}:{slot.machine}:{slot.time_range}",
|
||||
title=f"{slot.time_range} — {slot.machine} ({slot.location})",
|
||||
project="Laundry",
|
||||
status="running now" if slot.current else "booked",
|
||||
due_date=today,
|
||||
raw={
|
||||
"start": slot.start.isoformat() if slot.start else None,
|
||||
"current": slot.current,
|
||||
},
|
||||
)
|
||||
for slot in slots
|
||||
if slot.is_upcoming
|
||||
]
|
||||
|
||||
def check(self) -> str:
|
||||
self._check_session()
|
||||
return f"signed in at {self._base_url}"
|
||||
|
||||
def complete(self, item: TodoItem) -> bool:
|
||||
return False # the washing machine finishes on its own
|
||||
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
|
||||
|
||||
def _text(node: Any) -> str:
|
||||
return node.get_text(strip=True) if node is not None else ""
|
||||
|
||||
|
||||
def _parse_start(time_range: str) -> time | None:
|
||||
match = _TIME_RANGE_RE.search(time_range)
|
||||
if not match:
|
||||
return None
|
||||
hour, minute = int(match.group(1)), int(match.group(2))
|
||||
if not (0 <= hour <= 23 and 0 <= minute <= 59):
|
||||
return None
|
||||
return time(hour, minute)
|
||||
|
||||
|
||||
def parse_my_slots(html: str) -> tuple[str, list[LaundrySlot]]:
|
||||
"""Pull your own slots off a booking grid. Returns `(day_label, slots)`."""
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
day = soup.select_one(".date-machines")
|
||||
if day is None:
|
||||
raise SourceError(
|
||||
"No booking grid in the response — the layout changed, or the "
|
||||
"session was not accepted."
|
||||
)
|
||||
day_label = (day.get("id") or "").strip()
|
||||
|
||||
slots: list[LaundrySlot] = []
|
||||
for block in day.select(".machines"):
|
||||
location = _text(block.select_one(".floor-title")) or "?"
|
||||
for machine in block.select(".machine"):
|
||||
machine_name = _text(machine.select_one(".machine-title")) or "?"
|
||||
for period in machine.select(".period.my"):
|
||||
classes = set(period.get("class") or [])
|
||||
time_range = _text(period.find("div")) or "?"
|
||||
slots.append(
|
||||
LaundrySlot(
|
||||
location=location,
|
||||
machine=machine_name,
|
||||
time_range=time_range,
|
||||
start=_parse_start(time_range),
|
||||
finished="finished" in classes,
|
||||
current="current" in classes,
|
||||
refunded="refunded" in classes,
|
||||
)
|
||||
)
|
||||
|
||||
slots.sort(key=lambda slot: (slot.start is None, slot.start or time.min, slot.machine))
|
||||
return day_label, slots
|
||||
|
||||
|
||||
def slot_is_now(slot: LaundrySlot, moment: datetime | None = None) -> bool:
|
||||
"""Whether the machine is running at `moment` (used by the timer page)."""
|
||||
return slot.current
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Builds the configured sources and pulls them all, independently.
|
||||
|
||||
Independence is the whole point: an expired laundry cookie or a Jira outage
|
||||
costs you that one source and shows up as a warning beside it, never as an
|
||||
empty task list.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any, Callable
|
||||
|
||||
from app.settings_schema import AppSettings
|
||||
from app.sources.base import SourceError, SourceResult, TodoItem
|
||||
from app.sources.google_tasks import GoogleTasksSource
|
||||
from app.sources.jira import JiraSource
|
||||
from app.sources.laundry import LaundrySource
|
||||
from app.sources.todoist import TodoistSource
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
SOURCE_LABELS = {
|
||||
"jira": "Jira",
|
||||
"todoist": "Todoist",
|
||||
"google_tasks": "Google Tasks",
|
||||
"laundry": "Laundry",
|
||||
"local": "My list",
|
||||
}
|
||||
|
||||
|
||||
def build_sources(settings: AppSettings) -> dict[str, Any]:
|
||||
"""Instantiate every enabled source. A misconfigured one is skipped, loudly."""
|
||||
timeout = settings.sources.request_timeout_seconds
|
||||
builders: list[tuple[str, Callable[[], Any]]] = [
|
||||
("jira", lambda: JiraSource(settings.sources.jira, timeout=timeout)),
|
||||
("todoist", lambda: TodoistSource(settings.sources.todoist, timeout=timeout)),
|
||||
(
|
||||
"google_tasks",
|
||||
lambda: GoogleTasksSource(settings.sources.google_tasks, timeout=timeout),
|
||||
),
|
||||
("laundry", lambda: LaundrySource(settings.sources.laundry, timeout=timeout)),
|
||||
]
|
||||
enabled = {
|
||||
"jira": settings.sources.jira.enabled,
|
||||
"todoist": settings.sources.todoist.enabled,
|
||||
"google_tasks": settings.sources.google_tasks.enabled,
|
||||
"laundry": settings.sources.laundry.enabled,
|
||||
}
|
||||
|
||||
sources: dict[str, Any] = {}
|
||||
for name, build in builders:
|
||||
if not enabled[name]:
|
||||
continue
|
||||
try:
|
||||
sources[name] = build()
|
||||
except SourceError as exc:
|
||||
log.warning("source %s is enabled but unusable: %s", name, exc)
|
||||
return sources
|
||||
|
||||
|
||||
def _close(source: Any) -> None:
|
||||
closer = getattr(source, "close", None)
|
||||
if callable(closer):
|
||||
try:
|
||||
closer()
|
||||
except Exception: # pragma: no cover
|
||||
log.debug("closing %s failed", type(source).__name__, exc_info=True)
|
||||
|
||||
|
||||
def fetch_all(settings: AppSettings) -> list[SourceResult]:
|
||||
"""Fetch every enabled source in parallel; never raises."""
|
||||
sources = build_sources(settings)
|
||||
if not sources:
|
||||
return []
|
||||
|
||||
def run(name: str, source: Any) -> SourceResult:
|
||||
try:
|
||||
items: list[TodoItem] = source.fetch()
|
||||
return SourceResult(source=name, items=items)
|
||||
except SourceError as exc:
|
||||
return SourceResult.failed(name, str(exc))
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
log.exception("source %s blew up", name)
|
||||
return SourceResult.failed(name, f"unexpected error: {exc}")
|
||||
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=len(sources)) as pool:
|
||||
futures = [pool.submit(run, name, src) for name, src in sources.items()]
|
||||
return [future.result() for future in futures]
|
||||
finally:
|
||||
for source in sources.values():
|
||||
_close(source)
|
||||
|
||||
|
||||
def complete_remote(settings: AppSettings, item: TodoItem) -> tuple[bool, str | None]:
|
||||
"""Close a task in its own system. Returns `(did_write, error)`."""
|
||||
sources = build_sources(settings)
|
||||
source = sources.get(item.source)
|
||||
if source is None:
|
||||
return False, None
|
||||
try:
|
||||
return bool(source.complete(item)), None
|
||||
except SourceError as exc:
|
||||
return False, str(exc)
|
||||
finally:
|
||||
for built in sources.values():
|
||||
_close(built)
|
||||
|
||||
|
||||
def check_source(settings: AppSettings, name: str) -> tuple[bool, str]:
|
||||
"""Verify one source's credentials — what the settings page's Test button calls."""
|
||||
sources = build_sources(settings)
|
||||
source = sources.get(name)
|
||||
if source is None:
|
||||
return False, f"{SOURCE_LABELS.get(name, name)} is not enabled or not configured."
|
||||
try:
|
||||
return True, source.check()
|
||||
except SourceError as exc:
|
||||
return False, str(exc)
|
||||
except Exception as exc: # pragma: no cover
|
||||
return False, f"unexpected error: {exc}"
|
||||
finally:
|
||||
for built in sources.values():
|
||||
_close(built)
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Todoist as a todo source.
|
||||
|
||||
A personal API token sent as a Bearer token — no OAuth dance. jira-helper asks
|
||||
Todoist's own `overdue | today` filter so Todoist's timezone and recurrence
|
||||
rules decide what is due; here the default is *every* open task (you need
|
||||
something to point the timer at), with the filter still available as a setting
|
||||
for people who want the narrower list.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Any, Iterator
|
||||
|
||||
import httpx
|
||||
|
||||
from app.settings_schema import TodoistSourceSettings
|
||||
from app.sources.base import SourceError, TodoItem
|
||||
|
||||
_API_BASE = "https://api.todoist.com/api/v1"
|
||||
_TASK_URL = "https://app.todoist.com/app/task/{task_id}"
|
||||
_PAGE_SIZE = 200
|
||||
|
||||
# Todoist stores priority upside down from how its apps label it: 4 is "p1".
|
||||
_PRIORITY_LABELS = {4: "p1", 3: "p2", 2: "p3"}
|
||||
|
||||
|
||||
class TodoistSource:
|
||||
name = "todoist"
|
||||
|
||||
def __init__(self, config: TodoistSourceSettings, *, timeout: int = 30) -> None:
|
||||
if not config.api_token:
|
||||
raise SourceError("Todoist needs an API token (Settings → Integrations).")
|
||||
self._config = config
|
||||
self._client = httpx.Client(
|
||||
base_url=_API_BASE,
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"Bearer {config.api_token}",
|
||||
},
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
# ---- plumbing ----------------------------------------------------------
|
||||
|
||||
def _get(self, path: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
resp = self._client.get(path, params=params)
|
||||
except httpx.HTTPError as exc:
|
||||
raise SourceError(f"Todoist is unreachable: {exc}") from exc
|
||||
if resp.status_code in (401, 403):
|
||||
raise SourceError("Todoist rejected the API token.")
|
||||
if not resp.is_success:
|
||||
raise SourceError(f"Todoist GET {path} -> {resp.status_code}: {resp.text[:300]}")
|
||||
return resp.json() or {}
|
||||
|
||||
def _paginate(self, path: str, params: dict[str, Any]) -> Iterator[dict[str, Any]]:
|
||||
params = dict(params)
|
||||
while True:
|
||||
data = self._get(path, params)
|
||||
yield from data.get("results", [])
|
||||
cursor = data.get("next_cursor")
|
||||
if not cursor:
|
||||
return
|
||||
params["cursor"] = cursor
|
||||
|
||||
def _projects(self) -> dict[str, str]:
|
||||
return {
|
||||
project["id"]: project.get("name") or "(untitled project)"
|
||||
for project in self._paginate("/projects", {"limit": _PAGE_SIZE})
|
||||
if project.get("id")
|
||||
}
|
||||
|
||||
def _selected(self, project_id: str, project_name: str) -> bool:
|
||||
if not self._config.projects:
|
||||
return True
|
||||
wanted = set(self._config.projects)
|
||||
return bool(wanted & {project_id, project_name})
|
||||
|
||||
# ---- source API --------------------------------------------------------
|
||||
|
||||
def fetch(self) -> list[TodoItem]:
|
||||
projects = self._projects()
|
||||
query = self._config.filter_query.strip()
|
||||
if query:
|
||||
raw_tasks = self._paginate("/tasks/filter", {"query": query, "limit": _PAGE_SIZE})
|
||||
else:
|
||||
raw_tasks = self._paginate("/tasks", {"limit": _PAGE_SIZE})
|
||||
|
||||
items: list[TodoItem] = []
|
||||
for raw in raw_tasks:
|
||||
if raw.get("checked") or raw.get("is_deleted"):
|
||||
continue
|
||||
project_id = raw.get("project_id") or ""
|
||||
project_name = projects.get(project_id, "(unknown project)")
|
||||
if not self._selected(project_id, project_name):
|
||||
continue
|
||||
task_id = str(raw.get("id") or "")
|
||||
due = raw.get("due") or {}
|
||||
items.append(
|
||||
TodoItem(
|
||||
source=self.name,
|
||||
external_id=task_id,
|
||||
title=(raw.get("content") or "").strip() or "(no content)",
|
||||
description=(raw.get("description") or "").strip() or None,
|
||||
url=_TASK_URL.format(task_id=task_id) if task_id else None,
|
||||
project=project_name,
|
||||
priority=_PRIORITY_LABELS.get(raw.get("priority")),
|
||||
due_date=_parse_due(due),
|
||||
labels=list(raw.get("labels") or []),
|
||||
raw={"recurring": bool(due.get("is_recurring"))},
|
||||
)
|
||||
)
|
||||
return items
|
||||
|
||||
def check(self) -> str:
|
||||
projects = self._projects()
|
||||
return f"{len(projects)} Todoist project(s) visible"
|
||||
|
||||
def complete(self, item: TodoItem) -> bool:
|
||||
"""`POST /tasks/{id}/close` — the same verb the apps' checkbox uses."""
|
||||
if not self._config.writeback_done:
|
||||
return False
|
||||
try:
|
||||
resp = self._client.post(f"/tasks/{item.external_id}/close")
|
||||
except httpx.HTTPError as exc:
|
||||
raise SourceError(f"Todoist is unreachable: {exc}") from exc
|
||||
if not resp.is_success:
|
||||
raise SourceError(f"Todoist could not close the task: {resp.status_code}")
|
||||
return True
|
||||
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
|
||||
|
||||
def _parse_due(due: Any) -> date | None:
|
||||
if not isinstance(due, dict):
|
||||
return None
|
||||
raw = due.get("date")
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return date.fromisoformat(str(raw)[:10])
|
||||
except ValueError:
|
||||
return None
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Timezone helpers.
|
||||
|
||||
Everything is stored in UTC and *reported* in the user's timezone, so there is
|
||||
exactly one place that knows how to turn "now" into "which day does this count
|
||||
for" — here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
|
||||
def tz(name: str) -> ZoneInfo:
|
||||
try:
|
||||
return ZoneInfo(name)
|
||||
except Exception: # pragma: no cover - bad tz falls back rather than crashing
|
||||
return ZoneInfo("UTC")
|
||||
|
||||
|
||||
def now_utc() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def ensure_utc(moment: datetime | None) -> datetime | None:
|
||||
"""Attach UTC to a naive datetime.
|
||||
|
||||
SQLite has no timezone type, so a column declared `DateTime(timezone=True)`
|
||||
comes back naive from SQLite and aware from Postgres. Everything we store is
|
||||
UTC, so normalising on read keeps the arithmetic valid on both.
|
||||
"""
|
||||
if moment is None:
|
||||
return None
|
||||
return moment if moment.tzinfo is not None else moment.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def to_local(moment: datetime, tz_name: str) -> datetime:
|
||||
if moment.tzinfo is None:
|
||||
moment = moment.replace(tzinfo=timezone.utc)
|
||||
return moment.astimezone(tz(tz_name))
|
||||
|
||||
|
||||
def local_date(tz_name: str, moment: datetime | None = None) -> date:
|
||||
"""The day a moment belongs to for the user (the report's unit of grouping)."""
|
||||
return to_local(moment or now_utc(), tz_name).date()
|
||||
|
||||
|
||||
def local_time(tz_name: str, moment: datetime | None = None) -> time:
|
||||
return to_local(moment or now_utc(), tz_name).timetz().replace(tzinfo=None)
|
||||
|
||||
|
||||
def day_bounds_utc(day: date, tz_name: str) -> tuple[datetime, datetime]:
|
||||
"""The UTC half-open interval `[start, end)` covering a local calendar day."""
|
||||
zone = tz(tz_name)
|
||||
start = datetime.combine(day, time.min, tzinfo=zone)
|
||||
end = start + timedelta(days=1)
|
||||
return start.astimezone(timezone.utc), end.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def in_window(moment: time, start: time, end: time) -> bool:
|
||||
"""Whether a clock time is inside a window, handling windows that wrap midnight."""
|
||||
if start <= end:
|
||||
return start <= moment <= end
|
||||
return moment >= start or moment <= end
|
||||
|
||||
|
||||
def humanize_minutes(total_minutes: int | float) -> str:
|
||||
"""`95` -> `1h 35m`, `40` -> `40m`, `0` -> `0m`."""
|
||||
minutes = round(total_minutes)
|
||||
hours, rest = divmod(minutes, 60)
|
||||
if hours and rest:
|
||||
return f"{hours}h {rest}m"
|
||||
if hours:
|
||||
return f"{hours}h"
|
||||
return f"{rest}m"
|
||||
@@ -0,0 +1,40 @@
|
||||
[project]
|
||||
name = "kadence-backend"
|
||||
version = "0.1.0"
|
||||
description = "Pomodoro timer, todo aggregation, website/app blocking and daily Telegram reports."
|
||||
readme = "../README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"fastapi>=0.115",
|
||||
"uvicorn[standard]>=0.30",
|
||||
"sqlalchemy>=2.0",
|
||||
"psycopg[binary]>=3.2",
|
||||
"pydantic>=2.8",
|
||||
"pydantic-settings>=2.4",
|
||||
"httpx>=0.27",
|
||||
"beautifulsoup4>=4.12",
|
||||
"apscheduler>=3.10,<4",
|
||||
"python-dotenv>=1.0",
|
||||
"tzdata>=2024.1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=8.0", "ruff>=0.6"]
|
||||
|
||||
[project.scripts]
|
||||
kadence = "app.cli:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["app"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 90
|
||||
target-version = "py312"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["."]
|
||||
testpaths = ["tests"]
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Test fixtures.
|
||||
|
||||
The engine is built at import time from the environment, so the database URL has
|
||||
to be set before anything from `app` is imported — hence the env juggling here
|
||||
rather than in a fixture.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///" + tempfile.mktemp(suffix=".db"))
|
||||
os.environ["SCHEDULER_ENABLED"] = "false"
|
||||
os.environ["TELEGRAM_BOT_ENABLED"] = "false"
|
||||
# Keep the seeded settings empty so tests never reach a real Jira/Todoist.
|
||||
for name in (
|
||||
"JIRA_BASE_URL",
|
||||
"JIRA_EMAIL",
|
||||
"JIRA_API_TOKEN",
|
||||
"TODOIST_API_TOKEN",
|
||||
"GOOGLE_CLIENT_ID",
|
||||
"GOOGLE_CLIENT_SECRET",
|
||||
"GOOGLE_REFRESH_TOKEN",
|
||||
"LAUNDRY_SESSION",
|
||||
"TELEGRAM_BOT_TOKEN",
|
||||
"TELEGRAM_CHAT_ID",
|
||||
"SLACK_WEBHOOK_URL",
|
||||
):
|
||||
os.environ.pop(name, None)
|
||||
|
||||
from app.db import Base, SessionLocal, engine, init_db # noqa: E402
|
||||
from app.services import settings_service # noqa: E402
|
||||
from app.settings_schema import AppSettings # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def session():
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
init_db()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def settings(session) -> AppSettings:
|
||||
return settings_service.load_settings(session)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client():
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
init_db()
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
@@ -0,0 +1,99 @@
|
||||
"""The HTTP surface, driven the way the clients drive it."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def test_health_and_info(client):
|
||||
assert client.get("/api/health").json()["status"] == "ok"
|
||||
assert "auth_required" in client.get("/api/info").json()
|
||||
|
||||
|
||||
def test_a_whole_pomodoro_through_the_api(client):
|
||||
client.put("/api/settings", json={"pomodoro": {"daily_goal_pomodoros": 1}})
|
||||
task = client.post("/api/tasks", json={"title": "Write the API"}).json()
|
||||
|
||||
state = client.post("/api/timer/start", json={"kind": "work", "task_id": task["id"]}).json()
|
||||
assert state["current"]["kind"] == "work"
|
||||
assert state["current"]["task"]["title"] == "Write the API"
|
||||
|
||||
assert client.post("/api/timer/pause").json()["current"]["status"] == "paused"
|
||||
assert client.post("/api/timer/resume").json()["current"]["status"] == "running"
|
||||
assert client.post("/api/timer/extend", json={"minutes": 5}).json()["current"]["planned_seconds"] > 0
|
||||
|
||||
done = client.post("/api/timer/complete", json={"auto_next": False}).json()
|
||||
assert done["finished"]["status"] == "completed"
|
||||
assert done["completed_today"] == 1
|
||||
|
||||
marked = client.post(f"/api/tasks/{task['id']}/done", json={"done": True}).json()
|
||||
assert marked["task"]["is_done"] is True
|
||||
|
||||
report = client.get("/api/reports/daily").json()
|
||||
assert report["report"]["metrics"]["pomodoros"] == 1
|
||||
assert "Daily report" in report["telegram_preview"]
|
||||
|
||||
|
||||
def test_timer_errors_are_400_not_500(client):
|
||||
assert client.post("/api/timer/pause").status_code == 400
|
||||
assert client.post("/api/timer/complete", json={}).status_code == 400
|
||||
|
||||
|
||||
def test_task_crud(client):
|
||||
created = client.post("/api/tasks", json={"title": "Temporary", "priority": "p2"}).json()
|
||||
listing = client.get("/api/tasks").json()
|
||||
assert any(task["id"] == created["id"] for task in listing["tasks"])
|
||||
|
||||
patched = client.patch(f"/api/tasks/{created['id']}", json={"pinned": True}).json()
|
||||
assert patched["pinned"] is True
|
||||
|
||||
client.delete(f"/api/tasks/{created['id']}")
|
||||
assert all(task["id"] != created["id"] for task in client.get("/api/tasks").json()["tasks"])
|
||||
|
||||
|
||||
def test_refresh_reports_per_source_failures_without_failing(client):
|
||||
client.put(
|
||||
"/api/settings",
|
||||
json={"sources": {"todoist": {"enabled": True, "api_token": "definitely-invalid"}}},
|
||||
)
|
||||
result = client.post("/api/tasks/refresh").json()
|
||||
assert result["sources"], "the enabled source should be reported"
|
||||
assert result["sources"][0]["ok"] is False
|
||||
assert client.get("/api/tasks").status_code == 200
|
||||
|
||||
|
||||
def test_blocker_policy_and_events(client):
|
||||
client.put(
|
||||
"/api/settings",
|
||||
json={"blocker": {"enabled": True, "blocked_domains": ["youtube.com"]}},
|
||||
)
|
||||
assert client.get("/api/blocker/policy").json()["active"] is False
|
||||
|
||||
client.post("/api/timer/start", json={"kind": "work"})
|
||||
policy = client.get("/api/blocker/policy").json()
|
||||
assert policy["active"] is True
|
||||
assert "youtube.com" in policy["domains"]
|
||||
|
||||
client.post("/api/blocker/events", json={"kind": "domain", "target": "youtube.com"})
|
||||
events = client.get("/api/blocker/events").json()
|
||||
assert events["total"] == 1
|
||||
|
||||
|
||||
def test_settings_round_trip_masks_secrets(client):
|
||||
client.put("/api/settings", json={"sources": {"todoist": {"api_token": "s3cret"}}})
|
||||
body = client.get("/api/settings").json()
|
||||
assert body["settings"]["sources"]["todoist"]["api_token"] == "••••••••"
|
||||
assert body["defaults"]["blocked_domains"]
|
||||
|
||||
# Posting the mask back must not wipe the token.
|
||||
client.put("/api/settings", json=body["settings"])
|
||||
again = client.get("/api/settings").json()
|
||||
assert again["settings"]["sources"]["todoist"]["api_token"] == "••••••••"
|
||||
|
||||
|
||||
def test_invalid_settings_are_rejected(client):
|
||||
assert client.put("/api/settings", json={"pomodoro": {"work_minutes": 0}}).status_code == 422
|
||||
assert client.put("/api/settings", json={"timezone": "Mars/Olympus"}).status_code == 422
|
||||
|
||||
|
||||
def test_notification_test_endpoint_falls_back_to_the_log(client):
|
||||
body = client.post("/api/notifications/test", json={}).json()
|
||||
assert body["deliveries"][0]["channel"] == "log"
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Blocking policy: when it is on, and what is on the list."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.services import blocker_service, pomodoro, settings_service
|
||||
|
||||
|
||||
def test_disabled_blocker_blocks_nothing(session, settings):
|
||||
pomodoro.start(session, settings, kind="work")
|
||||
policy = blocker_service.policy(session, settings)
|
||||
assert policy["active"] is False and policy["domains"] == []
|
||||
|
||||
|
||||
def test_during_work_mode_follows_the_timer(session, settings):
|
||||
settings.blocker.enabled = True
|
||||
settings.blocker.blocked_domains = ["youtube.com"]
|
||||
settings_service.save_settings(session, settings)
|
||||
|
||||
assert blocker_service.policy(session, settings)["active"] is False
|
||||
|
||||
pomodoro.start(session, settings, kind="work")
|
||||
policy = blocker_service.policy(session, settings)
|
||||
assert policy["active"] is True
|
||||
assert "www.youtube.com" in policy["domains"]
|
||||
|
||||
pomodoro.complete(session, settings, auto_next=False)
|
||||
assert blocker_service.policy(session, settings)["active"] is False
|
||||
|
||||
|
||||
def test_breaks_do_not_block_unless_asked(session, settings):
|
||||
settings.blocker.enabled = True
|
||||
settings_service.save_settings(session, settings)
|
||||
pomodoro.start(session, settings, kind="short_break")
|
||||
assert blocker_service.policy(session, settings)["active"] is False
|
||||
|
||||
settings.blocker.block_during_breaks = True
|
||||
settings_service.save_settings(session, settings)
|
||||
assert blocker_service.policy(session, settings)["active"] is True
|
||||
|
||||
|
||||
def test_always_mode_ignores_the_timer(session, settings):
|
||||
settings.blocker.enabled = True
|
||||
settings.blocker.mode = "always"
|
||||
settings_service.save_settings(session, settings)
|
||||
assert blocker_service.policy(session, settings)["active"] is True
|
||||
|
||||
|
||||
def test_allowlist_wins_over_the_blocklist(session, settings):
|
||||
settings.blocker.blocked_domains = ["youtube.com"]
|
||||
settings.blocker.allowed_domains = ["m.youtube.com"]
|
||||
hosts = blocker_service.expand_domains(settings)
|
||||
assert "youtube.com" in hosts
|
||||
assert "m.youtube.com" not in hosts
|
||||
|
||||
|
||||
def test_events_are_grouped_for_the_report(session, settings):
|
||||
for _ in range(3):
|
||||
blocker_service.record_event(session, settings, kind="domain", target="YouTube.com")
|
||||
blocker_service.record_event(session, settings, kind="app", target="steam")
|
||||
summary = blocker_service.events_for_day(session, settings)
|
||||
assert summary["total"] == 4
|
||||
assert summary["by_target"][0] == {"target": "youtube.com", "count": 3}
|
||||
@@ -0,0 +1,93 @@
|
||||
"""The timer's state machine."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models import PomodoroSession
|
||||
from app.services import pomodoro, settings_service
|
||||
|
||||
|
||||
def test_start_sets_duration_from_settings(session, settings):
|
||||
row = pomodoro.start(session, settings, kind="work")
|
||||
assert row.planned_seconds == settings.pomodoro.work_minutes * 60
|
||||
assert row.status == "running"
|
||||
assert pomodoro.remaining_seconds(row) <= row.planned_seconds
|
||||
|
||||
|
||||
def test_minutes_override_wins(session, settings):
|
||||
row = pomodoro.start(session, settings, kind="work", minutes=3)
|
||||
assert row.planned_seconds == 180
|
||||
|
||||
|
||||
def test_starting_again_abandons_the_previous_interval(session, settings):
|
||||
first = pomodoro.start(session, settings, kind="work")
|
||||
pomodoro.start(session, settings, kind="work")
|
||||
session.refresh(first)
|
||||
assert first.status == "abandoned"
|
||||
assert len(session.query(PomodoroSession).all()) == 2
|
||||
|
||||
|
||||
def test_pause_counts_an_interruption_and_freezes_the_clock(session, settings):
|
||||
pomodoro.start(session, settings, kind="work")
|
||||
row = pomodoro.pause(session, settings)
|
||||
assert row.status == "paused"
|
||||
assert row.interruptions == 1
|
||||
frozen = pomodoro.elapsed_seconds(row)
|
||||
assert pomodoro.elapsed_seconds(row) == frozen
|
||||
|
||||
|
||||
def test_pause_can_be_switched_off(session, settings):
|
||||
settings.pomodoro.allow_pause = False
|
||||
settings_service.save_settings(session, settings)
|
||||
pomodoro.start(session, settings, kind="work")
|
||||
with pytest.raises(pomodoro.TimerError):
|
||||
pomodoro.pause(session, settings)
|
||||
|
||||
|
||||
def test_long_break_arrives_on_the_configured_cycle(session, settings):
|
||||
settings.pomodoro.cycles_before_long_break = 2
|
||||
settings.pomodoro.auto_start_breaks = False
|
||||
settings_service.save_settings(session, settings)
|
||||
|
||||
pomodoro.start(session, settings, kind="work")
|
||||
pomodoro.complete(session, settings, auto_next=False)
|
||||
assert pomodoro.next_kind(session, settings) == "short_break"
|
||||
|
||||
pomodoro.start(session, settings, kind="short_break")
|
||||
pomodoro.complete(session, settings, auto_next=False)
|
||||
pomodoro.start(session, settings, kind="work")
|
||||
pomodoro.complete(session, settings, auto_next=False)
|
||||
assert pomodoro.next_kind(session, settings) == "long_break"
|
||||
|
||||
|
||||
def test_auto_start_breaks_rolls_straight_on(session, settings):
|
||||
settings.pomodoro.auto_start_breaks = True
|
||||
settings_service.save_settings(session, settings)
|
||||
pomodoro.start(session, settings, kind="work")
|
||||
finished, follow_on = pomodoro.complete(session, settings)
|
||||
assert finished.status == "completed"
|
||||
assert follow_on is not None and follow_on.kind == "short_break"
|
||||
|
||||
|
||||
def test_strict_mode_demands_a_reason(session, settings):
|
||||
settings.pomodoro.strict_mode = True
|
||||
settings_service.save_settings(session, settings)
|
||||
pomodoro.start(session, settings, kind="work")
|
||||
with pytest.raises(pomodoro.TimerError):
|
||||
pomodoro.abandon(session, settings)
|
||||
row = pomodoro.abandon(session, settings, reason="fire alarm")
|
||||
assert row.status == "abandoned" and row.abandon_reason == "fire alarm"
|
||||
|
||||
|
||||
def test_reap_closes_intervals_left_running(session, settings):
|
||||
from datetime import timedelta
|
||||
|
||||
from app.models import utcnow
|
||||
|
||||
row = pomodoro.start(session, settings, kind="work")
|
||||
row.started_at = utcnow() - timedelta(hours=9)
|
||||
session.commit()
|
||||
assert pomodoro.reap_stale(session, settings) == 1
|
||||
session.refresh(row)
|
||||
assert row.status == "abandoned"
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Metrics, achievements and the rendered report."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from app.models import utcnow
|
||||
from app.services import pomodoro, report, settings_service, stats, tasks_service
|
||||
from app.timeutil import local_date
|
||||
|
||||
|
||||
def _run_pomodoros(session, settings, count: int, *, minutes: int = 25) -> None:
|
||||
"""Record `count` finished work intervals, backdated so they do not overlap."""
|
||||
for index in range(count):
|
||||
row = pomodoro.start(session, settings, kind="work", minutes=minutes)
|
||||
start = utcnow() - timedelta(minutes=(count - index) * (minutes + 5))
|
||||
row.started_at = start
|
||||
row.ended_at = start + timedelta(minutes=minutes)
|
||||
row.actual_seconds = minutes * 60
|
||||
row.status = "completed"
|
||||
session.commit()
|
||||
|
||||
|
||||
def test_day_metrics_counts_what_happened(session, settings):
|
||||
_run_pomodoros(session, settings, 3)
|
||||
metrics = stats.day_metrics(session, settings, local_date(settings.timezone))
|
||||
assert metrics["pomodoros"] == 3
|
||||
assert metrics["focus_minutes"] == 75
|
||||
assert metrics["goal"] == settings.pomodoro.daily_goal_pomodoros
|
||||
assert metrics["first_start"] and metrics["last_end"]
|
||||
|
||||
|
||||
def test_longest_stretch_joins_adjacent_pomodoros(session, settings):
|
||||
_run_pomodoros(session, settings, 3, minutes=25)
|
||||
metrics = stats.day_metrics(session, settings, local_date(settings.timezone))
|
||||
# 25-minute intervals five minutes apart are one unbroken stretch.
|
||||
assert metrics["longest_stretch_minutes"] >= 75
|
||||
|
||||
|
||||
def test_streak_survives_an_empty_today(session, settings):
|
||||
today = local_date(settings.timezone)
|
||||
_run_pomodoros(session, settings, 1)
|
||||
|
||||
# Move the work to yesterday and leave today empty.
|
||||
from app.models import PomodoroSession
|
||||
|
||||
for row in session.query(PomodoroSession).all():
|
||||
row.local_date = today - timedelta(days=1)
|
||||
session.commit()
|
||||
|
||||
streak = stats.streak(session, settings, until=today)
|
||||
assert streak["current"] == 1
|
||||
|
||||
|
||||
def test_report_payload_has_the_pieces_the_ui_draws(session, settings):
|
||||
settings.pomodoro.daily_goal_pomodoros = 2
|
||||
settings_service.save_settings(session, settings)
|
||||
_run_pomodoros(session, settings, 2)
|
||||
task = tasks_service.create_local_task(session, title="Ship the thing")
|
||||
tasks_service.complete_task(session, settings, task.id)
|
||||
|
||||
payload = report.build_daily(session, settings, local_date(settings.timezone))
|
||||
assert payload["metrics"]["pomodoros"] == 2
|
||||
assert payload["metrics"]["tasks_completed_count"] == 1
|
||||
assert any(item["code"] == "goal_met" for item in payload["achievements"])
|
||||
assert payload["fun_facts"]
|
||||
assert 0 <= payload["score"] <= 100
|
||||
|
||||
|
||||
def test_rendered_report_escapes_borrowed_text(session, settings):
|
||||
task = tasks_service.create_local_task(session, title="Fix <b>&</b> ship")
|
||||
tasks_service.complete_task(session, settings, task.id)
|
||||
payload = report.build_daily(session, settings, local_date(settings.timezone))
|
||||
text = report.render_daily_telegram(payload, settings)
|
||||
assert "Fix <b>&</b> ship" in text
|
||||
|
||||
|
||||
def test_russian_report_renders(session, settings):
|
||||
settings.notifications.report.language = "ru"
|
||||
settings.notifications.report.tone = "drill_sergeant"
|
||||
settings_service.save_settings(session, settings)
|
||||
_run_pomodoros(session, settings, 1)
|
||||
payload = report.build_daily(session, settings, local_date(settings.timezone))
|
||||
text = report.render_daily_telegram(payload, settings)
|
||||
assert "Отчёт за день" in text
|
||||
|
||||
|
||||
def test_weekly_review_covers_seven_days(session, settings):
|
||||
_run_pomodoros(session, settings, 2)
|
||||
payload = report.build_weekly(session, settings, local_date(settings.timezone))
|
||||
assert len(payload["days"]) == 7
|
||||
assert payload["pomodoros"] == 2
|
||||
assert "Weekly review" in report.render_weekly_telegram(payload, settings)
|
||||
|
||||
|
||||
def test_progress_bar_is_proportional():
|
||||
assert report.progress_bar(0, 8) == "░" * 10
|
||||
assert report.progress_bar(8, 8) == "█" * 10
|
||||
assert report.progress_bar(4, 8).count("█") == 5
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Settings: deep merge, secret masking, and the environment seed."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.services import settings_service
|
||||
from app.settings_schema import SECRET_MASK
|
||||
|
||||
|
||||
def test_patch_merges_without_clobbering_siblings(session):
|
||||
settings_service.apply_patch(session, {"pomodoro": {"work_minutes": 50}})
|
||||
updated = settings_service.apply_patch(
|
||||
session, {"pomodoro": {"daily_goal_pomodoros": 12}}
|
||||
)
|
||||
assert updated.pomodoro.work_minutes == 50
|
||||
assert updated.pomodoro.daily_goal_pomodoros == 12
|
||||
assert updated.pomodoro.short_break_minutes == 5 # untouched default
|
||||
|
||||
|
||||
def test_secrets_are_masked_on_the_way_out(session):
|
||||
settings_service.apply_patch(
|
||||
session,
|
||||
{"sources": {"todoist": {"enabled": True, "api_token": "super-secret"}}},
|
||||
)
|
||||
masked = settings_service.mask_secrets(
|
||||
settings_service.load_settings(session).model_dump(mode="json")
|
||||
)
|
||||
assert masked["sources"]["todoist"]["api_token"] == SECRET_MASK
|
||||
|
||||
|
||||
def test_sending_the_mask_back_keeps_the_stored_secret(session):
|
||||
settings_service.apply_patch(
|
||||
session, {"sources": {"todoist": {"api_token": "super-secret"}}}
|
||||
)
|
||||
settings_service.apply_patch(
|
||||
session,
|
||||
{"sources": {"todoist": {"api_token": SECRET_MASK, "enabled": True}}},
|
||||
)
|
||||
settings = settings_service.load_settings(session)
|
||||
assert settings.sources.todoist.api_token == "super-secret"
|
||||
assert settings.sources.todoist.enabled is True
|
||||
|
||||
|
||||
def test_a_real_new_secret_replaces_the_old_one(session):
|
||||
settings_service.apply_patch(session, {"sources": {"todoist": {"api_token": "old"}}})
|
||||
settings_service.apply_patch(session, {"sources": {"todoist": {"api_token": "new"}}})
|
||||
assert settings_service.load_settings(session).sources.todoist.api_token == "new"
|
||||
|
||||
|
||||
def test_environment_seeds_the_sources(monkeypatch):
|
||||
monkeypatch.setenv("JIRA_BASE_URL", "https://example.atlassian.net/")
|
||||
monkeypatch.setenv("JIRA_EMAIL", "me@example.com")
|
||||
monkeypatch.setenv("JIRA_API_TOKEN", "token")
|
||||
monkeypatch.setenv("TODOIST_API_TOKEN", "todoist-token")
|
||||
monkeypatch.setenv("TODOIST_PROJECTS", "Inbox, Учёба")
|
||||
from app.config import env_settings
|
||||
|
||||
env_settings.cache_clear()
|
||||
try:
|
||||
settings = settings_service.settings_from_env()
|
||||
finally:
|
||||
env_settings.cache_clear()
|
||||
|
||||
assert settings.sources.jira.enabled
|
||||
assert settings.sources.jira.base_url == "https://example.atlassian.net"
|
||||
assert settings.sources.todoist.projects == ["Inbox", "Учёба"]
|
||||
|
||||
|
||||
def test_listeners_fire_on_save(session):
|
||||
seen = []
|
||||
settings_service.on_change(lambda new: seen.append(new.pomodoro.work_minutes))
|
||||
settings_service.apply_patch(session, {"pomodoro": {"work_minutes": 33}})
|
||||
assert 33 in seen
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Source mapping: the parts that work without a network."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from app.settings_schema import JiraSourceSettings, LaundrySourceSettings
|
||||
from app.sources.jira import JiraSource, start_date_clause
|
||||
from app.sources.laundry import parse_cookie_header, parse_my_slots
|
||||
|
||||
JIRA_CONFIG = JiraSourceSettings(
|
||||
enabled=True,
|
||||
base_url="https://example.atlassian.net",
|
||||
email="me@example.com",
|
||||
api_token="token",
|
||||
)
|
||||
|
||||
|
||||
def test_start_date_clause_handles_custom_fields_and_names():
|
||||
assert start_date_clause("customfield_10015") == "cf[10015]"
|
||||
assert start_date_clause("Start date") == '"Start date"'
|
||||
|
||||
|
||||
def test_jira_jql_includes_the_extra_filter():
|
||||
source = JiraSource(JIRA_CONFIG.model_copy(update={"extra_jql": "assignee = currentUser()"}))
|
||||
jql = source._jql()
|
||||
assert "statusCategory != Done" in jql
|
||||
assert "assignee = currentUser()" in jql
|
||||
source.close()
|
||||
|
||||
|
||||
def test_jira_issue_maps_to_a_todo_item():
|
||||
source = JiraSource(JIRA_CONFIG)
|
||||
item = source._to_item(
|
||||
{
|
||||
"key": "ABC-1",
|
||||
"fields": {
|
||||
"summary": "Fix the thing",
|
||||
"duedate": "2026-10-20",
|
||||
"customfield_10015": "2026-10-15",
|
||||
"status": {"name": "In Progress", "statusCategory": {"key": "indeterminate"}},
|
||||
"priority": {"name": "High"},
|
||||
"project": {"name": "Platform"},
|
||||
"labels": ["backend"],
|
||||
},
|
||||
}
|
||||
)
|
||||
source.close()
|
||||
assert item.external_id == "ABC-1"
|
||||
assert item.title == "Fix the thing"
|
||||
assert item.url == "https://example.atlassian.net/browse/ABC-1"
|
||||
assert item.due_date == date(2026, 10, 20)
|
||||
assert item.start_date == date(2026, 10, 15)
|
||||
assert item.priority == "High"
|
||||
assert item.is_done is False
|
||||
assert item.days_overdue(date(2026, 10, 22)) == 2
|
||||
|
||||
|
||||
def test_cookie_header_accepts_both_forms():
|
||||
assert parse_cookie_header("abc123") == {"sessionid": "abc123"}
|
||||
parsed = parse_cookie_header("csrftoken=x; sessionid=abc123")
|
||||
assert parsed["sessionid"] == "abc123"
|
||||
|
||||
|
||||
LAUNDRY_HTML = """
|
||||
<div class="date-machines" id="Чт, 15 окт.">
|
||||
<div class="machines">
|
||||
<div class="floor-title">Этаж 1</div>
|
||||
<div class="machine">
|
||||
<div class="machine-title">Машинка 3</div>
|
||||
<div class="period my finished"><div>09:00-12:00</div></div>
|
||||
<div class="period my current"><div>18:00-21:00</div></div>
|
||||
<div class="period"><div>21:00-00:00</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
def test_laundry_grid_yields_only_my_upcoming_slots():
|
||||
label, slots = parse_my_slots(LAUNDRY_HTML)
|
||||
assert label == "Чт, 15 окт."
|
||||
assert len(slots) == 2
|
||||
upcoming = [slot for slot in slots if slot.is_upcoming]
|
||||
assert len(upcoming) == 1
|
||||
assert upcoming[0].time_range == "18:00-21:00"
|
||||
assert upcoming[0].machine == "Машинка 3"
|
||||
assert upcoming[0].location == "Этаж 1"
|
||||
|
||||
|
||||
def test_laundry_config_requires_a_session_cookie():
|
||||
from app.sources.base import SourceError
|
||||
|
||||
try:
|
||||
from app.sources.laundry import LaundrySource
|
||||
|
||||
LaundrySource(LaundrySourceSettings(enabled=True, session_cookie=""))
|
||||
except SourceError:
|
||||
return
|
||||
raise AssertionError("expected a SourceError")
|
||||
@@ -0,0 +1,71 @@
|
||||
"""The Telegram commands — the chat is a remote control, so it needs testing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.services import settings_service, tasks_service, telegram_bot
|
||||
|
||||
|
||||
def test_unknown_input_is_ignored(session):
|
||||
assert telegram_bot.handle_text("hello there") is None
|
||||
assert telegram_bot.handle_text("/nonsense") is None
|
||||
|
||||
|
||||
def test_help_lists_the_verbs(session):
|
||||
reply = telegram_bot.handle_text("/help")
|
||||
assert "/start_work" in reply and "/report" in reply
|
||||
|
||||
|
||||
def test_start_pause_resume_and_status(session):
|
||||
assert "started" in telegram_bot.handle_text("/start_work 10").lower()
|
||||
status = telegram_bot.handle_text("/status")
|
||||
assert "work" in status.lower() or "focus" in status.lower()
|
||||
assert "Paused" in telegram_bot.handle_text("/pause")
|
||||
assert "Resumed" in telegram_bot.handle_text("/resume")
|
||||
assert "Stopped" in telegram_bot.handle_text("/stop")
|
||||
|
||||
|
||||
def test_stop_without_a_timer_explains_itself(session):
|
||||
assert "No interval is running" in telegram_bot.handle_text("/stop")
|
||||
|
||||
|
||||
def test_today_numbers_tasks_and_done_closes_them(session):
|
||||
settings_service.load_settings(session)
|
||||
first = tasks_service.create_local_task(session, title="Alpha task")
|
||||
tasks_service.create_local_task(session, title="Beta task")
|
||||
|
||||
listing = telegram_bot.handle_text("/today")
|
||||
assert "Alpha task" in listing and "<b>1.</b>" in listing
|
||||
|
||||
reply = telegram_bot.handle_text("/done 1")
|
||||
assert "Alpha task" in reply
|
||||
session.expire_all()
|
||||
assert session.get(type(first), first.id).is_done is True
|
||||
|
||||
assert "No task with that number" in telegram_bot.handle_text("/done 99")
|
||||
|
||||
|
||||
def test_done_without_a_listing_asks_for_one(session):
|
||||
telegram_bot._INDEX.clear()
|
||||
assert "/today" in telegram_bot.handle_text("/done 1")
|
||||
|
||||
|
||||
def test_report_and_streak_render(session):
|
||||
assert "Daily report" in telegram_bot.handle_text("/report")
|
||||
assert "Streak" in telegram_bot.handle_text("/streak")
|
||||
assert "Weekly review" in telegram_bot.handle_text("/week")
|
||||
|
||||
|
||||
def test_block_command_describes_the_policy(session):
|
||||
settings = settings_service.load_settings(session)
|
||||
settings.blocker.enabled = True
|
||||
settings.blocker.blocked_domains = ["youtube.com"]
|
||||
settings_service.save_settings(session, settings)
|
||||
reply = telegram_bot.handle_text("/block")
|
||||
assert "Blocker" in reply and "Domains" in reply
|
||||
|
||||
|
||||
def test_russian_replies_when_configured(session):
|
||||
settings = settings_service.load_settings(session)
|
||||
settings.notifications.report.language = "ru"
|
||||
settings_service.save_settings(session, settings)
|
||||
assert "помидорку" in telegram_bot.handle_text("/help")
|
||||
@@ -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
|
||||
```
|
||||
Executable
+68
@@ -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"
|
||||
Executable
+211
@@ -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())
|
||||
@@ -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>
|
||||
@@ -0,0 +1,4 @@
|
||||
"""Native desktop client for Kadence (PyQt6 + QML, Material style)."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
APP_ID = "org.kadence.Desktop"
|
||||
@@ -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())
|
||||
@@ -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}"
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
Executable
+35
@@ -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."
|
||||
@@ -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
|
||||
@@ -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 |
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -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>
|
||||
Generated
+2572
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user