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