android / apk (push) Failing after 59s
lint / ruff (push) Failing after 3s
tests / backend (push) Failing after 2s
tests / desktop (push) Failing after 2s
tests / web (push) Successful in 27s
tests / android-core (push) Failing after 3s
docker / image (push) Canceled after 8s
274 Android tests, not 234; 73 backend, not 71. The new suites named alongside the others, since that table is how anyone finds out what is already covered. Two sentences in the Android README described the intent rather than the code, and both now describe the code: "offline, the server's last answer keeps working" (it is on disk, so it survives the process — and the process Android starts for the accessibility service is the one that needed it most), and "offline writes go to an outbox" (they do now; it says which guarantees that buys, and what happens to an interval that cannot be filed honestly). The `:app` module never contained Room, either. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
368 lines
19 KiB
Markdown
368 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 runner is registered.** `docker-compose.ci.yml` is the whole
|
||
of that, and its header is the setup checklist: a registration token from
|
||
*Settings → Actions → Runners*, then `make ci-runner`. Gitea itself needs
|
||
`[actions] ENABLED = true` and `DEFAULT_ACTIONS_URL = github` in `app.ini`, the
|
||
second so `actions/checkout@v4` can be resolved.
|
||
|
||
Two details decide whether the jobs work at all:
|
||
|
||
* **The label map.** `runs-on: ubuntu-latest` is pinned to
|
||
`catthehacker/ubuntu:act-24.04` by the runner's own config, because the jobs need
|
||
two things from the image: a `node` (Gitea's javascript actions run *inside* the
|
||
job container) and Python 3.12, which the backend's `requires-python` insists on.
|
||
* **The docker socket**, mounted into job containers so the image can be built. It
|
||
needs both the mount and `valid_volumes` naming it — see `deploy/act-runner.yaml`.
|
||
|
||
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).
|