293 lines
14 KiB
Markdown
293 lines
14 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.
|
||
|
||
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.
|
||
* **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 (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`.
|
||
* **Masks** — `youtube.com` blocks the site plus `www.` and `m.`; `*.google.com`
|
||
blocks every subdomain in your expansion list (a hosts file has no wildcards,
|
||
so the app expands them for you and tells you when a pattern like `*gram*`
|
||
cannot be expressed that way). Exceptions take masks too, so `*.google.com`
|
||
blocked plus `*.docs.google.com` allowed does what you would expect.
|
||
* **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 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 |
|
||
| `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 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. 63 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 app's `Theme` singleton carries the same tokens as
|
||
the web app's `tokens.ts`, so the two clients are one product. 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 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
|
||
python -m app.cli set-password # set/rotate the web login password (prompts, never argv)
|
||
```
|
||
|
||
---
|
||
|
||
## 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` (~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. 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).
|