`runs-on: ubuntu-latest` stays what it is everywhere else — a disposable container — and `docker-compose.ci.yml` is now what makes that true: the label maps to `catthehacker/ubuntu:act-24.04`, job containers share the runner's network so the cache server is reachable at all, and the host's docker socket is passed through (`docker_host: automount`) so the image workflow can build. The failure this is replacing is worth naming, because the symptom does not point at it: with a **host**-mode runner answering the same label, every job runs directly on the server as the runner's own user, so the workflows' `apt-get install` steps fail on permissions — and had they succeeded, CI would have been installing packages on a host. A workflow cannot tell which kind of runner it got; only the label map can say. `docs/gitea-runner.md` is the separate guide: why docker mode, the prerequisites including Gitea's two `app.ini` settings, the install, how to remove a host-mode runner (and what to check before you do, since a runner is shared), how to verify a job really is in a container, why labels cannot be changed without re-registering, and a table of the failures with their causes. Also `workflow_dispatch` on the test workflow, so a fresh runner can be proved from the UI without inventing a commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
373 lines
19 KiB
Markdown
373 lines
19 KiB
Markdown
# 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.
|
||
|
||
Four 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 agent** | A **native Qt/QML** tray agent: D-Bus, real notifications, a floating mini timer, and the part that actually blocks websites and apps | Your machine |
|
||
| **Android app** | A native Compose client for the Nothing Phone (3): the interval as an Android 16 Live Update, and the countdown on the rear Glyph Matrix | Your phone |
|
||
|
||
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.
|
||
* **Todoist** — every open task, or your own filter query.
|
||
* **Google Tasks** — every incomplete task, including undated ones.
|
||
* **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.
|
||
|
||
**You cannot tick a task off here.** Completion belongs to the tool the task
|
||
lives in — close it in Jira, Todoist or Google Tasks and the next refresh
|
||
notices (an item that comes back done, or that a healthy source stops returning
|
||
at all, is credited to that day). Two places to mark something done is two
|
||
places to be wrong.
|
||
|
||
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 — and nothing is ever counted as closed on a source that failed. 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 and are edited in the web app; the native desktop
|
||
agent enforces them on your machine:
|
||
|
||
* **Websites** — `/etc/hosts` is rewritten through a tiny privileged helper under
|
||
polkit, and the masks a hosts file cannot express are answered by a local DNS
|
||
stub the helper points `systemd-resolved` at, for the zones it can route. No
|
||
browser extension, no proxy, works for every app on the machine, and you can
|
||
audit the hosts region with `cat /etc/hosts`.
|
||
* **Masks** — `youtube.com` blocks the site plus `www.` and `m.`; `*.google.com`
|
||
blocks the bare domain plus every subdomain in your expansion list — add a bare
|
||
`*` to that list and "every" means every, answered by the DNS stub rather than by
|
||
guessed hostnames. A hosts file has no wildcards at all, so `*gram*` is reported
|
||
honestly as unenforceable on the machine rather than silently ignored.
|
||
Exceptions read exactly like blocks: a plain one covers its subdomains
|
||
(`youtube.com` allowed spares `music.youtube.com`, the same way blocking it
|
||
catches them), and a masked one is a mask — so `*.google.com` blocked plus
|
||
`*.docs.google.com` allowed does what you would expect, and `docs.*` allowed
|
||
is how you spare something narrower than a whole subtree.
|
||
* **Applications** — `/proc` is watched and blocked apps are closed (or just
|
||
reported, if you prefer a warning). Entries here are globs as well: `steam*`,
|
||
`*discord*`, matched against the process name and its command line.
|
||
* **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 desktop agent:
|
||
|
||
```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
|
||
```
|
||
|
||
It has no application window: it sits in the tray, and the countdown is a
|
||
floating pill you can put in a corner. Point it at the backend from the tray's
|
||
**Settings…** (or `kadence-desktop --settings`); 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 |
|
||
| `AUTH_USERNAME`, `AUTH_PASSWORD` | closes the **browser UI** behind a login; see [Going public](#going-public) |
|
||
| `TELEGRAM_OWNER_ID`, `TELEGRAM_BOT_USERNAME` | lets that same browser login happen via the Telegram Login Widget |
|
||
|
||
---
|
||
|
||
## 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 agent│──/api──│ • scheduler (briefing, │
|
||
│ Qt/QML, D-Bus│ │ report, nudges) │───────▶ Telegram ⇄ you
|
||
│ hosts+DNS+ │ └───────────────────────────────┘
|
||
│ /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. 73 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).
|
||
* **The look** — warm paper, ink type, hairline rules instead of boxes and
|
||
shadows, Fraunces for figures and headings, Inter for everything else, one
|
||
ember accent. The desktop agent's `Theme` singleton carries the same tokens as
|
||
the web app's `tokens.ts`, so the clients are one product. Fraunces has no
|
||
tabular figures, so every countdown set in it — the desktop pill, the phone's
|
||
dial — lays each glyph on a fixed advance rather than letting the clock jitter. Chart colours
|
||
come from a separate, validated data palette: the brand is warm, but a chart
|
||
has to stay readable for colour-blind readers in both themes.
|
||
* `desktop/` — the native agent: tray, mini timer, one settings dialog, and the
|
||
enforcement. There is deliberately no window with pages in it — lists, rules
|
||
and reports are the web app's job. See [`desktop/README.md`](desktop/README.md).
|
||
* `android/` — the phone client. Everything with interesting behaviour lives in a
|
||
plain-JVM `:core` module, so 274 tests run on a laptop with nothing but a JDK. See
|
||
[`android/README.md`](android/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-test # desktop app test suite (uses the backend venv)
|
||
make desktop-install # menu entry + icon
|
||
make helper-install # the privileged blocking helper
|
||
make report # build and send today's report right now
|
||
make android-install # build and install the phone client
|
||
make lint # ruff, the same invocation CI uses
|
||
make ci-test # every suite, with no venv assumed
|
||
make android-release # the release APK
|
||
make ci-runner # start the Gitea Actions runner
|
||
make deploy # push master and redeploy kadence.timka.su
|
||
```
|
||
|
||
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
|
||
python -m app.cli set-password # set/rotate the web login password (prompts, never argv)
|
||
```
|
||
|
||
---
|
||
|
||
## Continuous integration
|
||
|
||
Gitea Actions, in `.gitea/workflows/`:
|
||
|
||
| Workflow | When | What |
|
||
| --- | --- | --- |
|
||
| `test.yml` | push to master, every PR | backend (73), desktop (47), `:core` (193), and the web app's typecheck + build — four parallel jobs, none of which may need a running backend |
|
||
| `lint.yml` | push to master, every PR | `ruff check` over `backend/` and `desktop/`, with the rule set named in `backend/pyproject.toml` |
|
||
| `docker.yml` | push to master, `v*` tags | builds the image, starts it against SQLite and waits for `/api/health`, then pushes `git.timka.su/timka123/kadence:latest` and `:<short-sha>` |
|
||
| `android-release.yml` | push to master, `v*` tags | `:app`'s suite (81, on the JVM), then the release APK — signed from secrets, with `apksigner verify` printing the certificate it actually used |
|
||
|
||
**Nothing runs until a docker-mode runner is registered** —
|
||
[`docs/gitea-runner.md`](docs/gitea-runner.md) is the guide, and
|
||
`docker-compose.ci.yml` + `deploy/act-runner.yaml` are the two files it installs.
|
||
Short version, on the runner host:
|
||
|
||
```bash
|
||
mkdir -p /opt/gitea-runner && cd /opt/gitea-runner
|
||
cp …/docker-compose.ci.yml docker-compose.yml && cp …/deploy/act-runner.yaml config.yaml
|
||
printf 'GITEA_RUNNER_REGISTRATION_TOKEN=%s\n' "<token>" > .env && chmod 600 .env
|
||
docker compose up -d
|
||
```
|
||
|
||
`runs-on: ubuntu-latest` means what it means on GitHub: a disposable container,
|
||
here `catthehacker/ubuntu:act-24.04` — chosen because the jobs need a `node` in the
|
||
image (Gitea's javascript actions run *inside* the job container) and Python 3.12,
|
||
which the backend's `requires-python` insists on. A **host-mode** runner answering
|
||
the same label is the one thing that breaks all of this: the job then runs on the
|
||
machine as the runner's user, where `apt-get install` either fails or installs
|
||
packages on your server. The guide covers removing one.
|
||
|
||
Secrets, all under *Repository → Settings → Actions → Secrets*:
|
||
|
||
| Secret | For |
|
||
| --- | --- |
|
||
| `REGISTRY_USER`, `REGISTRY_TOKEN` | pushing to the Gitea registry (a token with `package:write`) |
|
||
| `KADENCE_KEYSTORE_BASE64` | `base64 -w0 your.keystore` |
|
||
| `KADENCE_KEYSTORE_PASSWORD`, `KADENCE_KEY_ALIAS`, `KADENCE_KEY_PASSWORD` | the key inside it |
|
||
|
||
With no keystore secret the APK job still runs and still produces an artifact —
|
||
debug-signed, which is fine for installing on your own phone and no use for
|
||
anything else. With one, `KADENCE_REQUIRE_SIGNING` makes a missing or misnamed
|
||
secret **fail the build**: the Gradle config otherwise falls back to the debug key
|
||
without a word, and a debug-signed "release" installs happily and can then never
|
||
be upgraded by a properly signed one.
|
||
|
||
The Glyph Matrix AAR is deliberately not fetched in CI — its licence forbids
|
||
redistribution, and the build compiles the `noglyph` source set without it, so a
|
||
CI APK has an inert rear display. That is also the correct behaviour on any phone
|
||
that is not a Nothing.
|
||
|
||
---
|
||
|
||
## Going public
|
||
|
||
Kadence is still single-user: `AUTH_USERNAME`/`AUTH_PASSWORD` and
|
||
`TELEGRAM_OWNER_ID` all name *you*, not a user table. What they do is close the
|
||
**browser UI** to everyone else once the backend is reachable beyond your own
|
||
machine — `API_TOKEN` already protected the raw API; this is the same idea with
|
||
a login screen instead of a header the desktop app fills in for you.
|
||
|
||
1. **Password.** Set `AUTH_PASSWORD` (and optionally `AUTH_USERNAME`, default
|
||
`timka`) in `.env` and restart — the hash is seeded into the database on
|
||
first boot, the plaintext is never stored. A browser now has to sign in at
|
||
`/`; the desktop app is unaffected, it keeps sending `X-API-Token`. Rotate
|
||
the password later from **Settings → Access** (while signed in) or by
|
||
running `make set-password` on the server — the UI never accepts a new
|
||
password over an unauthenticated connection.
|
||
2. **Telegram login, in addition** (optional). You already have the bot token
|
||
(`TELEGRAM_BOT_TOKEN`) for notifications; login reuses it:
|
||
* DM [@userinfobot](https://t.me/userinfobot) for your numeric Telegram id
|
||
→ `TELEGRAM_OWNER_ID`.
|
||
* Tell [@BotFather](https://t.me/BotFather) `/setdomain` and give it this
|
||
site's **HTTPS** domain — the Login Widget refuses to render anywhere else.
|
||
* Set `TELEGRAM_BOT_USERNAME` (no `@`) in `.env`, or fill it in under
|
||
**Settings → Access** once the UI is reachable.
|
||
* Restart. The login screen grows a "Log in with Telegram" button under the
|
||
password form.
|
||
3. Either credential alone is enough to close the UI; set both and either one
|
||
signs you in. With neither set, the browser stays as open as the bare API
|
||
always was — nothing changes until you opt in.
|
||
|
||
---
|
||
|
||
## Security notes
|
||
|
||
* The desktop app never runs as root. The only privileged code is
|
||
`desktop/helper/kadence-helper` (~250 lines) and it has exactly two jobs: 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 — and it writes one drop-in for `systemd-resolved`
|
||
pointing the routable zones at the unprivileged DNS stub, which it removes again
|
||
before the stub stops. 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. The password is never stored at
|
||
all — only a salted PBKDF2 hash.
|
||
* Set `API_TOKEN` before exposing the backend beyond localhost (protects the
|
||
raw API — desktop app, scripts, cron); see [Going public](#going-public) to
|
||
close the **browser UI** the same way, with a login instead of a header.
|
||
* The login cookie is `HttpOnly`, `SameSite=Lax`, and `Secure` whenever the
|
||
request arrives as HTTPS (directly or via `X-Forwarded-Proto` from a reverse
|
||
proxy) — put the backend behind TLS before relying on it over the open
|
||
internet, same as `API_TOKEN` always needed.
|
||
* Login attempts are rate-limited per source IP (in-process; fine for one
|
||
instance, one user).
|