From 17a0a3c4c9621b332cc745afaf5c16489a797c04 Mon Sep 17 00:00:00 2001 From: "Timur Kh." Date: Sun, 18 Oct 2026 18:02:05 +0300 Subject: [PATCH] init --- .dockerignore | 24 + .env.example | 64 + .gitignore | 25 + Dockerfile | 52 + Makefile | 42 + README.md | 233 ++ backend/app/__init__.py | 3 + backend/app/api/__init__.py | 20 + backend/app/api/deps.py | 37 + backend/app/api/routes_blocker.py | 58 + backend/app/api/routes_reports.py | 100 + backend/app/api/routes_settings.py | 54 + backend/app/api/routes_tasks.py | 121 + backend/app/api/routes_timer.py | 155 + backend/app/api/schemas.py | 79 + backend/app/cli.py | 76 + backend/app/config.py | 92 + backend/app/db.py | 62 + backend/app/main.py | 106 + backend/app/models.py | 183 ++ backend/app/services/__init__.py | 1 + backend/app/services/achievements.py | 320 ++ backend/app/services/blocker_service.py | 165 ++ backend/app/services/notify.py | 169 ++ backend/app/services/pomodoro.py | 354 +++ backend/app/services/report.py | 441 +++ backend/app/services/scheduler.py | 318 ++ backend/app/services/settings_service.py | 184 ++ backend/app/services/stats.py | 284 ++ backend/app/services/tasks_service.py | 275 ++ backend/app/services/telegram_bot.py | 367 +++ backend/app/settings_schema.py | 300 ++ backend/app/sources/__init__.py | 12 + backend/app/sources/base.py | 70 + backend/app/sources/google_tasks.py | 175 ++ backend/app/sources/jira.py | 184 ++ backend/app/sources/laundry.py | 194 ++ backend/app/sources/registry.py | 125 + backend/app/sources/todoist.py | 145 + backend/app/timeutil.py | 75 + backend/pyproject.toml | 40 + backend/tests/conftest.py | 64 + backend/tests/test_api.py | 99 + backend/tests/test_blocker.py | 62 + backend/tests/test_pomodoro.py | 93 + backend/tests/test_reports.py | 99 + backend/tests/test_settings_service.py | 72 + backend/tests/test_sources.py | 100 + backend/tests/test_telegram_bot.py | 71 + desktop/README.md | 129 + desktop/helper/install.sh | 68 + desktop/helper/kadence-helper | 211 ++ desktop/helper/org.kadence.helper.policy | 31 + desktop/kadence_desktop/__init__.py | 4 + desktop/kadence_desktop/__main__.py | 84 + desktop/kadence_desktop/api.py | 151 + desktop/kadence_desktop/app.py | 146 + desktop/kadence_desktop/blocking/__init__.py | 6 + desktop/kadence_desktop/blocking/hosts.py | 146 + desktop/kadence_desktop/blocking/manager.py | 109 + desktop/kadence_desktop/blocking/processes.py | 102 + desktop/kadence_desktop/config.py | 97 + desktop/kadence_desktop/controller.py | 639 ++++ .../kadence_desktop/integrations/__init__.py | 6 + .../kadence_desktop/integrations/autostart.py | 140 + .../integrations/dbus_service.py | 122 + .../kadence_desktop/integrations/inhibit.py | 106 + .../kadence_desktop/integrations/launcher.py | 43 + desktop/kadence_desktop/integrations/mpris.py | 69 + .../integrations/notifications.py | 133 + .../kadence_desktop/integrations/session.py | 119 + desktop/kadence_desktop/qml/BlockerPage.qml | 191 ++ desktop/kadence_desktop/qml/Main.qml | 234 ++ desktop/kadence_desktop/qml/ReportPage.qml | 180 ++ desktop/kadence_desktop/qml/SettingsPage.qml | 391 +++ desktop/kadence_desktop/qml/TasksPage.qml | 180 ++ desktop/kadence_desktop/qml/TimerPage.qml | 284 ++ .../kadence_desktop/qml/components/Banner.qml | 36 + .../kadence_desktop/qml/components/BarRow.qml | 79 + .../kadence_desktop/qml/components/Card.qml | 51 + .../qml/components/PrimaryButton.qml | 34 + .../qml/components/Snackbar.qml | 33 + .../qml/components/StatTile.qml | 38 + .../qml/components/StatusPill.qml | 29 + .../qml/components/TimerDial.qml | 87 + desktop/kadence_desktop/tray.py | 186 ++ desktop/kadence_desktop/worker.py | 164 ++ desktop/packaging/install-desktop.sh | 35 + desktop/packaging/kadence.desktop | 34 + desktop/packaging/kadence.svg | 16 + desktop/pyproject.toml | 19 + docker-compose.dev.yml | 31 + docker-compose.yml | 67 + frontend/index.html | 20 + frontend/package-lock.json | 2572 +++++++++++++++++ frontend/package.json | 28 + frontend/src/App.tsx | 75 + frontend/src/components/AppShell.tsx | 234 ++ frontend/src/components/StatTile.tsx | 43 + frontend/src/components/TimerDial.tsx | 110 + frontend/src/components/charts/ChartFrame.tsx | 61 + frontend/src/components/charts/FocusBars.tsx | 109 + frontend/src/components/charts/Heatmap.tsx | 94 + .../src/components/charts/HourHistogram.tsx | 58 + .../src/components/charts/SessionTimeline.tsx | 108 + frontend/src/lib/api.ts | 67 + frontend/src/lib/format.ts | 47 + frontend/src/lib/hooks.ts | 82 + frontend/src/lib/settings.tsx | 64 + frontend/src/lib/sound.ts | 67 + frontend/src/lib/types.ts | 231 ++ frontend/src/lib/viz.ts | 57 + frontend/src/main.tsx | 16 + frontend/src/pages/BlockerPage.tsx | 300 ++ frontend/src/pages/ReportPage.tsx | 222 ++ frontend/src/pages/SettingsPage.tsx | 626 ++++ frontend/src/pages/TasksPage.tsx | 316 ++ frontend/src/pages/TimerPage.tsx | 429 +++ frontend/src/theme.ts | 62 + frontend/tsconfig.json | 20 + frontend/vite.config.ts | 19 + 121 files changed, 17641 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/deps.py create mode 100644 backend/app/api/routes_blocker.py create mode 100644 backend/app/api/routes_reports.py create mode 100644 backend/app/api/routes_settings.py create mode 100644 backend/app/api/routes_tasks.py create mode 100644 backend/app/api/routes_timer.py create mode 100644 backend/app/api/schemas.py create mode 100644 backend/app/cli.py create mode 100644 backend/app/config.py create mode 100644 backend/app/db.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/achievements.py create mode 100644 backend/app/services/blocker_service.py create mode 100644 backend/app/services/notify.py create mode 100644 backend/app/services/pomodoro.py create mode 100644 backend/app/services/report.py create mode 100644 backend/app/services/scheduler.py create mode 100644 backend/app/services/settings_service.py create mode 100644 backend/app/services/stats.py create mode 100644 backend/app/services/tasks_service.py create mode 100644 backend/app/services/telegram_bot.py create mode 100644 backend/app/settings_schema.py create mode 100644 backend/app/sources/__init__.py create mode 100644 backend/app/sources/base.py create mode 100644 backend/app/sources/google_tasks.py create mode 100644 backend/app/sources/jira.py create mode 100644 backend/app/sources/laundry.py create mode 100644 backend/app/sources/registry.py create mode 100644 backend/app/sources/todoist.py create mode 100644 backend/app/timeutil.py create mode 100644 backend/pyproject.toml create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_api.py create mode 100644 backend/tests/test_blocker.py create mode 100644 backend/tests/test_pomodoro.py create mode 100644 backend/tests/test_reports.py create mode 100644 backend/tests/test_settings_service.py create mode 100644 backend/tests/test_sources.py create mode 100644 backend/tests/test_telegram_bot.py create mode 100644 desktop/README.md create mode 100755 desktop/helper/install.sh create mode 100755 desktop/helper/kadence-helper create mode 100644 desktop/helper/org.kadence.helper.policy create mode 100644 desktop/kadence_desktop/__init__.py create mode 100644 desktop/kadence_desktop/__main__.py create mode 100644 desktop/kadence_desktop/api.py create mode 100644 desktop/kadence_desktop/app.py create mode 100644 desktop/kadence_desktop/blocking/__init__.py create mode 100644 desktop/kadence_desktop/blocking/hosts.py create mode 100644 desktop/kadence_desktop/blocking/manager.py create mode 100644 desktop/kadence_desktop/blocking/processes.py create mode 100644 desktop/kadence_desktop/config.py create mode 100644 desktop/kadence_desktop/controller.py create mode 100644 desktop/kadence_desktop/integrations/__init__.py create mode 100644 desktop/kadence_desktop/integrations/autostart.py create mode 100644 desktop/kadence_desktop/integrations/dbus_service.py create mode 100644 desktop/kadence_desktop/integrations/inhibit.py create mode 100644 desktop/kadence_desktop/integrations/launcher.py create mode 100644 desktop/kadence_desktop/integrations/mpris.py create mode 100644 desktop/kadence_desktop/integrations/notifications.py create mode 100644 desktop/kadence_desktop/integrations/session.py create mode 100644 desktop/kadence_desktop/qml/BlockerPage.qml create mode 100644 desktop/kadence_desktop/qml/Main.qml create mode 100644 desktop/kadence_desktop/qml/ReportPage.qml create mode 100644 desktop/kadence_desktop/qml/SettingsPage.qml create mode 100644 desktop/kadence_desktop/qml/TasksPage.qml create mode 100644 desktop/kadence_desktop/qml/TimerPage.qml create mode 100644 desktop/kadence_desktop/qml/components/Banner.qml create mode 100644 desktop/kadence_desktop/qml/components/BarRow.qml create mode 100644 desktop/kadence_desktop/qml/components/Card.qml create mode 100644 desktop/kadence_desktop/qml/components/PrimaryButton.qml create mode 100644 desktop/kadence_desktop/qml/components/Snackbar.qml create mode 100644 desktop/kadence_desktop/qml/components/StatTile.qml create mode 100644 desktop/kadence_desktop/qml/components/StatusPill.qml create mode 100644 desktop/kadence_desktop/qml/components/TimerDial.qml create mode 100644 desktop/kadence_desktop/tray.py create mode 100644 desktop/kadence_desktop/worker.py create mode 100755 desktop/packaging/install-desktop.sh create mode 100644 desktop/packaging/kadence.desktop create mode 100644 desktop/packaging/kadence.svg create mode 100644 desktop/pyproject.toml create mode 100644 docker-compose.dev.yml create mode 100644 docker-compose.yml create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/components/AppShell.tsx create mode 100644 frontend/src/components/StatTile.tsx create mode 100644 frontend/src/components/TimerDial.tsx create mode 100644 frontend/src/components/charts/ChartFrame.tsx create mode 100644 frontend/src/components/charts/FocusBars.tsx create mode 100644 frontend/src/components/charts/Heatmap.tsx create mode 100644 frontend/src/components/charts/HourHistogram.tsx create mode 100644 frontend/src/components/charts/SessionTimeline.tsx create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/lib/format.ts create mode 100644 frontend/src/lib/hooks.ts create mode 100644 frontend/src/lib/settings.tsx create mode 100644 frontend/src/lib/sound.ts create mode 100644 frontend/src/lib/types.ts create mode 100644 frontend/src/lib/viz.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/BlockerPage.tsx create mode 100644 frontend/src/pages/ReportPage.tsx create mode 100644 frontend/src/pages/SettingsPage.tsx create mode 100644 frontend/src/pages/TasksPage.tsx create mode 100644 frontend/src/pages/TimerPage.tsx create mode 100644 frontend/src/theme.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d465cd1 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1081b52 --- /dev/null +++ b/.env.example @@ -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/getUpdates +TELEGRAM_BOT_TOKEN= +TELEGRAM_CHAT_ID= + +# ---- Slack (optional fallback) --------------------------------------------- +SLACK_WEBHOOK_URL= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e6e539b --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cc09914 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7aa306b --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..a6f7514 --- /dev/null +++ b/README.md @@ -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 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. diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..254741e --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1,3 @@ +"""kadence backend: pomodoro engine, todo aggregation, reports.""" + +__version__ = "0.1.0" diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..0158143 --- /dev/null +++ b/backend/app/api/__init__.py @@ -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"] diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py new file mode 100644 index 0000000..2fa0ff5 --- /dev/null +++ b/backend/app/api/deps.py @@ -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)] diff --git a/backend/app/api/routes_blocker.py b/backend/app/api/routes_blocker.py new file mode 100644 index 0000000..78a34c1 --- /dev/null +++ b/backend/app/api/routes_blocker.py @@ -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 {body.target} — 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} diff --git a/backend/app/api/routes_reports.py b/backend/app/api/routes_reports.py new file mode 100644 index 0000000..8351a0b --- /dev/null +++ b/backend/app/api/routes_reports.py @@ -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 ( + "🍅 Kadence\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()} diff --git a/backend/app/api/routes_settings.py b/backend/app/api/routes_settings.py new file mode 100644 index 0000000..d47d230 --- /dev/null +++ b/backend/app/api/routes_settings.py @@ -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() diff --git a/backend/app/api/routes_tasks.py b/backend/app/api/routes_tasks.py new file mode 100644 index 0000000..13ba2c0 --- /dev/null +++ b/backend/app/api/routes_tasks.py @@ -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} diff --git a/backend/app/api/routes_timer.py b/backend/app/api/routes_timer.py new file mode 100644 index 0000000..9151718 --- /dev/null +++ b/backend/app/api/routes_timer.py @@ -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"🎉 {done} помидорок готово!\n{bar} {done}/{goal}\nДержи темп." + if ru + else f"🎉 {done} 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 diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py new file mode 100644 index 0000000..8a6a045 --- /dev/null +++ b/backend/app/api/schemas.py @@ -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) diff --git a/backend/app/cli.py b/backend/app/cli.py new file mode 100644 index 0000000..e51c0c2 --- /dev/null +++ b/backend/app/cli.py @@ -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()) diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..84287f6 --- /dev/null +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/db.py b/backend/app/db.py new file mode 100644 index 0000000..bc37a0f --- /dev/null +++ b/backend/app/db.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..ca4c330 --- /dev/null +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..56d43b9 --- /dev/null +++ b/backend/app/models.py @@ -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) diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..2bf1aa8 --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1 @@ +"""Domain services: settings, timer, task aggregation, reports, notifications.""" diff --git a/backend/app/services/achievements.py b/backend/app/services/achievements.py new file mode 100644 index 0000000..c6398f3 --- /dev/null +++ b/backend/app/services/achievements.py @@ -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) diff --git a/backend/app/services/blocker_service.py b/backend/app/services/blocker_service.py new file mode 100644 index 0000000..bd3652c --- /dev/null +++ b/backend/app/services/blocker_service.py @@ -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"] diff --git a/backend/app/services/notify.py b/backend/app/services/notify.py new file mode 100644 index 0000000..0f5a3b1 --- /dev/null +++ b/backend/app/services/notify.py @@ -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) diff --git a/backend/app/services/pomodoro.py b/backend/app/services/pomodoro.py new file mode 100644 index 0000000..0ec6c6e --- /dev/null +++ b/backend/app/services/pomodoro.py @@ -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) diff --git a/backend/app/services/report.py b/backend/app/services/report.py new file mode 100644 index 0000000..77bc9a5 --- /dev/null +++ b/backend/app/services/report.py @@ -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"{header} — {payload['date']}") + lines.append("") + + bar = progress_bar(metrics["pomodoros"], metrics["goal"]) + goal_line = ( + f"{bar} {metrics['pomodoros']}/{metrics['goal']} " + + ("помидорок" if ru else "pomodoros") + ) + lines.append(goal_line) + lines.append( + ("⏱ Фокус: " if ru else "⏱ Focus: ") + + f"{humanize_minutes(metrics['focus_minutes'])}" + + (" · перерывы: " 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"{'✅ Закрытые задачи' if ru else '✅ Tasks closed'} ({len(tasks)})" + ) + 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"• {title} ({label})") + else: + lines.append(f"• {title} ({label})") + 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"{'🚫 Соблазны' if ru else '🚫 Temptations blocked'}") + for entry in metrics["blocked_top"][:5]: + lines.append(f"• {_esc(entry['target'])} × {entry['count']}") + + if payload["achievements"]: + lines.append("") + lines.append(f"{'🏅 Достижения' if ru else '🏅 Achievements'}") + for item in payload["achievements"]: + detail = f" — {_esc(item['detail'])}" if item.get("detail") else "" + lines.append(f"{item['emoji']} {_esc(item['title'])}{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"{_esc(payload['verdict'])}") + + 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"{'☀️ Доброе утро!' if ru else '☀️ Good morning!'}", ""] + goal = settings.pomodoro.daily_goal_pomodoros + lines.append( + (f"🎯 Цель на сегодня: {goal} помидорок " + f"(~{humanize_minutes(goal * settings.pomodoro.work_minutes)} фокуса)") + if ru + else ( + f"🎯 Today's goal: {goal} 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"{emoji} {title} ({len(records)})") + 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" — {days} дн. просрочки" if ru else f" — {days}d overdue" + ) + if record.url: + lines.append( + f"• {title_text} ({label}){due}" + ) + else: + lines.append(f"• {title_text} ({label}){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"{'🧺 Стирка' if ru else '🧺 Laundry'}") + 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( + "/start_work — запустить помидорку, /today — список, /report — отчёт" + if ru + else "/start_work to begin, /today for the list, /report for the numbers" + ) + 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"{'📅 Итоги недели' if ru else '📅 Weekly review'} " + f"({payload['start']} → {payload['end']})", + "", + ("🍅 Помидорок: " if ru else "🍅 Pomodoros: ") + + f"{payload['pomodoros']}" + + (" · в среднем " 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"{'По дням' if ru else 'Day by day'}") + for day in payload["days"]: + bar = progress_bar(day["pomodoros"], settings.pomodoro.daily_goal_pomodoros, width=8) + lines.append(f"{day['date'][5:]} {bar} {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) diff --git a/backend/app/services/scheduler.py b/backend/app/services/scheduler.py new file mode 100644 index 0000000..65821ba --- /dev/null +++ b/backend/app/services/scheduler.py @@ -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"Предлагаю: {report._esc(label)}") + if ru + else f"Suggested next: {report._esc(label)}" + ) + 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( + "/start_work — запустить таймер" if ru else "/start_work to begin" + ) + 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() + ] diff --git a/backend/app/services/settings_service.py b/backend/app/services/settings_service.py new file mode 100644 index 0000000..a832697 --- /dev/null +++ b/backend/app/services/settings_service.py @@ -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)) diff --git a/backend/app/services/stats.py b/backend/app/services/stats.py new file mode 100644 index 0000000..663a259 --- /dev/null +++ b/backend/app/services/stats.py @@ -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), + } diff --git a/backend/app/services/tasks_service.py b/backend/app/services/tasks_service.py new file mode 100644 index 0000000..3bf4a41 --- /dev/null +++ b/backend/app/services/tasks_service.py @@ -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() diff --git a/backend/app/services/telegram_bot.py b/backend/app/services/telegram_bot.py new file mode 100644 index 0000000..1324ca5 --- /dev/null +++ b/backend/app/services/telegram_bot.py @@ -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 = """Kadence + +/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 = """Kadence + +/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▶️ {kind} ({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"{'📋 Задачи' if ru else '📋 Your tasks'}"] + 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]} {position}. {report._esc(task.title)}{due}" + ) + if position > limit: + lines.append(("… и ещё " if ru else "… and ") + f"{position - limit}") + lines.append("") + lines.append("/done N") + 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"🔥 {'Серия' if ru else 'Streak'}: {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"🛡 {'Блокировщик' if ru else 'Blocker'}: {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 diff --git a/backend/app/settings_schema.py b/backend/app/settings_schema.py new file mode 100644 index 0000000..f66c7c6 --- /dev/null +++ b/backend/app/settings_schema.py @@ -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 = "••••••••" diff --git a/backend/app/sources/__init__.py b/backend/app/sources/__init__.py new file mode 100644 index 0000000..fe271ba --- /dev/null +++ b/backend/app/sources/__init__.py @@ -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"] diff --git a/backend/app/sources/base.py b/backend/app/sources/base.py new file mode 100644 index 0000000..304d883 --- /dev/null +++ b/backend/app/sources/base.py @@ -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.""" diff --git a/backend/app/sources/google_tasks.py b/backend/app/sources/google_tasks.py new file mode 100644 index 0000000..3e8ff4b --- /dev/null +++ b/backend/app/sources/google_tasks.py @@ -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 diff --git a/backend/app/sources/jira.py b/backend/app/sources/jira.py new file mode 100644 index 0000000..813bc0f --- /dev/null +++ b/backend/app/sources/jira.py @@ -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 diff --git a/backend/app/sources/laundry.py b/backend/app/sources/laundry.py new file mode 100644 index 0000000..c7d3743 --- /dev/null +++ b/backend/app/sources/laundry.py @@ -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 diff --git a/backend/app/sources/registry.py b/backend/app/sources/registry.py new file mode 100644 index 0000000..f644fd3 --- /dev/null +++ b/backend/app/sources/registry.py @@ -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) diff --git a/backend/app/sources/todoist.py b/backend/app/sources/todoist.py new file mode 100644 index 0000000..91261fc --- /dev/null +++ b/backend/app/sources/todoist.py @@ -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 diff --git a/backend/app/timeutil.py b/backend/app/timeutil.py new file mode 100644 index 0000000..77c5121 --- /dev/null +++ b/backend/app/timeutil.py @@ -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" diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..0507629 --- /dev/null +++ b/backend/pyproject.toml @@ -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"] diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..ec31357 --- /dev/null +++ b/backend/tests/conftest.py @@ -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 diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py new file mode 100644 index 0000000..f332a78 --- /dev/null +++ b/backend/tests/test_api.py @@ -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" diff --git a/backend/tests/test_blocker.py b/backend/tests/test_blocker.py new file mode 100644 index 0000000..9b91b67 --- /dev/null +++ b/backend/tests/test_blocker.py @@ -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} diff --git a/backend/tests/test_pomodoro.py b/backend/tests/test_pomodoro.py new file mode 100644 index 0000000..99ec22a --- /dev/null +++ b/backend/tests/test_pomodoro.py @@ -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" diff --git a/backend/tests/test_reports.py b/backend/tests/test_reports.py new file mode 100644 index 0000000..e933983 --- /dev/null +++ b/backend/tests/test_reports.py @@ -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 & 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 diff --git a/backend/tests/test_settings_service.py b/backend/tests/test_settings_service.py new file mode 100644 index 0000000..70cc0fc --- /dev/null +++ b/backend/tests/test_settings_service.py @@ -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 diff --git a/backend/tests/test_sources.py b/backend/tests/test_sources.py new file mode 100644 index 0000000..0c789bc --- /dev/null +++ b/backend/tests/test_sources.py @@ -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 = """ +
+
+
Этаж 1
+
+
Машинка 3
+
09:00-12:00
+
18:00-21:00
+
21:00-00:00
+
+
+
+""" + + +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") diff --git a/backend/tests/test_telegram_bot.py b/backend/tests/test_telegram_bot.py new file mode 100644 index 0000000..789d353 --- /dev/null +++ b/backend/tests/test_telegram_bot.py @@ -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 "1." 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") diff --git a/desktop/README.md b/desktop/README.md new file mode 100644 index 0000000..d3e13ae --- /dev/null +++ b/desktop/README.md @@ -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 +``` diff --git a/desktop/helper/install.sh b/desktop/helper/install.sh new file mode 100755 index 0000000..345d919 --- /dev/null +++ b/desktop/helper/install.sh @@ -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" < 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()) diff --git a/desktop/helper/org.kadence.helper.policy b/desktop/helper/org.kadence.helper.policy new file mode 100644 index 0000000..6917726 --- /dev/null +++ b/desktop/helper/org.kadence.helper.policy @@ -0,0 +1,31 @@ + + + + + Kadence + https://github.com/ + + + Manage the Kadence website block list + Управление списком заблокированных сайтов Kadence + Authentication is required to change which websites are blocked. + Для изменения списка заблокированных сайтов требуется аутентификация. + kadence + + auth_admin + auth_admin + auth_admin_keep + + /usr/local/bin/kadence-helper + true + + diff --git a/desktop/kadence_desktop/__init__.py b/desktop/kadence_desktop/__init__.py new file mode 100644 index 0000000..77435f6 --- /dev/null +++ b/desktop/kadence_desktop/__init__.py @@ -0,0 +1,4 @@ +"""Native desktop client for Kadence (PyQt6 + QML, Material style).""" + +__version__ = "0.1.0" +APP_ID = "org.kadence.Desktop" diff --git a/desktop/kadence_desktop/__main__.py b/desktop/kadence_desktop/__main__.py new file mode 100644 index 0000000..a0dfe6c --- /dev/null +++ b/desktop/kadence_desktop/__main__.py @@ -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()) diff --git a/desktop/kadence_desktop/api.py b/desktop/kadence_desktop/api.py new file mode 100644 index 0000000..77e888c --- /dev/null +++ b/desktop/kadence_desktop/api.py @@ -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}" diff --git a/desktop/kadence_desktop/app.py b/desktop/kadence_desktop/app.py new file mode 100644 index 0000000..f1bed25 --- /dev/null +++ b/desktop/kadence_desktop/app.py @@ -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) diff --git a/desktop/kadence_desktop/blocking/__init__.py b/desktop/kadence_desktop/blocking/__init__.py new file mode 100644 index 0000000..dbfeea0 --- /dev/null +++ b/desktop/kadence_desktop/blocking/__init__.py @@ -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. +""" diff --git a/desktop/kadence_desktop/blocking/hosts.py b/desktop/kadence_desktop/blocking/hosts.py new file mode 100644 index 0000000..e3accbd --- /dev/null +++ b/desktop/kadence_desktop/blocking/hosts.py @@ -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 diff --git a/desktop/kadence_desktop/blocking/manager.py b/desktop/kadence_desktop/blocking/manager.py new file mode 100644 index 0000000..3c13237 --- /dev/null +++ b/desktop/kadence_desktop/blocking/manager.py @@ -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.") diff --git a/desktop/kadence_desktop/blocking/processes.py b/desktop/kadence_desktop/blocking/processes.py new file mode 100644 index 0000000..c7f3885 --- /dev/null +++ b/desktop/kadence_desktop/blocking/processes.py @@ -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 diff --git a/desktop/kadence_desktop/config.py b/desktop/kadence_desktop/config.py new file mode 100644 index 0000000..d5ba302 --- /dev/null +++ b/desktop/kadence_desktop/config.py @@ -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() diff --git a/desktop/kadence_desktop/controller.py b/desktop/kadence_desktop/controller.py new file mode 100644 index 0000000..9c9e5a5 --- /dev/null +++ b/desktop/kadence_desktop/controller.py @@ -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) diff --git a/desktop/kadence_desktop/integrations/__init__.py b/desktop/kadence_desktop/integrations/__init__.py new file mode 100644 index 0000000..1803d54 --- /dev/null +++ b/desktop/kadence_desktop/integrations/__init__.py @@ -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. +""" diff --git a/desktop/kadence_desktop/integrations/autostart.py b/desktop/kadence_desktop/integrations/autostart.py new file mode 100644 index 0000000..742f9ef --- /dev/null +++ b/desktop/kadence_desktop/integrations/autostart.py @@ -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) diff --git a/desktop/kadence_desktop/integrations/dbus_service.py b/desktop/kadence_desktop/integrations/dbus_service.py new file mode 100644 index 0000000..91af58c --- /dev/null +++ b/desktop/kadence_desktop/integrations/dbus_service.py @@ -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 diff --git a/desktop/kadence_desktop/integrations/inhibit.py b/desktop/kadence_desktop/integrations/inhibit.py new file mode 100644 index 0000000..4232f95 --- /dev/null +++ b/desktop/kadence_desktop/integrations/inhibit.py @@ -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) diff --git a/desktop/kadence_desktop/integrations/launcher.py b/desktop/kadence_desktop/integrations/launcher.py new file mode 100644 index 0000000..d8796f0 --- /dev/null +++ b/desktop/kadence_desktop/integrations/launcher.py @@ -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) diff --git a/desktop/kadence_desktop/integrations/mpris.py b/desktop/kadence_desktop/integrations/mpris.py new file mode 100644 index 0000000..bf86b33 --- /dev/null +++ b/desktop/kadence_desktop/integrations/mpris.py @@ -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 = [] diff --git a/desktop/kadence_desktop/integrations/notifications.py b/desktop/kadence_desktop/integrations/notifications.py new file mode 100644 index 0000000..b15a265 --- /dev/null +++ b/desktop/kadence_desktop/integrations/notifications.py @@ -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) diff --git a/desktop/kadence_desktop/integrations/session.py b/desktop/kadence_desktop/integrations/session.py new file mode 100644 index 0000000..207c475 --- /dev/null +++ b/desktop/kadence_desktop/integrations/session.py @@ -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() diff --git a/desktop/kadence_desktop/qml/BlockerPage.qml b/desktop/kadence_desktop/qml/BlockerPage.qml new file mode 100644 index 0000000..9f71c21 --- /dev/null +++ b/desktop/kadence_desktop/qml/BlockerPage.qml @@ -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() +} diff --git a/desktop/kadence_desktop/qml/Main.qml b/desktop/kadence_desktop/qml/Main.qml new file mode 100644 index 0000000..152a7ca --- /dev/null +++ b/desktop/kadence_desktop/qml/Main.qml @@ -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 } +} diff --git a/desktop/kadence_desktop/qml/ReportPage.qml b/desktop/kadence_desktop/qml/ReportPage.qml new file mode 100644 index 0000000..1f29173 --- /dev/null +++ b/desktop/kadence_desktop/qml/ReportPage.qml @@ -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() +} diff --git a/desktop/kadence_desktop/qml/SettingsPage.qml b/desktop/kadence_desktop/qml/SettingsPage.qml new file mode 100644 index 0000000..aaa07e0 --- /dev/null +++ b/desktop/kadence_desktop/qml/SettingsPage.qml @@ -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() +} diff --git a/desktop/kadence_desktop/qml/TasksPage.qml b/desktop/kadence_desktop/qml/TasksPage.qml new file mode 100644 index 0000000..7e92986 --- /dev/null +++ b/desktop/kadence_desktop/qml/TasksPage.qml @@ -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() + } + } + } + } +} diff --git a/desktop/kadence_desktop/qml/TimerPage.qml b/desktop/kadence_desktop/qml/TimerPage.qml new file mode 100644 index 0000000..7c93eef --- /dev/null +++ b/desktop/kadence_desktop/qml/TimerPage.qml @@ -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() } + } + } + } +} diff --git a/desktop/kadence_desktop/qml/components/Banner.qml b/desktop/kadence_desktop/qml/components/Banner.qml new file mode 100644 index 0000000..1c34c4e --- /dev/null +++ b/desktop/kadence_desktop/qml/components/Banner.qml @@ -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() + } + } +} diff --git a/desktop/kadence_desktop/qml/components/BarRow.qml b/desktop/kadence_desktop/qml/components/BarRow.qml new file mode 100644 index 0000000..3d215a8 --- /dev/null +++ b/desktop/kadence_desktop/qml/components/BarRow.qml @@ -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 + } + } +} diff --git a/desktop/kadence_desktop/qml/components/Card.qml b/desktop/kadence_desktop/qml/components/Card.qml new file mode 100644 index 0000000..2827f33 --- /dev/null +++ b/desktop/kadence_desktop/qml/components/Card.qml @@ -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 + } + } +} diff --git a/desktop/kadence_desktop/qml/components/PrimaryButton.qml b/desktop/kadence_desktop/qml/components/PrimaryButton.qml new file mode 100644 index 0000000..bd66341 --- /dev/null +++ b/desktop/kadence_desktop/qml/components/PrimaryButton.qml @@ -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 + } +} diff --git a/desktop/kadence_desktop/qml/components/Snackbar.qml b/desktop/kadence_desktop/qml/components/Snackbar.qml new file mode 100644 index 0000000..ae5cedc --- /dev/null +++ b/desktop/kadence_desktop/qml/components/Snackbar.qml @@ -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() } +} diff --git a/desktop/kadence_desktop/qml/components/StatTile.qml b/desktop/kadence_desktop/qml/components/StatTile.qml new file mode 100644 index 0000000..3802152 --- /dev/null +++ b/desktop/kadence_desktop/qml/components/StatTile.qml @@ -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 + } + } +} diff --git a/desktop/kadence_desktop/qml/components/StatusPill.qml b/desktop/kadence_desktop/qml/components/StatusPill.qml new file mode 100644 index 0000000..52316e8 --- /dev/null +++ b/desktop/kadence_desktop/qml/components/StatusPill.qml @@ -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 + } +} diff --git a/desktop/kadence_desktop/qml/components/TimerDial.qml b/desktop/kadence_desktop/qml/components/TimerDial.qml new file mode 100644 index 0000000..54a2f20 --- /dev/null +++ b/desktop/kadence_desktop/qml/components/TimerDial.qml @@ -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 + } + } +} diff --git a/desktop/kadence_desktop/tray.py b/desktop/kadence_desktop/tray.py new file mode 100644 index 0000000..6522a70 --- /dev/null +++ b/desktop/kadence_desktop/tray.py @@ -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) diff --git a/desktop/kadence_desktop/worker.py b/desktop/kadence_desktop/worker.py new file mode 100644 index 0000000..4c58cb2 --- /dev/null +++ b/desktop/kadence_desktop/worker.py @@ -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() diff --git a/desktop/packaging/install-desktop.sh b/desktop/packaging/install-desktop.sh new file mode 100755 index 0000000..6fdb50e --- /dev/null +++ b/desktop/packaging/install-desktop.sh @@ -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" < "$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." diff --git a/desktop/packaging/kadence.desktop b/desktop/packaging/kadence.desktop new file mode 100644 index 0000000..6045d53 --- /dev/null +++ b/desktop/packaging/kadence.desktop @@ -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 diff --git a/desktop/packaging/kadence.svg b/desktop/packaging/kadence.svg new file mode 100644 index 0000000..0bc3c2c --- /dev/null +++ b/desktop/packaging/kadence.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + diff --git a/desktop/pyproject.toml b/desktop/pyproject.toml new file mode 100644 index 0000000..3fecbe2 --- /dev/null +++ b/desktop/pyproject.toml @@ -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"] diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..d0066d2 --- /dev/null +++ b/docker-compose.dev.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fc0eaaf --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..e0db96c --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,20 @@ + + + + + + + Kadence + + + + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..17f90ba --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2572 @@ +{ + "name": "kadence-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "kadence-web", + "version": "0.1.0", + "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" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz", + "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz", + "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.9", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/styled": { + "version": "11.14.1", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", + "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2" + }, + "peerDependencies": { + "@emotion/react": "^11.0.0-rc.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mui/core-downloads-tracker": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.5.0.tgz", + "integrity": "sha512-LGb8t8i6M2ZtS3Drn3GbTI1DVhDY6FJ9crEey2lZ0aN2EMZo8IZBZj9wRf4vqbZHaWjsYgtbOnJw5V8UWbmK2Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/@mui/icons-material": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-6.5.0.tgz", + "integrity": "sha512-VPuPqXqbBPlcVSA0BmnoE4knW4/xG6Thazo8vCLWkOKusko6DtwFV6B665MMWJ9j0KFohTIf3yx2zYtYacvG1g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^6.5.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.5.0.tgz", + "integrity": "sha512-yjvtXoFcrPLGtgKRxFaH6OQPtcLPhkloC0BML6rBG5UeldR0nPULR/2E2BfXdo5JNV7j7lOzrrLX2Qf/iSidow==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/core-downloads-tracker": "^6.5.0", + "@mui/system": "^6.5.0", + "@mui/types": "~7.2.24", + "@mui/utils": "^6.4.9", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1", + "react-is": "^19.0.0", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material-pigment-css": "^6.5.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@mui/material-pigment-css": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/private-theming": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.4.9.tgz", + "integrity": "sha512-LktcVmI5X17/Q5SkwjCcdOLBzt1hXuc14jYa7NPShog0GBDCDvKtcnP0V7a2s6EiVRlv7BzbWEJzH6+l/zaCxw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/utils": "^6.4.9", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styled-engine": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.5.0.tgz", + "integrity": "sha512-8woC2zAqF4qUDSPIBZ8v3sakj+WgweolpyM/FXf8jAx6FMls+IE4Y8VDZc+zS805J7PRz31vz73n2SovKGaYgw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@emotion/cache": "^11.13.5", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/system": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-6.5.0.tgz", + "integrity": "sha512-XcbBYxDS+h/lgsoGe78ExXFZXtuIlSBpn/KsZq8PtZcIkUNJInkuDqcLd2rVBQrDC1u+rvVovdaWPf2FHKJf3w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/private-theming": "^6.4.9", + "@mui/styled-engine": "^6.5.0", + "@mui/types": "~7.2.24", + "@mui/utils": "^6.4.9", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/types": { + "version": "7.2.24", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.24.tgz", + "integrity": "sha512-3c8tRt/CbWZ+pEg7QpSwbdxOk36EfmhbKf6AGZsD1EcLDLTSZoxxJ86FVtcjxvjuhdyBiWKSTGZFaXCnidO2kw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.4.9.tgz", + "integrity": "sha512-Y12Q9hbK9g+ZY0T3Rxrx9m2m10gaphDuUMgWxyV5kNJevVxXYCLclYUCC9vXaIk1/NdNDTcW2Yfr2OGvNFNmHg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/types": "~7.2.24", + "@types/prop-types": "^15.7.14", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.4", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.4.tgz", + "integrity": "sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.64.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.9.tgz", + "integrity": "sha512-qJmhckvOleXp8R+BaXiVW/S34/3M7pvN17/QcG90A74BDaSvKv8ffY+GYG+a/xU0vEoITzdMSiM7o9HZSQWbfA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.64.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.9.tgz", + "integrity": "sha512-dqQgds39BNWGB2ughJ6n2LTQFXk8RAOwnXj6m387OHpygfD93QRS1ODIfpiWyAa9gsFB9l34pSqeLmA0Xk/VBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.64.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.9.tgz", + "integrity": "sha512-scrMx4SGikOSbWknLmPD0v6OeJ1Wih3kyRLdNZnlVbBeKVWp1nRTVcpM0IxDJdc3u8UjesPHTmnX649u8cQpBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.64.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.9.tgz", + "integrity": "sha512-fkwi2OQR+QWoViRFt08Bsca6G0e9pk/4z+JgmqHq6jSRTT0LtI27KRVK8bu++6L/CBtQ3OZePbqC3bcrwGzTqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.64.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.9.tgz", + "integrity": "sha512-8Apr0Khn0r2MUsDtcOMbp85fDdXsX/E+iLhO17WUjG24m3O8ChArqI0isI7CAdj9Ouq177PHtVtEUpMNE1C4Gw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.64.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.9.tgz", + "integrity": "sha512-H+upy4DhBcBZlUsCAMyr8wR0Dg9kj8sGloVZdZ7va2heJM/5jnROxI47dtXseJjlD/VoQe6MrXhY3b5ikOq/Vw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.64.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.9.tgz", + "integrity": "sha512-b50NS9TbAEK/b16vBoKrkeNwy+HvGBXmtBbzX4wcodWOh9dQZvRTN9rUyElIXa36QnwXhU9PAVVkZpqRzSi2Tg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.64.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.9.tgz", + "integrity": "sha512-SdqcudB/FzjLESsRMgXhV+FXGtf6usxXwlMoRW7D/XS3f9CZ4T4wycRBu2h9bVl2AOaPpunoA8ftAM/MF4altQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.64.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.9.tgz", + "integrity": "sha512-dg19FFRoovN+KZ+gMHBAq4Do8PgQmBcxpuhux57OuiR0QLavfn8uPBY8CP+UO50bO+xf0tK0GlqI/jld++5E6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.64.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.9.tgz", + "integrity": "sha512-+jyv5ofGmpAvtrzLjSr/e4syskeikEp5L82bUzB76auwOgK1VNVwZV/F2g1Pes9bhTznbejOefAqPglHSKYmTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.64.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.9.tgz", + "integrity": "sha512-MKFXxqmkzCbtNvx/vjiJdss44eRtVabhCtDWcCFD9NHGwP+YkqnM/slnEsv/3fsoq9ukTtudraAjGhptrUPCVg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.64.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.9.tgz", + "integrity": "sha512-lhARcOT6n7Ni9hpQRq8LZjbuoW+G/GXaW4Jfhx2MbmjnCj9XxLMRTceJIMpiMLQVWm+qxe7oq1sYvlIGOAW34Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.64.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.9.tgz", + "integrity": "sha512-oORQzGxZ1oHghJwLdwc8Ni0SY9otILAeXsWdiImTF4F5HkWJUwh5s/269PF5tYILNC1z/3RwFsrgcHPguBcb0g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.64.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.9.tgz", + "integrity": "sha512-J2JQHQt7oR1uutd2OSwbV2Qpb2LI5gggPolbkUwDe7vn/0dnduZ0w6EIbGnZUAw7uIKaMgfVylcH6V+Aov8kRA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.64.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.9.tgz", + "integrity": "sha512-bWaKLrfzeQ9mOzJJxXbPgw2RH6VAaTJrb/60Tqf7YSMxubG8780Hzm7Us+D6qVW8QRGJYl4VUvVXxnRB0o9zvQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.64.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.9.tgz", + "integrity": "sha512-TFJyaCB9NsvSyvVGQgTSRD9XgxsAq99ivDDkt9KAySlrNW5Obsd1kQpvoCvBCOLq+JlJTjmtroLH0wp8M8mEsA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.64.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.9.tgz", + "integrity": "sha512-DvmOM5xz/wDnUJFVjqP32ahalxn7BDuvO25bpuFOrb50AIFCCcFPN4lWWUDD1c8Ddv9sMO9SXyqtOet8G8u9cQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.64.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.9.tgz", + "integrity": "sha512-aFzokHK3yE00mzAvKm/PY76OsOOTjvoTGE82Vp89iBDyD2wUBlV4ZsR2tWkRECTEFk6TC4Yc3zaEWjPqq1vstQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.64.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.9.tgz", + "integrity": "sha512-t/yMdeaaQUuNNpmT7taBmH2VvHzgT4YA/IaKVE3qVr4PYQemn/kmJkL4uG4UvTLcWXQd0MSoOa/3URYSWGmUEg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.64.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.9.tgz", + "integrity": "sha512-4dnxKQ8GHARyjqAU87q5UWhywZTpIbR2VWhtACdC37GD1SGszmvb3TaBFVA4n+fnDHSKMut4ZJ63hVFwgnxVtg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.64.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.9.tgz", + "integrity": "sha512-1qdQj6IrNoR0xFvrCQHmhBXe9HMbBGNTFi+3n4jKMt2rTx4k0aeAdraZU4WDOMfL9tAwS8/TRSuquvNuPQ+Gpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.64.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.9.tgz", + "integrity": "sha512-yc1GSEHIYhKGN5WSaJc+eMy8bZvzcvAmflEgFh8L3xcy8dYxGmfrudyBnBFde9f6JzOa1kFqocF8BHaWybjQoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.64.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.9.tgz", + "integrity": "sha512-VzHGvosAkLIzrwqvSgJBPDmGFTF/yE8OtMT4H5z8pM1NA0NiPpvTYnUh+9xPd+d5oy/wqF317FmJ1I8Kk8pp0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.64.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.9.tgz", + "integrity": "sha512-FB77txy1PiAcJgG72nOLA6R0S3PRZahs8Spx0z2yseDUkoMmJcrQghiebJ2JHZ74cCOVlX/MYd4dUbJtJJ8fow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.64.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.9.tgz", + "integrity": "sha512-IoIgQLpK1DbOcQf1vX+i5oe5LXypsCVb1j/mU6BLAbOGmXH4E+j4kMfgZRwPYUohVBurNyVMgIKVM5dR6xA0kA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.29", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.29.tgz", + "integrity": "sha512-YeesxhUmR4h5y5V8KF8iBCqJb8YFrBJ++XBTeGcWIaQOYXGN19JM0FoiOjAhwuDoln8owMgXcWBmgHtYSkky2w==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001816", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001816.tgz", + "integrity": "sha512-+8Yx7BXCoqk6IF8yTicjfUR6D/ldJzdlsodlEOiOSPmhK844WIgk67/zIQaGAOxNkZyyvBlrRFnLvqKIoqbAFw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.459", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.459.tgz", + "integrity": "sha512-4SC9HHSFjO/liAhifpdOL5hG9b4BP7/5G3y3UPZVO8trlAXr1CaUEEJL0gWK3kOcYcZrOA5rkwG45Gra/lTIgg==", + "dev": true, + "license": "ISC" + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.17.0.tgz", + "integrity": "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.59", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.59.tgz", + "integrity": "sha512-5qr2okC4ItAI0hG5SaV0HoYMpnM/Y6+Oqr7AmPYy/2uEoGvS+GbSKLeRxSS1uAdu59yz0ouzcuXievSgg///WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.30", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.1.tgz", + "integrity": "sha512-RRieF6IyxQBkcK6pP8xOHprLTPW6QjRkbGdImtqNme+/hYVwCZkLQLdlZGOhUqXwkS9IEynaGvPJtv6XB/cz0Q==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.6", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz", + "integrity": "sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.6", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.6.tgz", + "integrity": "sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.4", + "react-router": "6.30.6" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/resolve": { + "version": "1.22.13", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.13.tgz", + "integrity": "sha512-Dj/cW9zBV2aRj6XzCxuGIs3ESZKv02jixne3q6jFZYZ3/Tg09gHHviSMDL4kObh9pqSahdRHEnDoaP2gn8YLhA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.17.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.64.9", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.9.tgz", + "integrity": "sha512-mnPSQINChN7PpT+a5CI0/+87hm/+6zxSKBK1XonrzQ0zFnglaTjhkTP2Br4yNCTcLHUEvrAfZffH57jGf+8xBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.64.9", + "@rollup/rollup-android-arm64": "4.64.9", + "@rollup/rollup-darwin-arm64": "4.64.9", + "@rollup/rollup-darwin-x64": "4.64.9", + "@rollup/rollup-freebsd-arm64": "4.64.9", + "@rollup/rollup-freebsd-x64": "4.64.9", + "@rollup/rollup-linux-arm-gnueabihf": "4.64.9", + "@rollup/rollup-linux-arm-musleabihf": "4.64.9", + "@rollup/rollup-linux-arm64-gnu": "4.64.9", + "@rollup/rollup-linux-arm64-musl": "4.64.9", + "@rollup/rollup-linux-loong64-gnu": "4.64.9", + "@rollup/rollup-linux-loong64-musl": "4.64.9", + "@rollup/rollup-linux-ppc64-gnu": "4.64.9", + "@rollup/rollup-linux-ppc64-musl": "4.64.9", + "@rollup/rollup-linux-riscv64-gnu": "4.64.9", + "@rollup/rollup-linux-riscv64-musl": "4.64.9", + "@rollup/rollup-linux-s390x-gnu": "4.64.9", + "@rollup/rollup-linux-x64-gnu": "4.64.9", + "@rollup/rollup-linux-x64-musl": "4.64.9", + "@rollup/rollup-openbsd-x64": "4.64.9", + "@rollup/rollup-openharmony-arm64": "4.64.9", + "@rollup/rollup-win32-arm64-msvc": "4.64.9", + "@rollup/rollup-win32-ia32-msvc": "4.64.9", + "@rollup/rollup-win32-x64-gnu": "4.64.9", + "@rollup/rollup-win32-x64-msvc": "4.64.9", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..aa0e77c --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..8a5c431 --- /dev/null +++ b/frontend/src/App.tsx @@ -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(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 ( + + + {loading && } + {error && ( + + + The backend is not reachable ({error}). Start it with{" "} + docker compose up or check the API token in Settings. + + + )} + + + } /> + } /> + } /> + } /> + } /> + } /> + + + setToast(null)} + message={toast ?? ""} + anchorOrigin={{ vertical: "bottom", horizontal: "center" }} + /> + + ); +} + +export default function App() { + return ( + + + + ); +} diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx new file mode 100644 index 0000000..b8e559c --- /dev/null +++ b/frontend/src/components/AppShell.tsx @@ -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: }, + { to: "/tasks", label: "Tasks", icon: }, + { to: "/blocker", label: "Blocker", icon: }, + { to: "/report", label: "Report", icon: }, + { to: "/settings", label: "Settings", icon: }, +]; + +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("/api/timer/state", 5000); + const policy = useResource("/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 ( + + {wide && ( + + + 🍅 + + + Kadence + + + find your rhythm + + + + + {NAV.map((item) => { + const selected = location.pathname === item.to; + return ( + 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" }, + }, + }} + > + {item.icon} + + + ); + })} + + + + )} + + + {!wide && ( + + + 🍅 + + {NAV[activeIndex]?.label ?? "Kadence"} + + + + + )} + + + {children} + + + {!wide && ( + 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) => ( + + ))} + + )} + + + ); +} + +function StatusChips({ + timer, + policy, + compact = false, +}: { + timer: TimerState | null; + policy: BlockerPolicy | null; + compact?: boolean; +}) { + const current = timer?.current ?? null; + return ( + + + + + {!compact && timer && ( + + )} + + + } + label={policy?.active ? "blocking" : "open"} + /> + + + + ); +} diff --git a/frontend/src/components/StatTile.tsx b/frontend/src/components/StatTile.tsx new file mode 100644 index 0000000..eddfe7e --- /dev/null +++ b/frontend/src/components/StatTile.tsx @@ -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 ( + + + + {icon} + + {label} + + + + {value} + + {caption && ( + + {caption} + + )} + + + ); +} diff --git a/frontend/src/components/TimerDial.tsx b/frontend/src/components/TimerDial.tsx new file mode 100644 index 0000000..629ee27 --- /dev/null +++ b/frontend/src/components/TimerDial.tsx @@ -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 ( + + + + + + + + {label} + + + {clock(remaining)} + + {sublabel && ( + + {sublabel} + + )} + + + ); +} diff --git a/frontend/src/components/charts/ChartFrame.tsx b/frontend/src/components/charts/ChartFrame.tsx new file mode 100644 index 0000000..2ef76b7 --- /dev/null +++ b/frontend/src/components/charts/ChartFrame.tsx @@ -0,0 +1,61 @@ +/** Shared chrome for the charts: title, optional legend, and a caption slot. */ +import { Box, Card, CardContent, Stack, Typography } from "@mui/material"; +import type { ReactNode } from "react"; + +export interface LegendItem { + label: string; + color: string; +} + +export default function ChartFrame({ + title, + caption, + legend, + children, + action, +}: { + title: string; + caption?: string; + legend?: LegendItem[]; + children: ReactNode; + action?: ReactNode; +}) { + return ( + + + + + {title} + + {action} + + {caption && ( + + {caption} + + )} + {legend && legend.length > 1 && ( + + {legend.map((item) => ( + + + + {item.label} + + + ))} + + )} + {children} + + + ); +} diff --git a/frontend/src/components/charts/FocusBars.tsx b/frontend/src/components/charts/FocusBars.tsx new file mode 100644 index 0000000..1f4172c --- /dev/null +++ b/frontend/src/components/charts/FocusBars.tsx @@ -0,0 +1,109 @@ +/** + * Pomodoros per day — magnitude over time, so: bars, one hue, baseline at zero. + * + * The goal line is the only reference mark; days that cleared it are the darker + * ramp step, which keeps "did I hit the goal" readable without a second colour. + */ +import { Box, Tooltip, Typography, useTheme } from "@mui/material"; + +import { shortDate } from "../../lib/format"; +import type { VizPalette } from "../../lib/viz"; +import ChartFrame from "./ChartFrame"; + +interface Day { + date: string; + pomodoros: number; + focus_minutes: number; + goal_met: boolean; +} + +const HEIGHT = 160; + +export default function FocusBars({ + days, + goal, + palette, + title = "Pomodoros per day", +}: { + days: Day[]; + goal: number; + palette: VizPalette; + title?: string; +}) { + const theme = useTheme(); + const max = Math.max(goal, ...days.map((day) => day.pomodoros), 1); + const goalY = HEIGHT - (goal / max) * HEIGHT; + const slotWidth = 100 / Math.max(days.length, 1); + + return ( + + + {goal > 0 && ( + + + goal + + + )} + {days.map((day) => { + const height = (day.pomodoros / max) * HEIGHT; + return ( + + + {day.date} + + + {day.pomodoros} pomodoros · {day.focus_minutes} min + + + } + > + 0 ? 4 : 2), + minHeight: 2, + borderRadius: "4px 4px 0 0", + bgcolor: + day.pomodoros === 0 + ? theme.palette.action.disabledBackground + : day.goal_met + ? palette.sequential[4] + : palette.sequential[2], + transition: "height .3s", + cursor: "default", + }} + /> + + ); + })} + + + + {days.length ? shortDate(days[0].date) : ""} + + + {days.length ? shortDate(days[days.length - 1].date) : ""} + + + + ); +} diff --git a/frontend/src/components/charts/Heatmap.tsx b/frontend/src/components/charts/Heatmap.tsx new file mode 100644 index 0000000..da25f87 --- /dev/null +++ b/frontend/src/components/charts/Heatmap.tsx @@ -0,0 +1,94 @@ +/** + * The streak calendar: one cell per day, darker = more pomodoros. + * + * Sequential encoding, so one hue light→dark and an empty day is the surface + * tint rather than a colour — "nothing happened" should not look like a value. + */ +import { Box, Stack, Tooltip, Typography, useTheme } from "@mui/material"; + +import { weekdayOf } from "../../lib/format"; +import { rampStep, type VizPalette } from "../../lib/viz"; +import ChartFrame from "./ChartFrame"; + +const CELL = 13; +const GAP = 3; +const DAY_LABELS = ["M", "", "W", "", "F", "", "S"]; + +export default function Heatmap({ + days, + goal, + palette, +}: { + days: { date: string; pomodoros: number; focus_minutes: number }[]; + goal: number; + palette: VizPalette; +}) { + const theme = useTheme(); + if (!days.length) return null; + + // Pad the first week so every column is a real Monday→Sunday week. + const leading = weekdayOf(days[0].date); + const cells: ({ date: string; pomodoros: number; focus_minutes: number } | null)[] = [ + ...Array.from({ length: leading }, () => null), + ...days, + ]; + const weeks: typeof cells[] = []; + for (let index = 0; index < cells.length; index += 7) { + weeks.push(cells.slice(index, index + 7)); + } + + return ( + day.pomodoros > 0).length} active days in the last ${days.length}`}> + + + {DAY_LABELS.map((label, index) => ( + + + {label} + + + ))} + + {weeks.map((week, weekIndex) => ( + + {Array.from({ length: 7 }, (_unused, dayIndex) => { + const cell = week[dayIndex]; + if (!cell) { + return ; + } + const fill = rampStep(cell.pomodoros, goal, palette); + return ( + + + + ); + })} + + ))} + + + + less + + {palette.sequential.map((color) => ( + + ))} + + more + + + + ); +} diff --git a/frontend/src/components/charts/HourHistogram.tsx b/frontend/src/components/charts/HourHistogram.tsx new file mode 100644 index 0000000..31096ed --- /dev/null +++ b/frontend/src/components/charts/HourHistogram.tsx @@ -0,0 +1,58 @@ +/** + * When during the day the work actually happens. One series, one hue; the peak + * hour is the only bar that gets a direct label, which is the number you came + * for. + */ +import { Box, Tooltip, Typography, useTheme } from "@mui/material"; + +import type { VizPalette } from "../../lib/viz"; +import ChartFrame from "./ChartFrame"; + +const HEIGHT = 120; + +export default function HourHistogram({ + byHour, + palette, + peak, +}: { + byHour: { hour: number; count: number }[]; + palette: VizPalette; + peak: number | null; +}) { + const theme = useTheme(); + const max = Math.max(1, ...byHour.map((entry) => entry.count)); + + return ( + + + {byHour.map((entry) => ( + + + + ))} + + + {["00", "06", "12", "18", "23"].map((label) => ( + + {label} + + ))} + + + ); +} diff --git a/frontend/src/components/charts/SessionTimeline.tsx b/frontend/src/components/charts/SessionTimeline.tsx new file mode 100644 index 0000000..ca1c360 --- /dev/null +++ b/frontend/src/components/charts/SessionTimeline.tsx @@ -0,0 +1,108 @@ +/** + * Today, laid out on a clock: every interval in its real place. + * + * Three categories (focus / short / long break) so a legend is mandatory, and + * each block is also labelled on hover — colour never carries identity alone. + */ +import { Box, Stack, Tooltip, Typography, useTheme } from "@mui/material"; + +import { KIND_LABEL } from "../../lib/format"; +import { kindColor, type VizPalette } from "../../lib/viz"; +import ChartFrame from "./ChartFrame"; + +interface Session { + id: number; + kind: string; + status: string; + started_at: string; + minutes: number; + task: string | null; +} + +const LANE = 34; + +export default function SessionTimeline({ + sessions, + palette, +}: { + sessions: Session[]; + palette: VizPalette; +}) { + const theme = useTheme(); + const done = sessions.filter((item) => item.minutes > 0); + if (!done.length) { + return ( + + + + ); + } + + const hourOf = (iso: string) => { + const date = new Date(iso); + return date.getHours() + date.getMinutes() / 60; + }; + const first = Math.floor(Math.min(...done.map((item) => hourOf(item.started_at)))); + const last = Math.ceil( + Math.max(...done.map((item) => hourOf(item.started_at) + item.minutes / 60)), + ); + const span = Math.max(last - first, 1); + + return ( + + + {done.map((item) => { + const start = (hourOf(item.started_at) - first) / span; + const width = item.minutes / 60 / span; + return ( + + + {KIND_LABEL[item.kind] ?? item.kind} · {item.minutes} min + + + {new Date(item.started_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} + {item.task ? ` · ${item.task}` : ""} + {item.status === "abandoned" ? " · abandoned" : ""} + + + } + > + + + ); + })} + + + + {String(first).padStart(2, "0")}:00 + + + {String(last).padStart(2, "0")}:00 + + + + ); +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..0e25879 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,67 @@ +/** + * The one place that talks to the backend. + * + * Every path is relative: in production the API and the UI share an origin, and + * in development Vite proxies /api, so neither build needs to know a host. The + * optional shared secret is kept in localStorage and sent on every call. + */ + +export class ApiError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + } +} + +const TOKEN_KEY = "pt.apiToken"; + +export function getToken(): string { + return localStorage.getItem(TOKEN_KEY) ?? ""; +} + +export function setToken(token: string): void { + if (token) localStorage.setItem(TOKEN_KEY, token); + else localStorage.removeItem(TOKEN_KEY); +} + +async function request(path: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers); + headers.set("Accept", "application/json"); + if (init.body) headers.set("Content-Type", "application/json"); + const token = getToken(); + if (token) headers.set("X-API-Token", token); + + const response = await fetch(path, { ...init, headers }); + if (!response.ok) { + let detail = `${response.status} ${response.statusText}`; + try { + const body = await response.json(); + if (typeof body?.detail === "string") detail = body.detail; + else if (Array.isArray(body?.detail)) { + detail = body.detail + .map((item: { loc?: string[]; msg?: string }) => + `${(item.loc ?? []).slice(1).join(".")}: ${item.msg ?? ""}`.trim(), + ) + .join("; "); + } + } catch { + /* a non-JSON error body is fine — the status line says enough */ + } + throw new ApiError(detail, response.status); + } + if (response.status === 204) return undefined as T; + return (await response.json()) as T; +} + +export const api = { + get: (path: string) => request(path), + post: (path: string, body?: unknown) => + request(path, { method: "POST", body: body === undefined ? "{}" : JSON.stringify(body) }), + put: (path: string, body: unknown) => + request(path, { method: "PUT", body: JSON.stringify(body) }), + patch: (path: string, body: unknown) => + request(path, { method: "PATCH", body: JSON.stringify(body) }), + del: (path: string) => request(path, { method: "DELETE" }), +}; diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts new file mode 100644 index 0000000..d426251 --- /dev/null +++ b/frontend/src/lib/format.ts @@ -0,0 +1,47 @@ +/** Formatting shared by every page, so numbers read the same everywhere. */ + +export function clock(totalSeconds: number): string { + const safe = Math.max(0, Math.round(totalSeconds)); + const hours = Math.floor(safe / 3600); + const minutes = Math.floor((safe % 3600) / 60); + const seconds = safe % 60; + const pad = (value: number) => value.toString().padStart(2, "0"); + return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${pad(minutes)}:${pad(seconds)}`; +} + +export function humanMinutes(minutes: number): string { + const rounded = Math.round(minutes); + const hours = Math.floor(rounded / 60); + const rest = rounded % 60; + if (hours && rest) return `${hours}h ${rest}m`; + if (hours) return `${hours}h`; + return `${rest}m`; +} + +export const KIND_LABEL: Record = { + work: "Focus", + short_break: "Short break", + long_break: "Long break", +}; + +export const BUCKET_LABEL: Record = { + overdue: "Overdue", + today: "Due today", + upcoming: "Coming up", + someday: "No deadline", +}; + +export function dueLabel(task: { due_date: string | null; days_overdue: number }): string { + if (!task.due_date) return ""; + if (task.days_overdue > 0) return `${task.days_overdue}d overdue`; + return task.due_date; +} + +export function shortDate(iso: string): string { + return iso.slice(5).replace("-", "/"); +} + +export function weekdayOf(iso: string): number { + // Monday = 0, matching the backend's schedule days. + return (new Date(`${iso}T00:00:00`).getDay() + 6) % 7; +} diff --git a/frontend/src/lib/hooks.ts b/frontend/src/lib/hooks.ts new file mode 100644 index 0000000..93ff00c --- /dev/null +++ b/frontend/src/lib/hooks.ts @@ -0,0 +1,82 @@ +/** + * Small data hooks. No query library: the app has a handful of endpoints and + * one rule — the server owns the truth, the client polls it and ticks locally + * between polls so the countdown stays smooth without hammering the API. + */ +import { useCallback, useEffect, useRef, useState } from "react"; + +import { api } from "./api"; + +export interface Resource { + data: T | null; + error: string | null; + loading: boolean; + reload: () => Promise; + set: (value: T) => void; +} + +export function useResource(path: string | null, pollMs = 0): Resource { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(path !== null); + const alive = useRef(true); + + const reload = useCallback(async () => { + if (!path) return; + try { + const value = await api.get(path); + if (!alive.current) return; + setData(value); + setError(null); + } catch (exc) { + if (alive.current) setError(exc instanceof Error ? exc.message : String(exc)); + } finally { + if (alive.current) setLoading(false); + } + }, [path]); + + useEffect(() => { + alive.current = true; + void reload(); + if (!pollMs) return () => void (alive.current = false); + const handle = window.setInterval(() => void reload(), pollMs); + return () => { + alive.current = false; + window.clearInterval(handle); + }; + }, [reload, pollMs]); + + return { data, error, loading, reload, set: setData }; +} + +/** A 1 Hz tick, used to count down between server polls. */ +export function useTick(active: boolean): number { + const [tick, setTick] = useState(0); + useEffect(() => { + if (!active) return; + const handle = window.setInterval(() => setTick((value) => value + 1), 1000); + return () => window.clearInterval(handle); + }, [active]); + return tick; +} + +/** Remembers a value in localStorage (used for per-device UI preferences). */ +export function useLocalState(key: string, initial: T): [T, (value: T) => void] { + const [value, setValue] = useState(() => { + const stored = localStorage.getItem(key); + if (stored === null) return initial; + try { + return JSON.parse(stored) as T; + } catch { + return initial; + } + }); + const update = useCallback( + (next: T) => { + setValue(next); + localStorage.setItem(key, JSON.stringify(next)); + }, + [key], + ); + return [value, update]; +} diff --git a/frontend/src/lib/settings.tsx b/frontend/src/lib/settings.tsx new file mode 100644 index 0000000..91b69ff --- /dev/null +++ b/frontend/src/lib/settings.tsx @@ -0,0 +1,64 @@ +/** + * The settings context. + * + * One fetch at start-up, shared by every page, with a `save` that sends a patch + * and swaps in whatever the server echoes back — so the secret masking stays + * consistent and no page keeps a stale copy. + */ +import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; + +import { api } from "./api"; +import type { AppSettings, SettingsResponse } from "./types"; + +interface SettingsContextValue { + settings: AppSettings | null; + defaults: SettingsResponse["defaults"] | null; + error: string | null; + loading: boolean; + reload: () => Promise; + save: (patch: Record) => Promise; +} + +const Context = createContext(null); + +export function SettingsProvider({ children }: { children: ReactNode }) { + const [settings, setSettings] = useState(null); + const [defaults, setDefaults] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + const reload = useCallback(async () => { + try { + const body = await api.get("/api/settings"); + setSettings(body.settings); + setDefaults(body.defaults); + setError(null); + } catch (exc) { + setError(exc instanceof Error ? exc.message : String(exc)); + } finally { + setLoading(false); + } + }, []); + + const save = useCallback(async (patch: Record) => { + const body = await api.put<{ settings: AppSettings }>("/api/settings", patch); + setSettings(body.settings); + return body.settings; + }, []); + + useEffect(() => { + void reload(); + }, [reload]); + + const value = useMemo( + () => ({ settings, defaults, error, loading, reload, save }), + [settings, defaults, error, loading, reload, save], + ); + return {children}; +} + +export function useSettings(): SettingsContextValue { + const value = useContext(Context); + if (!value) throw new Error("useSettings outside SettingsProvider"); + return value; +} diff --git a/frontend/src/lib/sound.ts b/frontend/src/lib/sound.ts new file mode 100644 index 0000000..090a953 --- /dev/null +++ b/frontend/src/lib/sound.ts @@ -0,0 +1,67 @@ +/** + * Alarms and ticks, synthesised with the Web Audio API. + * + * No audio files to ship or to 404, and no autoplay surprise: the context is + * created on the first user gesture (starting a timer counts) and reused. + */ +let context: AudioContext | null = null; + +function ensureContext(): AudioContext | null { + if (typeof window === "undefined") return null; + if (!context) { + const Ctor = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; + if (!Ctor) return null; + context = new Ctor(); + } + if (context.state === "suspended") void context.resume(); + return context; +} + +function beep(frequency: number, seconds: number, volume: number, at = 0): void { + const ctx = ensureContext(); + if (!ctx) return; + const oscillator = ctx.createOscillator(); + const gain = ctx.createGain(); + oscillator.type = "sine"; + oscillator.frequency.value = frequency; + const start = ctx.currentTime + at; + // A short ramp instead of a hard stop; a clipped sine clicks. + gain.gain.setValueAtTime(0.0001, start); + gain.gain.exponentialRampToValueAtTime(Math.max(0.0002, volume), start + 0.02); + gain.gain.exponentialRampToValueAtTime(0.0001, start + seconds); + oscillator.connect(gain).connect(ctx.destination); + oscillator.start(start); + oscillator.stop(start + seconds + 0.05); +} + +export const sound = { + /** Work interval finished: three rising notes. */ + workDone(volume: number): void { + const level = volume / 100; + beep(660, 0.18, 0.25 * level, 0); + beep(880, 0.18, 0.25 * level, 0.2); + beep(1046, 0.4, 0.25 * level, 0.4); + }, + /** Break finished: two falling notes — back to it. */ + breakDone(volume: number): void { + const level = volume / 100; + beep(880, 0.18, 0.22 * level, 0); + beep(587, 0.35, 0.22 * level, 0.2); + }, + tick(volume: number): void { + beep(1200, 0.03, 0.04 * (volume / 100)); + }, + unlock(): void { + ensureContext(); + }, +}; + +/** A desktop notification, when the browser (or Electron) allows one. */ +export function notify(title: string, body: string): void { + if (typeof Notification === "undefined") return; + if (Notification.permission === "granted") { + new Notification(title, { body, icon: undefined }); + } else if (Notification.permission !== "denied") { + void Notification.requestPermission(); + } +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts new file mode 100644 index 0000000..6acf154 --- /dev/null +++ b/frontend/src/lib/types.ts @@ -0,0 +1,231 @@ +/** Shapes returned by the API. Only the fields the UI actually reads. */ + +export type TimerKind = "work" | "short_break" | "long_break"; + +export interface TimerSession { + id: number; + kind: TimerKind; + status: "running" | "paused" | "completed" | "abandoned"; + started_at: string; + ended_at: string | null; + planned_seconds: number; + elapsed_seconds: number; + remaining_seconds: number; + interruptions: number; + cycle_index: number; + task: { source: string; external_id: string; title: string } | null; + note: string | null; +} + +export interface TimerState { + current: TimerSession | null; + next_kind: TimerKind; + completed_today: number; + goal: number; + cycle_position: number; + cycles_before_long_break: number; + durations: Record; + server_time: string; + finished?: TimerSession | null; + auto_started?: TimerSession | null; + celebration?: string | null; +} + +export interface Task { + id: number; + source: string; + source_label: string; + external_id: string; + title: string; + description: string | null; + url: string | null; + project: string | null; + status: string | null; + priority: string | null; + due_date: string | null; + start_date: string | null; + labels: string[]; + is_done: boolean; + pinned: boolean; + stale: boolean; + estimated_pomodoros: number | null; + days_overdue: number; + bucket: "overdue" | "today" | "upcoming" | "someday"; +} + +export interface TaskListResponse { + date: string; + tasks: Task[]; + counts: Record; + sources: string[]; +} + +export interface RefreshResult { + refreshed_at: string; + date: string; + total: number; + sources: { source: string; label: string; ok: boolean; error: string | null; count: number }[]; +} + +export interface BlockerPolicy { + active: boolean; + reason: string; + mode: string; + enabled: boolean; + strict: boolean; + redirect_ip: string; + domains: string[]; + apps: string[]; + kill_apps: boolean; + poll_seconds: number; + all_domains: string[]; + all_apps: string[]; + session_id: number | null; +} + +export interface BlockEvents { + date: string; + total: number; + by_target: { target: string; count: number }[]; + events: { id: number; kind: string; target: string; action: string; at: string; details: string | null }[]; +} + +export interface DayMetrics { + date: string; + pomodoros: number; + goal: number; + goal_pct: number; + goal_met: boolean; + focus_minutes: number; + break_minutes: number; + wasted_minutes: number; + abandoned: number; + breaks_taken: number; + interruptions: number; + completion_rate: number; + longest_stretch_minutes: number; + first_start: string | null; + last_end: string | null; + by_hour: { hour: number; count: number }[]; + peak_hour: number | null; + tasks_completed: { id: number; title: string; source: string; project: string | null; url: string | null }[]; + tasks_completed_count: number; + time_by_task: { title: string; minutes: number }[]; + blocked_attempts: number; + blocked_top: { target: string; count: number }[]; + sessions: { + id: number; + kind: TimerKind; + status: string; + started_at: string; + minutes: number; + task: string | null; + interruptions: number; + }[]; +} + +export interface DailyReport { + date: string; + metrics: DayMetrics; + streak: { current: number; best: number; goal_streak: number; total_days: number }; + average_7d: { pomodoros: number; focus_minutes: number }; + lifetime: { pomodoros: number; focus_minutes: number; focus_hours: number; tasks_completed: number; blocked_attempts: number }; + comparison: { yesterday: number; previous_best: number }; + achievements: { code: string; emoji: string; title: string; detail: string }[]; + fun_facts: string[]; + verdict: string; + open_tasks: Record; + score: number; +} + +export interface StatsSummary { + today: DayMetrics; + streak: { current: number; best: number; goal_streak: number; total_days: number }; + lifetime: DailyReport["lifetime"]; + average_7d: { pomodoros: number; focus_minutes: number }; + range: { date: string; pomodoros: number; focus_minutes: number; goal_met: boolean }[]; + heatmap: { date: string; pomodoros: number; focus_minutes: number; goal_met: boolean }[]; +} + +export interface AppSettings { + timezone: string; + week_starts_monday: boolean; + pomodoro: { + work_minutes: number; + short_break_minutes: number; + long_break_minutes: number; + cycles_before_long_break: number; + auto_start_breaks: boolean; + auto_start_work: boolean; + daily_goal_pomodoros: number; + strict_mode: boolean; + allow_pause: boolean; + count_interruptions: boolean; + tick_sound: boolean; + alarm_sound: boolean; + alarm_volume: number; + desktop_notifications: boolean; + keep_on_top_during_work: boolean; + }; + sources: { + jira: Record & { enabled: boolean; base_url: string; email: string; api_token: string; extra_jql: string; start_date_field: string; include_all_open: boolean; writeback_done: boolean; done_transition_name: string; max_issues: number }; + google_tasks: { enabled: boolean; client_id: string; client_secret: string; refresh_token: string; lists: string[]; include_undated: boolean; writeback_done: boolean }; + todoist: { enabled: boolean; api_token: string; projects: string[]; filter_query: string; writeback_done: boolean }; + laundry: { enabled: boolean; base_url: string; session_cookie: string }; + refresh_interval_minutes: number; + request_timeout_seconds: number; + }; + blocker: { + enabled: boolean; + mode: "during_work" | "always" | "scheduled"; + block_during_breaks: boolean; + blocked_domains: string[]; + allowed_domains: string[]; + blocked_apps: string[]; + kill_blocked_apps: boolean; + schedule: { label: string; start: string; end: string; days: number[] }[]; + strict: boolean; + redirect_ip: string; + app_poll_seconds: number; + notify_on_block: boolean; + }; + notifications: { + telegram: { + enabled: boolean; + bot_token: string; + chat_id: string; + commands_enabled: boolean; + daily_report: boolean; + daily_report_time: string; + morning_briefing: boolean; + morning_briefing_time: string; + weekly_report: boolean; + weekly_report_weekday: number; + celebrate_every_n_pomodoros: number; + nudge_enabled: boolean; + nudge_after_idle_minutes: number; + nudge_window_start: string; + nudge_window_end: string; + nudge_max_per_day: number; + goal_warning: boolean; + quiet_hours: { enabled: boolean; start: string; end: string }; + }; + slack: { enabled: boolean; webhook_url: string }; + report: { + tone: "friendly" | "coach" | "drill_sergeant" | "dry"; + language: "en" | "ru"; + include_funny_stats: boolean; + include_task_list: boolean; + include_blocked_sites: boolean; + include_achievements: boolean; + include_comparison: boolean; + max_tasks_listed: number; + }; + }; + appearance: { theme: "light" | "dark" | "system"; accent: string; compact: boolean }; +} + +export interface SettingsResponse { + settings: AppSettings; + defaults: { blocked_domains: string[]; blocked_apps: string[] }; +} diff --git a/frontend/src/lib/viz.ts b/frontend/src/lib/viz.ts new file mode 100644 index 0000000..3494f04 --- /dev/null +++ b/frontend/src/lib/viz.ts @@ -0,0 +1,57 @@ +/** + * Chart colours. + * + * These are the validated data-viz slots, not the Material theme's palette: the + * UI's accent can be anything, but a chart has to stay readable for colour-blind + * readers in both themes. Work/short/long are categorical slots 1–3 (validated + * all-pairs in both modes); magnitude charts use the single-hue blue ramp. + */ + +export interface VizPalette { + work: string; + shortBreak: string; + longBreak: string; + sequential: string[]; + grid: string; + axis: string; + surface: string; +} + +const LIGHT: VizPalette = { + work: "#2a78d6", + shortBreak: "#eb6834", + longBreak: "#1baf7a", + // blue 100 → 600, light → dark: "more pomodoros" reads as "darker". + sequential: ["#cde2fb", "#9ec5f4", "#6da7ec", "#3987e5", "#256abf", "#184f95"], + grid: "rgba(0,0,0,0.08)", + axis: "rgba(0,0,0,0.45)", + surface: "#fcfcfb", +}; + +const DARK: VizPalette = { + work: "#3987e5", + shortBreak: "#d95926", + longBreak: "#199e70", + sequential: ["#104281", "#184f95", "#256abf", "#3987e5", "#6da7ec", "#9ec5f4"], + grid: "rgba(255,255,255,0.10)", + axis: "rgba(255,255,255,0.55)", + surface: "#1a1a19", +}; + +export function vizPalette(dark: boolean): VizPalette { + return dark ? DARK : LIGHT; +} + +export function kindColor(kind: string, palette: VizPalette): string { + if (kind === "work") return palette.work; + if (kind === "long_break") return palette.longBreak; + return palette.shortBreak; +} + +/** Pick a ramp step for a count, 0 meaning "empty". */ +export function rampStep(count: number, goal: number, palette: VizPalette): string | null { + if (count <= 0) return null; + const ratio = goal > 0 ? count / goal : count / 8; + const index = Math.min(palette.sequential.length - 1, Math.max(0, Math.round(ratio * (palette.sequential.length - 1)))); + return palette.sequential[index]; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..eefaf64 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { HashRouter } from "react-router-dom"; + +import App from "./App"; + +// HashRouter, not BrowserRouter: the same bundle is loaded from http:// by the +// web app and from file:// by the packaged desktop app, and only the hash +// router works in both without a server rewrite. +ReactDOM.createRoot(document.getElementById("root")!).render( + + + + + , +); diff --git a/frontend/src/pages/BlockerPage.tsx b/frontend/src/pages/BlockerPage.tsx new file mode 100644 index 0000000..b322dad --- /dev/null +++ b/frontend/src/pages/BlockerPage.tsx @@ -0,0 +1,300 @@ +/** + * The blocker's rules and its live state. + * + * The rules are server-side so every device agrees; the *enforcement* happens in + * the native desktop app, which polls the same policy this page edits. The web + * UI is honest about that: it shows whether anything is currently enforcing. + */ +import { useEffect, useMemo, useState } from "react"; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + Divider, + FormControlLabel, + Grid, + MenuItem, + Stack, + Switch, + TextField, + Typography, +} from "@mui/material"; +import ShieldIcon from "@mui/icons-material/Shield"; + +import StatTile from "../components/StatTile"; +import { useResource } from "../lib/hooks"; +import { useSettings } from "../lib/settings"; +import type { BlockEvents, BlockerPolicy } from "../lib/types"; + +export default function BlockerPage({ onToast }: { onToast: (message: string) => void }) { + const { settings, defaults, save } = useSettings(); + const policy = useResource("/api/blocker/policy", 5000); + const events = useResource("/api/blocker/events", 20000); + + const [domains, setDomains] = useState(""); + const [allowed, setAllowed] = useState(""); + const [apps, setApps] = useState(""); + const [dirty, setDirty] = useState(false); + + useEffect(() => { + if (!settings || dirty) return; + setDomains(settings.blocker.blocked_domains.join("\n")); + setAllowed(settings.blocker.allowed_domains.join("\n")); + setApps(settings.blocker.blocked_apps.join("\n")); + }, [settings, dirty]); + + const lines = (value: string) => + value + .split("\n") + .map((item) => item.trim()) + .filter(Boolean); + + const patch = async (change: Record) => { + try { + await save({ blocker: change }); + await policy.reload(); + onToast("Blocker updated"); + } catch (exc) { + onToast(exc instanceof Error ? exc.message : String(exc)); + } + }; + + const saveLists = async () => { + await patch({ + blocked_domains: lines(domains), + allowed_domains: lines(allowed), + blocked_apps: lines(apps), + }); + setDirty(false); + }; + + const addDefaults = () => { + const current = new Set(lines(domains)); + (defaults?.blocked_domains ?? []).forEach((domain) => current.add(domain)); + setDomains([...current].join("\n")); + setDirty(true); + }; + + const top = useMemo(() => events.data?.by_target.slice(0, 8) ?? [], [events.data]); + const blocker = settings?.blocker; + + return ( + + + + + + + + {policy.data?.active ? "Blocking is ON" : "Blocking is off"} + + + {policy.data?.reason} + + + void patch({ enabled: event.target.checked })} + /> + } + label="Enabled" + /> + + + + + + + + + + + + + + + + + + + Enforcement happens in the desktop app — it rewrites /etc/hosts through a + privileged helper and closes blocked applications. This page decides what and{" "} + when. + + + + + + + + When to block + + + void patch({ mode: event.target.value })} + > + While a focus interval runs + Always + On a schedule + + void patch({ block_during_breaks: event.target.checked })} + /> + } + label="Keep blocking during breaks" + /> + void patch({ kill_blocked_apps: event.target.checked })} + /> + } + label="Close blocked apps (otherwise just warn)" + /> + + + + + + + + + + Blocked websites + + + One hostname per line. www. and m. are added for you. + + { + setDomains(event.target.value); + setDirty(true); + }} + sx={{ "& textarea": { fontFamily: "monospace", fontSize: 13 } }} + /> + + + + + + + + + + Exceptions + + + Survive a blocked parent domain — e.g. music.youtube.com. + + { + setAllowed(event.target.value); + setDirty(true); + }} + sx={{ "& textarea": { fontFamily: "monospace", fontSize: 13 } }} + /> + + + + + + + + + Blocked applications + + + Process names, matched loosely: steam also catches{" "} + steamwebhelper. + + { + setApps(event.target.value); + setDirty(true); + }} + sx={{ "& textarea": { fontFamily: "monospace", fontSize: 13 } }} + /> + + + + + + + + + + + + + + Today's temptations + + + Every attempt the blocker turned away. This is what the daily report teases you about. + + {top.length === 0 ? ( + + Nothing yet today — either the blocker is off, or you are behaving. + + ) : ( + }> + {top.map((entry) => ( + + {entry.target} + + + ))} + + )} + + + + ); +} diff --git a/frontend/src/pages/ReportPage.tsx b/frontend/src/pages/ReportPage.tsx new file mode 100644 index 0000000..3dda3f7 --- /dev/null +++ b/frontend/src/pages/ReportPage.tsx @@ -0,0 +1,222 @@ +/** + * The day in numbers — the same payload the Telegram message is rendered from, + * so the chat and the browser can never disagree. + */ +import { useMemo, useState } from "react"; +import { + Box, + Button, + Card, + CardContent, + Chip, + Divider, + Grid, + Stack, + Tab, + Tabs, + Typography, +} from "@mui/material"; +import SendIcon from "@mui/icons-material/Send"; + +import FocusBars from "../components/charts/FocusBars"; +import Heatmap from "../components/charts/Heatmap"; +import HourHistogram from "../components/charts/HourHistogram"; +import SessionTimeline from "../components/charts/SessionTimeline"; +import StatTile from "../components/StatTile"; +import { api } from "../lib/api"; +import { humanMinutes } from "../lib/format"; +import { useResource } from "../lib/hooks"; +import type { DailyReport, StatsSummary } from "../lib/types"; +import { vizPalette } from "../lib/viz"; + +export default function ReportPage({ + dark, + onToast, +}: { + dark: boolean; + onToast: (message: string) => void; +}) { + const palette = useMemo(() => vizPalette(dark), [dark]); + const daily = useResource<{ report: DailyReport; sent_at: string | null; telegram_preview: string }>( + "/api/reports/daily", + 60000, + ); + const stats = useResource("/api/stats/summary?days=30", 60000); + const [tab, setTab] = useState(0); + const [sending, setSending] = useState(false); + + const report = daily.data?.report; + const metrics = report?.metrics; + + const send = async () => { + setSending(true); + try { + const result = await api.post<{ sent: boolean }>("/api/reports/daily/send", { force: true }); + onToast(result.sent ? "Report sent" : "Nothing sent — configure Telegram in Settings"); + await daily.reload(); + } catch (exc) { + onToast(exc instanceof Error ? exc.message : String(exc)); + } finally { + setSending(false); + } + }; + + return ( + + + + {report ? `Report for ${report.date}` : "Report"} + + {daily.data?.sent_at && ( + + )} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + setTab(value)} sx={{ mb: 2 }}> + + + + + + + {tab === 0 && + (report?.achievements.length ? ( + + {report.achievements.map((item) => ( + + + {item.emoji} {item.title} + + {item.detail && ( + + {item.detail} + + )} + + } + sx={{ height: "auto", py: 1, px: 0.5 }} + /> + ))} + + ) : ( + + Nothing unlocked yet today. + + ))} + + {tab === 1 && ( + + {(report?.fun_facts ?? []).map((fact) => ( + + {fact} + + ))} + {report?.verdict && ( + + {report.verdict} + + )} + + )} + + {tab === 2 && ( + }> + {(metrics?.tasks_completed ?? []).map((task) => ( + + {task.title} + + + ))} + {!metrics?.tasks_completed.length && ( + + No tasks closed here today. + + )} + + )} + + {tab === 3 && ( + + {(daily.data?.telegram_preview ?? "").replace(/<[^>]+>/g, "")} + + )} + + + + + + + + + + + + + + + + + + + ); +} diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx new file mode 100644 index 0000000..c1d1913 --- /dev/null +++ b/frontend/src/pages/SettingsPage.tsx @@ -0,0 +1,626 @@ +/** + * Every setting, grouped the way you think about them. + * + * Secrets come back masked (`••••••••`) and are only sent when you actually type + * a new one, so saving this form never leaks — or clobbers — a token. + */ +import { useEffect, useState } from "react"; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Alert, + Box, + Button, + Chip, + FormControlLabel, + Grid, + MenuItem, + Slider, + Stack, + Switch, + TextField, + Typography, +} from "@mui/material"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; + +import { api, getToken, setToken } from "../lib/api"; +import { useSettings } from "../lib/settings"; +import type { AppSettings } from "../lib/types"; + +type Patch = Record; + +export default function SettingsPage({ onToast }: { onToast: (message: string) => void }) { + const { settings, reload, save } = useSettings(); + const [token, setLocalToken] = useState(getToken()); + const [testing, setTesting] = useState(null); + + const patch = async (change: Patch, message = "Saved") => { + try { + await save(change); + onToast(message); + } catch (exc) { + onToast(exc instanceof Error ? exc.message : String(exc)); + } + }; + + if (!settings) return Loading…; + + const testSource = async (name: string) => { + setTesting(name); + try { + const result = await api.post<{ ok: boolean; detail: string }>(`/api/tasks/sources/${name}/test`); + onToast(`${name}: ${result.ok ? "✓ " : "✗ "}${result.detail}`); + } catch (exc) { + onToast(exc instanceof Error ? exc.message : String(exc)); + } finally { + setTesting(null); + } + }; + + return ( + +
+ + {( + [ + ["work_minutes", "Focus (min)", 1, 240], + ["short_break_minutes", "Short break", 1, 120], + ["long_break_minutes", "Long break", 1, 240], + ["cycles_before_long_break", "Long break every", 1, 12], + ["daily_goal_pomodoros", "Daily goal", 1, 40], + ] as const + ).map(([key, label, min, max]) => ( + + void patch({ pomodoro: { [key]: value } })} + /> + + ))} + + + {( + [ + ["auto_start_breaks", "Auto-start breaks"], + ["auto_start_work", "Auto-start next focus"], + ["allow_pause", "Allow pausing"], + ["strict_mode", "Strict mode (a reason to abandon)"], + ["count_interruptions", "Count interruptions"], + ["alarm_sound", "Alarm sound"], + ["desktop_notifications", "Browser notifications"], + ] as const + ).map(([key, label]) => ( + void patch({ pomodoro: { [key]: event.target.checked } })} + /> + } + label={label} + /> + ))} + + + + Alarm volume + + + void patch({ pomodoro: { alarm_volume: value as number } }) + } + /> + +
+ +
+ void patch({ sources: { jira: { enabled: value } } })} + onTest={() => void testSource("jira")} + testing={testing === "jira"} + > + void patch({ sources: { jira: { base_url: value } } })} + /> + void patch({ sources: { jira: { email: value } } })} + /> + void patch({ sources: { jira: { api_token: value } } })} + /> + void patch({ sources: { jira: { extra_jql: value } } })} + /> + void patch({ sources: { jira: { start_date_field: value } } })} + /> + + void patch({ sources: { jira: { writeback_done: event.target.checked } } }) + } + /> + } + label="Completing a task here transitions the Jira issue to Done" + /> + + + void patch({ sources: { todoist: { enabled: value } } })} + onTest={() => void testSource("todoist")} + testing={testing === "todoist"} + > + void patch({ sources: { todoist: { api_token: value } } })} + /> + + void patch({ + sources: { + todoist: { projects: value.split(",").map((item) => item.trim()).filter(Boolean) }, + }, + }) + } + /> + void patch({ sources: { todoist: { filter_query: value } } })} + /> + + + void patch({ sources: { google_tasks: { enabled: value } } })} + onTest={() => void testSource("google_tasks")} + testing={testing === "google_tasks"} + > + void patch({ sources: { google_tasks: { client_id: value } } })} + /> + void patch({ sources: { google_tasks: { client_secret: value } } })} + /> + void patch({ sources: { google_tasks: { refresh_token: value } } })} + /> + + + void patch({ sources: { laundry: { enabled: value } } })} + onTest={() => void testSource("laundry")} + testing={testing === "laundry"} + > + void patch({ sources: { laundry: { base_url: value } } })} + /> + void patch({ sources: { laundry: { session_cookie: value } } })} + /> + + + void patch({ sources: { refresh_interval_minutes: value } })} + /> +
+ +
+ + + void patch({ notifications: { telegram: { enabled: event.target.checked } } }) + } + /> + } + label="Enabled" + /> + void patch({ notifications: { telegram: { bot_token: value } } })} + /> + void patch({ notifications: { telegram: { chat_id: value } } })} + /> + + + + + + + void patch({ notifications: { telegram: { daily_report_time: value } } }) + } + /> + + + + void patch({ notifications: { telegram: { morning_briefing_time: value } } }) + } + /> + + + + void patch({ notifications: { telegram: { nudge_after_idle_minutes: value } } }) + } + /> + + + + void patch({ notifications: { telegram: { celebrate_every_n_pomodoros: value } } }) + } + /> + + + + + {( + [ + ["daily_report", "Daily report"], + ["morning_briefing", "Morning briefing"], + ["weekly_report", "Weekly review"], + ["nudge_enabled", "Nudge me when idle"], + ["commands_enabled", "Chat commands"], + ["goal_warning", "Warn when the goal slips away"], + ] as const + ).map(([key, label]) => ( + + void patch({ notifications: { telegram: { [key]: event.target.checked } } }) + } + /> + } + label={label} + /> + ))} + + + + + void patch({ notifications: { report: { tone: event.target.value } } }) + } + > + Friendly + Coach + Drill sergeant + Dry + + + void patch({ notifications: { report: { language: event.target.value } } }) + } + > + English + Русский + + + void patch({ notifications: { report: { include_funny_stats: event.target.checked } } }) + } + /> + } + label="Funny stats" + /> + + void patch({ + notifications: { telegram: { quiet_hours: { enabled: event.target.checked } } }, + }) + } + /> + } + label="Quiet hours" + /> + +
+ +
+ + void patch({ timezone: value })} + /> + void patch({ appearance: { theme: event.target.value } })} + > + Follow the system + Light + Dark + + void patch({ appearance: { accent: event.target.value } })} + /> + setLocalToken(event.target.value)} + onBlur={() => { + setToken(token); + void reload(); + onToast("Token stored in this browser"); + }} + /> + + + Website and application blocking is enforced by the native desktop app — install it from{" "} + desktop/, then its lists live on the Blocker page. + +
+
+ ); +} + +function Section({ + title, + subtitle, + children, +}: { + title: string; + subtitle?: string; + children: React.ReactNode; +}) { + return ( + + }> + + + {title} + + {subtitle && ( + + {subtitle} + + )} + + + + {children} + + + ); +} + +function SourceBlock({ + label, + enabled, + onToggle, + onTest, + testing, + children, +}: { + name: string; + label: string; + enabled: boolean; + onToggle: (value: boolean) => void; + onTest: () => void; + testing: boolean; + children: React.ReactNode; +}) { + return ( + + + {label} + {enabled && } + + onToggle(event.target.checked)} /> + + {enabled && ( + + {children} + + )} + + ); +} + +/** A text field that only reports a change when you leave it (one save per edit). */ +function Field({ + label, + value, + placeholder, + secret, + onCommit, +}: { + label: string; + value: string; + placeholder?: string; + secret?: boolean; + onCommit: (value: string) => void; +}) { + const [draft, setDraft] = useState(value); + useEffect(() => setDraft(value), [value]); + return ( + setDraft(event.target.value)} + onBlur={() => draft !== value && onCommit(draft)} + onKeyDown={(event) => { + if (event.key === "Enter" && draft !== value) onCommit(draft); + }} + /> + ); +} + +function NumberField({ + label, + value, + min, + max, + onCommit, +}: { + label: string; + value: number; + min: number; + max: number; + onCommit: (value: number) => void; +}) { + const [draft, setDraft] = useState(String(value)); + useEffect(() => setDraft(String(value)), [value]); + const commit = () => { + const parsed = Number(draft); + if (Number.isFinite(parsed) && parsed >= min && parsed <= max && parsed !== value) onCommit(parsed); + else setDraft(String(value)); + }; + return ( + setDraft(event.target.value)} + onBlur={commit} + onKeyDown={(event) => event.key === "Enter" && commit()} + /> + ); +} + +function TimeField({ + label, + value, + onCommit, +}: { + label: string; + value: string; + onCommit: (value: string) => void; +}) { + return ( + onCommit(`${event.target.value}:00`)} + /> + ); +} + +export type { AppSettings }; diff --git a/frontend/src/pages/TasksPage.tsx b/frontend/src/pages/TasksPage.tsx new file mode 100644 index 0000000..ed7846c --- /dev/null +++ b/frontend/src/pages/TasksPage.tsx @@ -0,0 +1,316 @@ +/** + * Everything you owe, from every source, in one list. + * + * Grouped by deadline rather than by source, because "what is late" is the + * question you actually have; the source is a chip on the row. Ticking a task + * closes it in its own system too, and says so when that write-back fails. + */ +import { useMemo, useState } from "react"; +import { + Alert, + AlertTitle, + Box, + Button, + Card, + CardContent, + Checkbox, + Chip, + Collapse, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + IconButton, + LinearProgress, + Link, + Stack, + TextField, + ToggleButton, + ToggleButtonGroup, + Tooltip, + Typography, +} from "@mui/material"; +import AddIcon from "@mui/icons-material/Add"; +import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; +import OpenInNewIcon from "@mui/icons-material/OpenInNew"; +import PlayArrowIcon from "@mui/icons-material/PlayArrow"; +import PushPinIcon from "@mui/icons-material/PushPin"; +import PushPinOutlinedIcon from "@mui/icons-material/PushPinOutlined"; +import RefreshIcon from "@mui/icons-material/Refresh"; +import { useNavigate } from "react-router-dom"; + +import { api } from "../lib/api"; +import { BUCKET_LABEL } from "../lib/format"; +import { useResource } from "../lib/hooks"; +import type { RefreshResult, Task, TaskListResponse } from "../lib/types"; + +const BUCKETS: Task["bucket"][] = ["overdue", "today", "upcoming", "someday"]; + +export default function TasksPage({ onToast }: { onToast: (message: string) => void }) { + const navigate = useNavigate(); + const tasks = useResource("/api/tasks", 60000); + const [refreshing, setRefreshing] = useState(false); + const [sourceStatus, setSourceStatus] = useState(null); + const [filter, setFilter] = useState("all"); + const [addOpen, setAddOpen] = useState(false); + const [draft, setDraft] = useState({ title: "", due_date: "", estimated_pomodoros: "" }); + + const refresh = async () => { + setRefreshing(true); + try { + const result = await api.post("/api/tasks/refresh"); + setSourceStatus(result.sources); + await tasks.reload(); + const failed = result.sources.filter((item) => !item.ok); + onToast( + failed.length + ? `${failed.length} source(s) failed — see the banner` + : `Pulled ${result.total} tasks from ${result.sources.length} source(s)`, + ); + } catch (exc) { + onToast(exc instanceof Error ? exc.message : String(exc)); + } finally { + setRefreshing(false); + } + }; + + const toggleDone = async (task: Task, done: boolean) => { + try { + const result = await api.post<{ warning: string | null }>(`/api/tasks/${task.id}/done`, { done }); + await tasks.reload(); + onToast(result.warning ?? (done ? `Closed "${task.title}"` : "Reopened")); + } catch (exc) { + onToast(exc instanceof Error ? exc.message : String(exc)); + } + }; + + const togglePin = async (task: Task) => { + await api.patch(`/api/tasks/${task.id}`, { pinned: !task.pinned }); + await tasks.reload(); + }; + + const startOn = async (task: Task) => { + await api.post("/api/timer/start", { kind: "work", task_id: task.id }); + onToast(`Focusing on "${task.title}"`); + navigate("/"); + }; + + const visible = useMemo(() => { + const all = tasks.data?.tasks ?? []; + return filter === "all" ? all : all.filter((task) => task.source === filter); + }, [tasks.data, filter]); + + const grouped = useMemo(() => { + const map: Record = { overdue: [], today: [], upcoming: [], someday: [] }; + for (const task of visible) map[task.bucket].push(task); + return map; + }, [visible]); + + const failed = sourceStatus?.filter((item) => !item.ok) ?? []; + + return ( + + + value && setFilter(value)} + > + All + {(tasks.data?.sources ?? []).map((source) => ( + + {tasks.data?.tasks.find((task) => task.source === source)?.source_label ?? source} + + ))} + + + + + + + {refreshing && } + + 0}> + setSourceStatus(null)}> + Some sources could not be read + {failed.map((item) => ( + + {item.label}: {item.error} + + ))} + + + + {sourceStatus && failed.length === 0 && ( + + {sourceStatus.map((item) => ( + + ))} + + )} + + {tasks.data && visible.length === 0 && ( + + + Nothing on the list + + Connect Jira, Todoist or Google Tasks in Settings → Sources, or add something yourself. + + + + )} + + {BUCKETS.map((bucket) => + grouped[bucket].length ? ( + + + + + {BUCKET_LABEL[bucket]} + + + + + }> + {grouped[bucket].map((task) => ( + { + await api.del(`/api/tasks/${task.id}`); + await tasks.reload(); + }} + /> + ))} + + + + ) : null, + )} + + setAddOpen(false)} fullWidth maxWidth="xs"> + New task + + + setDraft({ ...draft, title: event.target.value })} + /> + setDraft({ ...draft, due_date: event.target.value })} + /> + setDraft({ ...draft, estimated_pomodoros: event.target.value })} + /> + + + + + + + + + ); +} + +function TaskRow({ + task, + onToggle, + onPin, + onStart, + onDelete, +}: { + task: Task; + onToggle: (task: Task, done: boolean) => Promise; + onPin: (task: Task) => Promise; + onStart: (task: Task) => Promise; + onDelete: () => Promise; +}) { + return ( + + void onToggle(task, event.target.checked)} /> + + + + {task.title} + + {task.url && ( + + + + )} + + + + {task.project && } + {task.priority && } + {task.status && } + {task.days_overdue > 0 && ( + + )} + {task.due_date && task.days_overdue === 0 && ( + + )} + {task.stale && } + + + + void onPin(task)}> + {task.pinned ? : } + + + + void onStart(task)}> + + + + {task.source === "local" && ( + + void onDelete()}> + + + + )} + + ); +} diff --git a/frontend/src/pages/TimerPage.tsx b/frontend/src/pages/TimerPage.tsx new file mode 100644 index 0000000..90884ec --- /dev/null +++ b/frontend/src/pages/TimerPage.tsx @@ -0,0 +1,429 @@ +/** + * The timer. + * + * The server owns the clock, so this page polls `/api/timer/state` and ticks + * locally in between: the countdown is smooth, and a reload (or the desktop app + * taking over) picks up exactly where the browser left off. When the local + * countdown reaches zero it tells the server to complete the interval — the + * server decides what comes next. + */ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + Grid, + IconButton, + LinearProgress, + MenuItem, + Stack, + TextField, + Tooltip, + Typography, +} from "@mui/material"; +import AddAlarmIcon from "@mui/icons-material/AddAlarm"; +import CheckCircleIcon from "@mui/icons-material/CheckCircle"; +import CoffeeIcon from "@mui/icons-material/Coffee"; +import NotificationsActiveIcon from "@mui/icons-material/NotificationsActive"; +import PauseIcon from "@mui/icons-material/Pause"; +import PlayArrowIcon from "@mui/icons-material/PlayArrow"; +import StopIcon from "@mui/icons-material/Stop"; + +import SessionTimeline from "../components/charts/SessionTimeline"; +import StatTile from "../components/StatTile"; +import TimerDial from "../components/TimerDial"; +import { api } from "../lib/api"; +import { KIND_LABEL, clock, humanMinutes } from "../lib/format"; +import { useResource, useTick } from "../lib/hooks"; +import { useSettings } from "../lib/settings"; +import { notify, sound } from "../lib/sound"; +import type { DayMetrics, Task, TaskListResponse, TimerState } from "../lib/types"; +import { kindColor, vizPalette } from "../lib/viz"; + +export default function TimerPage({ + onToast, + dark, +}: { + onToast: (message: string) => void; + dark: boolean; +}) { + const { settings } = useSettings(); + const palette = useMemo(() => vizPalette(dark), [dark]); + + const timer = useResource("/api/timer/state", 10000); + const tasks = useResource("/api/tasks", 60000); + const today = useResource<{ sessions: unknown[]; metrics: DayMetrics }>("/api/timer/sessions", 30000); + + const [abandonOpen, setAbandonOpen] = useState(false); + const [abandonReason, setAbandonReason] = useState(""); + const [selectedTask, setSelectedTask] = useState(""); + const [busy, setBusy] = useState(false); + + const state = timer.data; + const current = state?.current ?? null; + const running = current?.status === "running"; + + // Local countdown between polls. + const tick = useTick(Boolean(current)); + const anchor = useRef<{ at: number; remaining: number; id: number } | null>(null); + useEffect(() => { + if (current) anchor.current = { at: Date.now(), remaining: current.remaining_seconds, id: current.id }; + else anchor.current = null; + }, [current?.id, current?.remaining_seconds, current?.status]); + + const remaining = useMemo(() => { + if (!current) return 0; + if (!anchor.current || anchor.current.id !== current.id) return current.remaining_seconds; + if (current.status === "paused") return anchor.current.remaining; + const drift = Math.floor((Date.now() - anchor.current.at) / 1000); + return Math.max(0, anchor.current.remaining - drift); + }, [current, tick]); + + const refreshAll = useCallback(async () => { + await Promise.all([timer.reload(), today.reload()]); + }, [timer, today]); + + const call = useCallback( + async (path: string, body?: unknown, successToast?: string) => { + setBusy(true); + try { + const next = await api.post(path, body); + timer.set(next); + if (successToast) onToast(successToast); + void today.reload(); + return next; + } catch (exc) { + onToast(exc instanceof Error ? exc.message : String(exc)); + return null; + } finally { + setBusy(false); + } + }, + [onToast, timer, today], + ); + + // When the countdown hits zero, hand it to the server and announce it. + const firing = useRef(false); + useEffect(() => { + if (!current || remaining > 0 || firing.current) return; + firing.current = true; + const wasWork = current.kind === "work"; + void (async () => { + const next = await call("/api/timer/complete", { auto_next: true }); + const volume = settings?.pomodoro.alarm_volume ?? 70; + if (settings?.pomodoro.alarm_sound ?? true) { + wasWork ? sound.workDone(volume) : sound.breakDone(volume); + } + if (settings?.pomodoro.desktop_notifications ?? true) { + notify( + wasWork ? "Pomodoro complete 🍅" : "Break over ☕", + wasWork ? "Time for a break." : "Back to it.", + ); + } + if (next?.celebration) onToast("Milestone reached — check Telegram 🎉"); + firing.current = false; + })(); + }, [remaining, current, call, settings, onToast]); + + const start = async (kind: "work" | "short_break" | "long_break") => { + sound.unlock(); + await call( + "/api/timer/start", + { kind, task_id: kind === "work" && selectedTask !== "" ? selectedTask : undefined }, + kind === "work" ? "Focus interval started" : "Break started", + ); + }; + + const metrics = today.data?.metrics; + const goal = state?.goal ?? settings?.pomodoro.daily_goal_pomodoros ?? 8; + const doneToday = state?.completed_today ?? 0; + const openTasks = tasks.data?.tasks.filter((task) => !task.is_done) ?? []; + const suggestion = useMemo(() => pickSuggestion(openTasks), [openTasks]); + + const kind = current?.kind ?? state?.next_kind ?? "work"; + const total = current?.planned_seconds ?? (state ? state.durations[kind] * 60 : 1500); + + return ( + + + + + + + + + {!current && ( + <> + + + + )} + {current && ( + <> + {running ? ( + + ) : ( + + )} + + + void call("/api/timer/extend", { minutes: 5 })} + > + + + + + void call("/api/timer/interrupt")}> + + + + + setAbandonOpen(true)}> + + + + + )} + + + {current && current.interruptions > 0 && ( + + {current.interruptions} interruption{current.interruptions === 1 ? "" : "s"} this interval + + )} + + + + + + + + + + Today + + + + {doneToday} + + + / {goal} pomodoros + + + + + {Array.from({ length: Math.max(goal, doneToday) }, (_unused, index) => ( + + ))} + + {metrics && ( + + {humanMinutes(metrics.focus_minutes)} focused · {humanMinutes(metrics.break_minutes)} rested + {metrics.longest_stretch_minutes > 0 && + ` · longest stretch ${humanMinutes(metrics.longest_stretch_minutes)}`} + + )} + + Cycle {state?.cycle_position ?? 1} of {state?.cycles_before_long_break ?? 4} before a long break + + + + + + + + What are you working on? + + { + const value = event.target.value === "" ? "" : Number(event.target.value); + setSelectedTask(value); + if (current) void call("/api/timer/task", { task_id: value === "" ? null : value }); + }} + helperText={ + current?.task + ? `This interval is tracked against "${current.task.title}"` + : "Pick a task so the report knows where the time went" + } + > + + No task + + {openTasks.slice(0, 50).map((task) => ( + + {task.pinned ? "📌 " : ""} + {task.title} + {task.due_date ? ` · ${task.due_date}` : ""} + + ))} + + + {suggestion && selectedTask === "" && ( + 0 ? "warning" : "info"} + sx={{ mt: 2 }} + action={ + + } + > + Suggested: {suggestion.title} + {suggestion.days_overdue > 0 ? ` — ${suggestion.days_overdue} days overdue` : ""} + + )} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + setAbandonOpen(false)} fullWidth maxWidth="xs"> + Abandon this interval? + + + {clock(remaining)} left. {settings?.pomodoro.strict_mode ? "Strict mode wants a reason." : "A reason is optional."} + + setAbandonReason(event.target.value)} + /> + + + + + + + + ); +} + +/** Overdue first, then due today, then whatever is pinned — the obvious next thing. */ +function pickSuggestion(tasks: Task[]): Task | null { + const pinned = tasks.find((task) => task.pinned); + if (pinned) return pinned; + const overdue = tasks.filter((task) => task.bucket === "overdue"); + if (overdue.length) return overdue[0]; + const dueToday = tasks.filter((task) => task.bucket === "today"); + if (dueToday.length) return dueToday[0]; + return tasks[0] ?? null; +} diff --git a/frontend/src/theme.ts b/frontend/src/theme.ts new file mode 100644 index 0000000..03ea7ef --- /dev/null +++ b/frontend/src/theme.ts @@ -0,0 +1,62 @@ +/** + * Material theme. + * + * The seed colour from the settings drives the palette, so changing the accent + * repaints the whole app; shape, elevation and typography follow Material 3's + * defaults (rounded 16–28px surfaces, tonal containers, no hard shadows). + */ +import { alpha, createTheme, type Theme } from "@mui/material/styles"; + +export type ThemeMode = "light" | "dark"; + +export function buildTheme(mode: ThemeMode, seed: string, compact: boolean): Theme { + const dark = mode === "dark"; + return createTheme({ + palette: { + mode, + primary: { main: seed }, + secondary: { main: dark ? "#CCC2DC" : "#625B71" }, + success: { main: dark ? "#8BD5A8" : "#2E7D52" }, + warning: { main: dark ? "#E6B566" : "#9A6700" }, + error: { main: dark ? "#F2B8B5" : "#B3261E" }, + background: { + default: dark ? "#131316" : "#FBF8FD", + paper: dark ? "#1C1B20" : "#FFFFFF", + }, + }, + shape: { borderRadius: 16 }, + spacing: compact ? 6 : 8, + typography: { + fontFamily: '"Roboto", "Segoe UI", system-ui, sans-serif', + h4: { fontWeight: 500, letterSpacing: "-0.01em" }, + h5: { fontWeight: 500 }, + h6: { fontWeight: 500 }, + button: { textTransform: "none", fontWeight: 500, letterSpacing: 0 }, + overline: { letterSpacing: "0.08em", fontWeight: 500 }, + }, + components: { + MuiPaper: { + styleOverrides: { + root: ({ theme }) => ({ + backgroundImage: "none", + border: `1px solid ${alpha(theme.palette.text.primary, 0.08)}`, + }), + }, + }, + MuiCard: { + defaultProps: { elevation: 0 }, + styleOverrides: { root: { borderRadius: 20 } }, + }, + MuiButton: { + styleOverrides: { root: { borderRadius: 999, paddingInline: 20 } }, + }, + MuiChip: { styleOverrides: { root: { borderRadius: 8 } } }, + MuiTextField: { defaultProps: { size: "small" } }, + MuiTooltip: { defaultProps: { arrow: true } }, + MuiAppBar: { + defaultProps: { elevation: 0, color: "transparent" }, + styleOverrides: { root: { backdropFilter: "blur(12px)" } }, + }, + }, + }); +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..e3f25db --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "isolatedModules": true, + "resolveJsonModule": true, + "allowImportingTsExtensions": false, + "noEmit": true, + "types": ["vite/client"] + }, + "include": ["src"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..56f03c7 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +// In development the UI runs on :5173 and the API on :8000, so /api is proxied. +// In production both are served from the same origin (the backend serves the +// built files), which is why every request in the app is a relative path. +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + "/api": { + target: process.env.VITE_API_TARGET || "http://localhost:8000", + changeOrigin: true, + }, + }, + }, + build: { outDir: "dist", sourcemap: false }, +});