refactor: enhance TimerPage layout and functionality; update theme and tokens for improved design consistency

- Refactored TimerPage component to improve layout and user experience.
- Updated theme configuration to utilize design tokens for better maintainability.
- Introduced new tokens for colors and typography to align with design principles.
- Simplified state management and improved timer functionality.
- Enhanced accessibility and responsiveness of UI elements.
This commit is contained in:
timka123 committed 2026-10-18 23:57:36 +03:00
1 parent 17a0a3c4c9
commit 77b26757bf
84 files changed
+3905 -2086

No files matched your search

+25 -8
View File
@@ -33,19 +33,24 @@ and taking **all** of them:
* **Jira** — every open issue across the site (not just what is due today), with * **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 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**. project.
* **Todoist** — every open task, or your own filter query. Finishing closes it in * **Todoist** — every open task, or your own filter query.
Todoist. * **Google Tasks** — every incomplete task, including undated ones.
* **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 * **Laundry bookings** (`4ka.inbicst.ru`) — the slots you booked today, because
they are blocks of time that will interrupt your pomodoros. they are blocks of time that will interrupt your pomodoros.
* **Local tasks** — the "and this too" you type at 9am. * **Local tasks** — the "and this too" you type at 9am.
**You cannot tick a task off here.** Completion belongs to the tool the task
lives in — close it in Jira, Todoist or Google Tasks and the next refresh
notices (an item that comes back done, or that a healthy source stops returning
at all, is credited to that day). Two places to mark something done is two
places to be wrong.
Every source is pulled independently: an expired laundry cookie or a Jira outage 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 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 empty list — and nothing is ever counted as closed on a source that failed. The
instant and the daily report still works when a source is down. 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 ### A focus guard that actually blocks
Rules live on the server (edit them in the web app or the desktop app); the Rules live on the server (edit them in the web app or the desktop app); the
@@ -54,8 +59,14 @@ native desktop app enforces them on your machine:
* **Websites** — `/etc/hosts` is rewritten through a tiny privileged helper under * **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 polkit. No browser extension, no proxy, works for every app on the machine, and
you can audit it with `cat /etc/hosts`. you can audit it with `cat /etc/hosts`.
* **Masks** — `youtube.com` blocks the site plus `www.` and `m.`; `*.google.com`
blocks every subdomain in your expansion list (a hosts file has no wildcards,
so the app expands them for you and tells you when a pattern like `*gram*`
cannot be expressed that way). Exceptions take masks too, so `*.google.com`
blocked plus `*.docs.google.com` allowed does what you would expect.
* **Applications** — `/proc` is watched and blocked apps are closed (or just * **Applications** — `/proc` is watched and blocked apps are closed (or just
reported, if you prefer a warning). reported, if you prefer a warning). Entries here are globs as well: `steam*`,
`*discord*`, matched against the process name and its command line.
* **When** — only while a focus interval runs (the default), always, or on a * **When** — only while a focus interval runs (the default), always, or on a
schedule. Breaks can keep the block on or let it go. schedule. Breaks can keep the block on or let it go.
* Every attempt that gets turned away is recorded — and teased about in the * Every attempt that gets turned away is recorded — and teased about in the
@@ -192,6 +203,12 @@ and you can fill everything in from **Settings**.
per integration; `app/api/` is a thin HTTP layer. 44 tests under `backend/tests`. 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 * `frontend/` — React + TypeScript + MUI, built by Vite into the backend image and
served from the same origin (so there is no CORS to configure). served from the same origin (so there is no CORS to configure).
* **The look** — warm paper, ink type, hairline rules instead of boxes and
shadows, Fraunces for figures and headings, Inter for everything else, one
ember accent. The desktop app's `Theme` singleton carries the same tokens as
the web app's `tokens.ts`, so the two clients are one product. Chart colours
come from a separate, validated data palette: the brand is warm, but a chart
has to stay readable for colour-blind readers in both themes.
* `desktop/` — the native app. See [`desktop/README.md`](desktop/README.md). * `desktop/` — the native app. See [`desktop/README.md`](desktop/README.md).
--- ---
+12 -14
View File
@@ -1,11 +1,15 @@
"""Task endpoints: the aggregated list, refreshing it, and acting on it.""" """Task endpoints: the aggregated list and the refresh that keeps it honest.
There is no "mark done" here on purpose — a task is closed where it lives, and
the refresh notices. See `app.services.tasks_service`.
"""
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from app.api.deps import DbSession, Settings from app.api.deps import DbSession, Settings
from app.api.schemas import TaskCreateRequest, TaskDoneRequest, TaskUpdateRequest from app.api.schemas import TaskCreateRequest, TaskUpdateRequest
from app.models import TaskRecord from app.models import TaskRecord
from app.services import tasks_service from app.services import tasks_service
from app.sources.registry import SOURCE_LABELS, check_source from app.sources.registry import SOURCE_LABELS, check_source
@@ -40,12 +44,15 @@ def task_dict(record: TaskRecord, today) -> dict:
def _bucket_of(record: TaskRecord, today) -> str: def _bucket_of(record: TaskRecord, today) -> str:
if record.due_date is None: if record.due_date is not None and record.due_date < today:
return "someday"
if record.due_date < today:
return "overdue" return "overdue"
if record.due_date == today: if record.due_date == today:
return "today" return "today"
if record.start_date is not None and record.start_date <= today:
# Started, but not due today — in progress rather than "upcoming".
return "active"
if record.due_date is None:
return "someday"
return "upcoming" return "upcoming"
@@ -99,15 +106,6 @@ def update(task_id: int, body: TaskUpdateRequest, session: DbSession, settings:
return task_dict(record, local_date(settings.timezone)) 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}") @router.delete("/{task_id}")
def delete(task_id: int, session: DbSession) -> dict: def delete(task_id: int, session: DbSession) -> dict:
tasks_service.delete_task(session, task_id) tasks_service.delete_task(session, task_id)
-4
View File
@@ -50,10 +50,6 @@ class TaskUpdateRequest(BaseModel):
pinned: bool | None = None pinned: bool | None = None
class TaskDoneRequest(BaseModel):
done: bool = True
class BlockEventRequest(BaseModel): class BlockEventRequest(BaseModel):
kind: Literal["domain", "app"] = "domain" kind: Literal["domain", "app"] = "domain"
target: str = Field(min_length=1, max_length=255) target: str = Field(min_length=1, max_length=255)
+6 -1
View File
@@ -16,15 +16,20 @@ ever opening the settings page, and anything you later change in the UI wins.
from __future__ import annotations from __future__ import annotations
import os
from functools import lru_cache from functools import lru_cache
from pydantic import Field from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
# Tests set this so a developer's real `.env` — with live Jira and Telegram
# credentials in it — can never leak into a test run.
_DOTENV_FILES = () if os.environ.get("KADENCE_NO_DOTENV") else (".env", "../.env")
class EnvSettings(BaseSettings): class EnvSettings(BaseSettings):
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=(".env", "../.env"), env_file=_DOTENV_FILES,
env_file_encoding="utf-8", env_file_encoding="utf-8",
extra="ignore", extra="ignore",
) )
+12 -5
View File
@@ -90,9 +90,12 @@ class PomodoroSession(Base):
class TaskRecord(Base): class TaskRecord(Base):
"""A todo item, wherever it came from. """A todo item, wherever it came from.
External tasks are cached here on every refresh so the UI is instant and the External tasks are mirrored here on every refresh so the UI is instant and
daily report still works when Jira is down. Locally created tasks live here the daily report still works when Jira is down. Locally created tasks live
too, with `source = "local"` — they are the only ones we own outright. here too, with `source = "local"`.
Completion is never recorded from this app: you close a task where it lives,
and the next refresh notices. That keeps one source of truth per task.
""" """
__tablename__ = "tasks" __tablename__ = "tasks"
@@ -119,8 +122,12 @@ class TaskRecord(Base):
is_done: Mapped[bool] = mapped_column(Boolean, default=False, index=True) is_done: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
completed_date: Mapped[date | None] = mapped_column(Date, index=True) completed_date: Mapped[date | None] = mapped_column(Date, index=True)
completed_here: Mapped[bool] = mapped_column(Boolean, default=False) """The day the task was seen to close, in your timezone.
"""True when *this app* closed it — what the daily report counts."""
Tasks are never ticked off here — Jira, Todoist and Google Tasks own that —
so this is set when a refresh finds a task closed, or finds it gone from a
source that was otherwise healthy. That is what the daily report counts.
"""
estimated_pomodoros: Mapped[int | None] = mapped_column(Integer) estimated_pomodoros: Mapped[int | None] = mapped_column(Integer)
pinned: Mapped[bool] = mapped_column(Boolean, default=False) pinned: Mapped[bool] = mapped_column(Boolean, default=False)
+61 -13
View File
@@ -10,6 +10,7 @@ and a second machine running the desktop app obeys the same list.
from __future__ import annotations from __future__ import annotations
from datetime import date, datetime from datetime import date, datetime
from fnmatch import fnmatch
from typing import Any from typing import Any
from sqlalchemy import func, select from sqlalchemy import func, select
@@ -21,27 +22,73 @@ from app.settings_schema import AppSettings
from app.timeutil import ensure_utc, in_window, local_date, local_time from app.timeutil import ensure_utc, in_window, local_date, local_time
def is_allowed(host: str, allow_masks: list[str]) -> bool:
"""Whether an allow-list entry (plain or masked) covers this hostname."""
return any(fnmatch(host, mask) for mask in allow_masks)
def expand_entry(raw: str, subdomains: list[str]) -> tuple[list[str], str | None]:
"""Turn one blocklist entry into concrete hostnames.
A hosts file has no wildcards, so a mask has to be expanded into real names:
* `youtube.com` → `youtube.com`, `www.youtube.com`, `m.youtube.com`
* `*.youtube.com` → the bare domain plus every configured subdomain
* `*gram*` → nothing expandable; returned as a leftover pattern so the
UI can say so instead of silently dropping it
Returns `(hostnames, unexpandable_pattern)`.
"""
entry = raw.strip().lower().lstrip(".")
if not entry:
return [], None
if entry.startswith("*."):
base = entry[2:]
if not base or "*" in base:
return [], entry
return [base, *[f"{prefix}.{base}" for prefix in subdomains]], None
if "*" in entry:
return [], entry
return [entry, f"www.{entry}", f"m.{entry}"], None
def expand_domains(settings: AppSettings) -> list[str]: def expand_domains(settings: AppSettings) -> list[str]:
"""Every hostname the desktop app should point at the redirect IP. """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 `youtube.com` means "that site", so `www.` and `m.` come along; masks are
subdomains come along; anything in `allowed_domains` is removed again, which expanded as described above; and anything the allow list covers — itself
is how `youtube.com` blocked + `music.youtube.com` allowed works. maskable — is taken back out, which is how `*.google.com` blocked plus
`*.docs.google.com` allowed works.
""" """
blocker = settings.blocker blocker = settings.blocker
allowed = {entry.strip().lower().lstrip(".") for entry in blocker.allowed_domains if entry.strip()} allow_masks = [
entry.strip().lower().lstrip(".") for entry in blocker.allowed_domains if entry.strip()
]
subdomains = [part.strip().lower() for part in blocker.subdomain_expansions if part.strip()]
hosts: list[str] = [] hosts: list[str] = []
for raw in blocker.blocked_domains: for raw in blocker.blocked_domains:
domain = raw.strip().lower().lstrip(".") expanded, _ = expand_entry(raw, subdomains)
if not domain or domain in allowed: for host in expanded:
continue if host not in hosts and not is_allowed(host, allow_masks):
for candidate in (domain, f"www.{domain}", f"m.{domain}"): hosts.append(host)
if candidate not in allowed and candidate not in hosts:
hosts.append(candidate)
return hosts return hosts
def unexpandable_masks(settings: AppSettings) -> list[str]:
"""Blocklist entries a hosts file cannot express, so the UI can flag them."""
subdomains = [part.strip().lower() for part in settings.blocker.subdomain_expansions if part.strip()]
leftovers: list[str] = []
for raw in settings.blocker.blocked_domains:
_, pattern = expand_entry(raw, subdomains)
if pattern and pattern not in leftovers:
leftovers.append(pattern)
return leftovers
def schedule_active(settings: AppSettings, moment: datetime | None = None) -> bool: def schedule_active(settings: AppSettings, moment: datetime | None = None) -> bool:
now_time = local_time(settings.timezone, moment) now_time = local_time(settings.timezone, moment)
weekday = (moment or datetime.now()).weekday() weekday = (moment or datetime.now()).weekday()
@@ -86,6 +133,7 @@ def policy(session: Session, settings: AppSettings) -> dict[str, Any]:
"redirect_ip": blocker.redirect_ip, "redirect_ip": blocker.redirect_ip,
"domains": expand_domains(settings) if active else [], "domains": expand_domains(settings) if active else [],
"apps": [app.strip() for app in blocker.blocked_apps if app.strip()] if active else [], "apps": [app.strip() for app in blocker.blocked_apps if app.strip()] if active else [],
"unexpandable": unexpandable_masks(settings),
"kill_apps": blocker.kill_blocked_apps, "kill_apps": blocker.kill_blocked_apps,
"notify_on_block": blocker.notify_on_block, "notify_on_block": blocker.notify_on_block,
"poll_seconds": blocker.app_poll_seconds, "poll_seconds": blocker.app_poll_seconds,
@@ -148,10 +196,10 @@ def events_for_day(session: Session, settings: AppSettings, day: date | None = N
DEFAULT_DOMAINS = [ DEFAULT_DOMAINS = [
"youtube.com", "*.youtube.com",
"twitter.com", "twitter.com",
"x.com", "x.com",
"instagram.com", "*.instagram.com",
"tiktok.com", "tiktok.com",
"reddit.com", "reddit.com",
"facebook.com", "facebook.com",
@@ -162,4 +210,4 @@ DEFAULT_DOMAINS = [
"pikabu.ru", "pikabu.ru",
] ]
DEFAULT_APPS = ["steam", "discord", "telegram-desktop", "spotify"] DEFAULT_APPS = ["steam*", "discord", "telegram-desktop", "spotify"]
+4 -1
View File
@@ -115,7 +115,9 @@ def start(
task_id: int | None = None, task_id: int | None = None,
note: str | None = None, note: str | None = None,
) -> PomodoroSession: ) -> PomodoroSession:
"""Begin an interval, abandoning whatever was still running.""" if not settings.pomodoro.timer_enabled:
raise TimerError("Timer is disabled (view-only mode).")
running = current_session(session) running = current_session(session)
if running is not None: if running is not None:
_finish(session, settings, running, status="abandoned", reason="superseded") _finish(session, settings, running, status="abandoned", reason="superseded")
@@ -317,6 +319,7 @@ def state(session: Session, settings: AppSettings) -> dict[str, Any]:
"long_break": settings.pomodoro.long_break_minutes, "long_break": settings.pomodoro.long_break_minutes,
}, },
"server_time": utcnow().isoformat(), "server_time": utcnow().isoformat(),
"timer_enabled": settings.pomodoro.timer_enabled,
} }
+3 -1
View File
@@ -115,6 +115,7 @@ def build_daily(session: Session, settings: AppSettings, day: date) -> dict[str,
"open_tasks": { "open_tasks": {
"overdue": len(buckets["overdue"]), "overdue": len(buckets["overdue"]),
"today": len(buckets["today"]), "today": len(buckets["today"]),
"active": len(buckets["active"]),
"upcoming": len(buckets["upcoming"]), "upcoming": len(buckets["upcoming"]),
"someday": len(buckets["someday"]), "someday": len(buckets["someday"]),
}, },
@@ -209,7 +210,7 @@ def render_daily_telegram(payload: dict[str, Any], settings: AppSettings) -> str
if report_settings.include_task_list and tasks: if report_settings.include_task_list and tasks:
lines.append("") lines.append("")
lines.append( lines.append(
f"<b>{'✅ Закрытые задачи' if ru else '✅ Tasks closed'} ({len(tasks)})</b>" f"<b>{'✅ Закрыто за день' if ru else '✅ Closed today'} ({len(tasks)})</b>"
) )
for task in tasks[: report_settings.max_tasks_listed]: for task in tasks[: report_settings.max_tasks_listed]:
label = SOURCE_LABELS.get(task["source"], task["source"]) label = SOURCE_LABELS.get(task["source"], task["source"])
@@ -355,6 +356,7 @@ def render_morning_briefing(session: Session, settings: AppSettings) -> str:
section("Просрочено" if ru else "Overdue", buckets["overdue"], "🔴") section("Просрочено" if ru else "Overdue", buckets["overdue"], "🔴")
section("На сегодня" if ru else "Due today", buckets["today"], "🟡") section("На сегодня" if ru else "Due today", buckets["today"], "🟡")
section("Было бы неплохо начать" if ru else "Would be nice to start", buckets["active"], "🟢")
section("Скоро" if ru else "Coming up", buckets["upcoming"][:5], "🔵") section("Скоро" if ru else "Coming up", buckets["upcoming"][:5], "🔵")
laundry = [task for task in tasks if task.source == "laundry"] laundry = [task for task in tasks if task.source == "laundry"]
+4 -6
View File
@@ -66,12 +66,10 @@ def day_metrics(session: Session, settings: AppSettings, day: date) -> dict[str,
dropped_seconds = sum(r.actual_seconds for r in work_dropped) 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) by_hour = Counter(to_local(r.started_at, tz_name).hour for r in work_done)
# Closed in Jira / Todoist / Google and noticed by a refresh — this app
# never ticks a task off itself.
tasks = list( tasks = list(
session.scalars( session.scalars(select(TaskRecord).where(TaskRecord.completed_date == day))
select(TaskRecord).where(
TaskRecord.completed_date == day, TaskRecord.completed_here.is_(True)
)
)
) )
blocks = list( blocks = list(
@@ -272,7 +270,7 @@ def lifetime(session: Session, settings: AppSettings) -> dict[str, Any]:
) )
).one() ).one()
tasks = session.scalar( tasks = session.scalar(
select(func.count(TaskRecord.id)).where(TaskRecord.completed_here.is_(True)) select(func.count(TaskRecord.id)).where(TaskRecord.completed_date.is_not(None))
) )
blocked = session.scalar(select(func.count(BlockEvent.id))) blocked = session.scalar(select(func.count(BlockEvent.id)))
return { return {
+47 -68
View File
@@ -1,9 +1,14 @@
"""The task list: refreshing it from every source, and acting on it. """The task list: refreshing it from every source, and reading it.
External tasks are mirrored into the `tasks` table on each refresh. The mirror 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 buys two things a live call cannot: the UI stays instant, and the daily report
still works when a source is down, and "I finished this" is recorded locally still works when a source is down.
even if the write-back to Jira fails.
Nothing here closes a task. Jira, Todoist and Google Tasks each own completion
for their own items, and a second place to tick things off is a second place to
be wrong — so the refresh *detects* what you closed in those tools instead:
an item that comes back done, or that a healthy source stops returning at all,
is recorded as closed today.
""" """
from __future__ import annotations from __future__ import annotations
@@ -19,7 +24,7 @@ from sqlalchemy.orm import Session
from app.models import TaskRecord, utcnow from app.models import TaskRecord, utcnow
from app.settings_schema import AppSettings from app.settings_schema import AppSettings
from app.sources.base import SourceResult, TodoItem from app.sources.base import SourceResult, TodoItem
from app.sources.registry import SOURCE_LABELS, complete_remote, fetch_all from app.sources.registry import SOURCE_LABELS, fetch_all
from app.timeutil import local_date from app.timeutil import local_date
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -30,7 +35,12 @@ LOCAL_SOURCE = "local"
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# Refresh # Refresh
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
def _upsert(session: Session, item: TodoItem) -> TaskRecord: # Laundry slots vanish from the grid when they finish; that is the washing
# machine's business, not a task you completed.
_TRANSIENT_SOURCES = frozenset({"laundry"})
def _upsert(session: Session, item: TodoItem, today: date) -> TaskRecord:
record = session.scalar( record = session.scalar(
select(TaskRecord).where( select(TaskRecord).where(
TaskRecord.source == item.source, TaskRecord.source == item.source,
@@ -54,18 +64,24 @@ def _upsert(session: Session, item: TodoItem) -> TaskRecord:
record.last_seen_at = utcnow() record.last_seen_at = utcnow()
record.stale = False record.stale = False
if item.is_done and not record.is_done: if item.is_done and not record.is_done:
# Closed in the source, not here: it counts as done but not as "mine". _mark_closed(record, today)
return record
def _mark_closed(record: TaskRecord, today: date) -> None:
"""Record that a task is finished, as of the first refresh that saw it so."""
record.is_done = True record.is_done = True
record.completed_at = record.completed_at or utcnow() record.completed_at = record.completed_at or utcnow()
return record record.completed_date = record.completed_date or today
def refresh_tasks(session: Session, settings: AppSettings) -> dict[str, Any]: def refresh_tasks(session: Session, settings: AppSettings) -> dict[str, Any]:
"""Pull every enabled source and reconcile the mirror. """Pull every enabled source and reconcile the mirror.
Tasks a source stops returning are flagged `stale` rather than deleted: the A task a *healthy* source stops returning has left your plate — Jira drops
history behind "you finished 6 things today" has to survive a source that it from "open issues" the moment you move it to Done — so it is recorded as
quietly drops an item. closed today rather than deleted. Nothing is ever dropped on a source that
failed: an outage must not look like a productive afternoon.
""" """
results: list[SourceResult] = fetch_all(settings) results: list[SourceResult] = fetch_all(settings)
today = local_date(settings.timezone) today = local_date(settings.timezone)
@@ -86,7 +102,7 @@ def refresh_tasks(session: Session, settings: AppSettings) -> dict[str, Any]:
seen_ids: set[str] = set() seen_ids: set[str] = set()
for item in result.items: for item in result.items:
_upsert(session, item) _upsert(session, item, today)
seen_ids.add(item.external_id) seen_ids.add(item.external_id)
existing = session.scalars( existing = session.scalars(
@@ -94,9 +110,15 @@ def refresh_tasks(session: Session, settings: AppSettings) -> dict[str, Any]:
TaskRecord.source == result.source, TaskRecord.is_done.is_(False) TaskRecord.source == result.source, TaskRecord.is_done.is_(False)
) )
).all() ).all()
closed = 0
for record in existing: for record in existing:
if record.external_id not in seen_ids: if record.external_id in seen_ids:
continue
if result.source in _TRANSIENT_SOURCES:
record.stale = True record.stale = True
continue
_mark_closed(record, today)
closed += 1
summary.append( summary.append(
{ {
@@ -105,6 +127,7 @@ def refresh_tasks(session: Session, settings: AppSettings) -> dict[str, Any]:
"ok": True, "ok": True,
"error": None, "error": None,
"count": len(result.items), "count": len(result.items),
"closed": closed,
} }
) )
@@ -114,6 +137,7 @@ def refresh_tasks(session: Session, settings: AppSettings) -> dict[str, Any]:
"date": today.isoformat(), "date": today.isoformat(),
"sources": summary, "sources": summary,
"total": sum(entry["count"] for entry in summary), "total": sum(entry["count"] for entry in summary),
"closed": sum(entry.get("closed", 0) for entry in summary),
} }
@@ -155,26 +179,28 @@ def bucket_tasks(records: Iterable[TaskRecord], today: date) -> dict[str, list[T
buckets: dict[str, list[TaskRecord]] = { buckets: dict[str, list[TaskRecord]] = {
"overdue": [], "overdue": [],
"today": [], "today": [],
"active": [],
"upcoming": [], "upcoming": [],
"someday": [], "someday": [],
} }
for record in records: for record in records:
if record.due_date is None: if record.due_date is not None and record.due_date < today:
buckets["someday"].append(record)
elif record.due_date < today:
buckets["overdue"].append(record) buckets["overdue"].append(record)
elif record.due_date == today: elif record.due_date == today:
buckets["today"].append(record) buckets["today"].append(record)
elif record.start_date is not None and record.start_date <= today:
# Started, but not due today — in progress rather than "upcoming".
buckets["active"].append(record)
elif record.due_date is None:
buckets["someday"].append(record)
else: else:
buckets["upcoming"].append(record) buckets["upcoming"].append(record)
return buckets return buckets
def completed_on(session: Session, day: date, *, only_here: bool = True) -> list[TaskRecord]: def completed_on(session: Session, day: date) -> list[TaskRecord]:
query = select(TaskRecord).where(TaskRecord.completed_date == day) """Tasks seen to close on `day`, whichever tool they were closed in."""
if only_here: return list(session.scalars(select(TaskRecord).where(TaskRecord.completed_date == day)))
query = query.where(TaskRecord.completed_here.is_(True))
return list(session.scalars(query))
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@@ -209,53 +235,6 @@ def create_local_task(
return record 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: def update_task(session: Session, task_id: int, **changes: Any) -> TaskRecord:
record = session.get(TaskRecord, task_id) record = session.get(TaskRecord, task_id)
if record is None: if record is None:
+1 -26
View File
@@ -41,7 +41,6 @@ HELP_EN = """<b>Kadence</b>
/pause · /resume — hold the clock /pause · /resume — hold the clock
/status — what is running right now /status — what is running right now
/today — your todo list, grouped /today — your todo list, grouped
/done &lt;number&gt; — close task N from /today
/report — today's report, now /report — today's report, now
/week — the weekly review /week — the weekly review
/streak — streak and all-time totals /streak — streak and all-time totals
@@ -56,7 +55,6 @@ HELP_RU = """<b>Kadence</b>
/pause · /resume — пауза и продолжение /pause · /resume — пауза и продолжение
/status — что идёт сейчас /status — что идёт сейчас
/today — список задач по группам /today — список задач по группам
/done &lt;номер&gt; — закрыть задачу N из /today
/report — отчёт за сегодня /report — отчёт за сегодня
/week — итоги недели /week — итоги недели
/streak — серия и общая статистика /streak — серия и общая статистика
@@ -171,7 +169,7 @@ def _cmd_resume(session, settings: AppSettings, _args: str) -> str:
def _cmd_today(session, settings: AppSettings, _args: str) -> str: def _cmd_today(session, settings: AppSettings, _args: str) -> str:
"""The todo list, numbered so `/done 3` has something to refer to.""" """The todo list, grouped by deadline and numbered for reading."""
ru = settings.notifications.report.language == "ru" ru = settings.notifications.report.language == "ru"
today = local_date(settings.timezone) today = local_date(settings.timezone)
tasks = tasks_service.list_tasks(session) tasks = tasks_service.list_tasks(session)
@@ -180,7 +178,6 @@ def _cmd_today(session, settings: AppSettings, _args: str) -> str:
if not ordered: if not ordered:
return "✨ Задач нет." if ru else "✨ Nothing on the list." 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 limit = settings.notifications.report.max_tasks_listed
lines = [f"<b>{'📋 Задачи' if ru else '📋 Your tasks'}</b>"] lines = [f"<b>{'📋 Задачи' if ru else '📋 Your tasks'}</b>"]
marks = {"overdue": "🔴", "today": "🟡", "upcoming": "🔵", "someday": "⚪"} marks = {"overdue": "🔴", "today": "🟡", "upcoming": "🔵", "someday": "⚪"}
@@ -196,30 +193,9 @@ def _cmd_today(session, settings: AppSettings, _args: str) -> str:
) )
if position > limit: if position > limit:
lines.append(("… и ещё " if ru else "… and ") + f"{position - limit}") lines.append(("… и ещё " if ru else "… and ") + f"{position - limit}")
lines.append("")
lines.append("<i>/done N</i>")
return "\n".join(lines) 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: def _cmd_report(session, settings: AppSettings, _args: str) -> str:
today = local_date(settings.timezone) today = local_date(settings.timezone)
_, payload = report.get_or_build_daily(session, settings, today, rebuild=True) _, payload = report.get_or_build_daily(session, settings, today, rebuild=True)
@@ -283,7 +259,6 @@ COMMANDS: dict[str, Callable] = {
"resume": _cmd_resume, "resume": _cmd_resume,
"today": _cmd_today, "today": _cmd_today,
"tasks": _cmd_today, "tasks": _cmd_today,
"done": _cmd_done,
"report": _cmd_report, "report": _cmd_report,
"week": _cmd_week, "week": _cmd_week,
"streak": _cmd_streak, "streak": _cmd_streak,
+16 -13
View File
@@ -49,6 +49,9 @@ class PomodoroSettings(BaseModel):
alarm_sound: bool = True alarm_sound: bool = True
alarm_volume: int = Field(default=70, ge=0, le=100) alarm_volume: int = Field(default=70, ge=0, le=100)
timer_enabled: bool = True
"""When false the timer controls are hidden — the app becomes view-only."""
desktop_notifications: bool = True desktop_notifications: bool = True
keep_on_top_during_work: bool = False keep_on_top_during_work: bool = False
"""Desktop app: float the window above everything while a work interval runs.""" """Desktop app: float the window above everything while a work interval runs."""
@@ -76,12 +79,6 @@ class JiraSourceSettings(BaseModel):
max_issues: int = Field(default=200, ge=1, le=2000) 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): class GoogleTasksSourceSettings(BaseModel):
"""Google Tasks, via an OAuth refresh token (see jira-helper's README).""" """Google Tasks, via an OAuth refresh token (see jira-helper's README)."""
@@ -96,8 +93,6 @@ class GoogleTasksSourceSettings(BaseModel):
include_undated: bool = True include_undated: bool = True
"""Keep tasks with no due date (jira-helper drops them; here they are todos).""" """Keep tasks with no due date (jira-helper drops them; here they are todos)."""
writeback_done: bool = True
class TodoistSourceSettings(BaseModel): class TodoistSourceSettings(BaseModel):
"""Todoist, via a personal API token.""" """Todoist, via a personal API token."""
@@ -110,8 +105,6 @@ class TodoistSourceSettings(BaseModel):
filter_query: str = "" filter_query: str = ""
"""Override the server-side filter. Empty = all open tasks.""" """Override the server-side filter. Empty = all open tasks."""
writeback_done: bool = True
class LaundrySourceSettings(BaseModel): class LaundrySourceSettings(BaseModel):
"""Campus laundry bookings — not a todo, but a thing that owns your time.""" """Campus laundry bookings — not a todo, but a thing that owns your time."""
@@ -157,13 +150,23 @@ class BlockerSettings(BaseModel):
block_during_breaks: bool = False block_during_breaks: bool = False
blocked_domains: list[str] = Field(default_factory=list) blocked_domains: list[str] = Field(default_factory=list)
"""Bare hostnames, e.g. `youtube.com`. `www.` and subdomains are added for you.""" """Hostnames or masks.
`youtube.com` blocks the site (plus `www.` and `m.`); `*.youtube.com` blocks
its subdomains too, expanded with `subdomain_expansions` because a hosts file
has no wildcards of its own.
"""
allowed_domains: list[str] = Field(default_factory=list) allowed_domains: list[str] = Field(default_factory=list)
"""Exceptions that survive a wildcard entry, e.g. `music.youtube.com`.""" """Exceptions, masks allowed: `music.youtube.com`, `*.docs.google.com`."""
subdomain_expansions: list[str] = Field(
default_factory=lambda: ["www", "m", "mobile", "app", "web", "i", "old", "new"]
)
"""What `*.example.com` turns into. Add the subdomains you actually visit."""
blocked_apps: list[str] = Field(default_factory=list) blocked_apps: list[str] = Field(default_factory=list)
"""Process names, e.g. `steam`, `discord`, `Telegram`.""" """Process names or masks: `steam`, `*discord*`, `telegram-*`."""
kill_blocked_apps: bool = True kill_blocked_apps: bool = True
"""Terminate a blocked app when it appears; otherwise only warn and log.""" """Terminate a blocked app when it appears; otherwise only warn and log."""
-3
View File
@@ -63,8 +63,5 @@ class TodoSource(Protocol):
def fetch(self) -> list[TodoItem]: def fetch(self) -> list[TodoItem]:
"""Every open todo this source knows about.""" """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: def check(self) -> str:
"""Verify credentials; return a short human description of the account.""" """Verify credentials; return a short human description of the account."""
-19
View File
@@ -142,25 +142,6 @@ class GoogleTasksSource:
def check(self) -> str: def check(self) -> str:
return f"{len(self._task_lists())} Google task list(s) visible" 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: def close(self) -> None:
self._client.close() self._client.close()
+2 -41
View File
@@ -6,8 +6,8 @@ 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 "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. something to start a pomodoro on even when nothing is due.
Writing back is new here: finishing a Jira-backed pomodoro task transitions the Nothing is written back: you close an issue in Jira, and the next refresh
issue to Done, so the app is not a second place to keep your task list in sync. notices it has left the "open issues" query. One owner per task.
""" """
from __future__ import annotations from __future__ import annotations
@@ -110,45 +110,6 @@ class JiraSource:
me = self._request("GET", "/rest/api/3/myself") or {} me = self._request("GET", "/rest/api/3/myself") or {}
return f"{me.get('displayName', 'unknown')} on {self._base_url}" 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: def close(self) -> None:
self._client.close() self._client.close()
-3
View File
@@ -133,9 +133,6 @@ class LaundrySource:
self._check_session() self._check_session()
return f"signed in at {self._base_url}" 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: def close(self) -> None:
self._client.close() self._client.close()
-15
View File
@@ -93,21 +93,6 @@ def fetch_all(settings: AppSettings) -> list[SourceResult]:
_close(source) _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]: def check_source(settings: AppSettings, name: str) -> tuple[bool, str]:
"""Verify one source's credentials — what the settings page's Test button calls.""" """Verify one source's credentials — what the settings page's Test button calls."""
sources = build_sources(settings) sources = build_sources(settings)
-12
View File
@@ -117,18 +117,6 @@ class TodoistSource:
projects = self._projects() projects = self._projects()
return f"{len(projects)} Todoist project(s) visible" 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: def close(self) -> None:
self._client.close() self._client.close()
+3
View File
@@ -12,6 +12,9 @@ import tempfile
import pytest import pytest
# Before anything imports the settings: no `.env`, ever. A developer's real
# file would otherwise point the suite at live Jira, Telegram and laundry.
os.environ["KADENCE_NO_DOTENV"] = "1"
os.environ.setdefault("DATABASE_URL", "sqlite:///" + tempfile.mktemp(suffix=".db")) os.environ.setdefault("DATABASE_URL", "sqlite:///" + tempfile.mktemp(suffix=".db"))
os.environ["SCHEDULER_ENABLED"] = "false" os.environ["SCHEDULER_ENABLED"] = "false"
os.environ["TELEGRAM_BOT_ENABLED"] = "false" os.environ["TELEGRAM_BOT_ENABLED"] = "false"
+12 -3
View File
@@ -24,9 +24,6 @@ def test_a_whole_pomodoro_through_the_api(client):
assert done["finished"]["status"] == "completed" assert done["finished"]["status"] == "completed"
assert done["completed_today"] == 1 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() report = client.get("/api/reports/daily").json()
assert report["report"]["metrics"]["pomodoros"] == 1 assert report["report"]["metrics"]["pomodoros"] == 1
assert "Daily report" in report["telegram_preview"] assert "Daily report" in report["telegram_preview"]
@@ -37,6 +34,18 @@ def test_timer_errors_are_400_not_500(client):
assert client.post("/api/timer/complete", json={}).status_code == 400 assert client.post("/api/timer/complete", json={}).status_code == 400
def test_tasks_cannot_be_ticked_off_here(client):
"""Completion belongs to Jira / Todoist / Google — we only notice it.
405 rather than 404 because the SPA catch-all claims unknown paths for GET;
either way there is no way to close a task through the API.
"""
task = client.post("/api/tasks", json={"title": "Not yours to close"}).json()
response = client.post(f"/api/tasks/{task['id']}/done", json={"done": True})
assert response.status_code in (404, 405)
assert client.get("/api/tasks").json()["tasks"][0]["is_done"] is False
def test_task_crud(client): def test_task_crud(client):
created = client.post("/api/tasks", json={"title": "Temporary", "priority": "p2"}).json() created = client.post("/api/tasks", json={"title": "Temporary", "priority": "p2"}).json()
listing = client.get("/api/tasks").json() listing = client.get("/api/tasks").json()
+35
View File
@@ -60,3 +60,38 @@ def test_events_are_grouped_for_the_report(session, settings):
summary = blocker_service.events_for_day(session, settings) summary = blocker_service.events_for_day(session, settings)
assert summary["total"] == 4 assert summary["total"] == 4
assert summary["by_target"][0] == {"target": "youtube.com", "count": 3} assert summary["by_target"][0] == {"target": "youtube.com", "count": 3}
def test_masked_subdomains_are_expanded(session, settings):
"""A hosts file has no wildcards, so `*.x` has to become real names."""
settings.blocker.blocked_domains = ["*.google.com"]
settings.blocker.subdomain_expansions = ["www", "mail"]
hosts = blocker_service.expand_domains(settings)
assert hosts == ["google.com", "www.google.com", "mail.google.com"]
def test_allow_masks_carve_holes_in_block_masks(session, settings):
settings.blocker.blocked_domains = ["*.google.com", "reddit.com"]
settings.blocker.subdomain_expansions = ["www", "docs"]
settings.blocker.allowed_domains = ["docs.*", "m.reddit.com"]
hosts = blocker_service.expand_domains(settings)
assert "docs.google.com" not in hosts
assert "m.reddit.com" not in hosts
assert "www.google.com" in hosts and "reddit.com" in hosts
def test_patterns_a_hosts_file_cannot_express_are_reported(session, settings):
settings.blocker.blocked_domains = ["*gram*", "youtube.com", "*.x.com"]
assert blocker_service.unexpandable_masks(settings) == ["*gram*"]
hosts = blocker_service.expand_domains(settings)
assert "youtube.com" in hosts and "x.com" in hosts
def test_app_masks_are_passed_through_untouched(session, settings):
"""Process matching happens on the desktop, where globs work directly."""
settings.blocker.enabled = True
settings.blocker.mode = "always"
settings.blocker.blocked_apps = ["steam*", "*discord*"]
settings_service.save_settings(session, settings)
policy = blocker_service.policy(session, settings)
assert policy["apps"] == ["steam*", "*discord*"]
+9
View File
@@ -91,3 +91,12 @@ def test_reap_closes_intervals_left_running(session, settings):
assert pomodoro.reap_stale(session, settings) == 1 assert pomodoro.reap_stale(session, settings) == 1
session.refresh(row) session.refresh(row)
assert row.status == "abandoned" assert row.status == "abandoned"
def test_timer_disabled_prevents_start(session, settings):
settings.pomodoro.timer_enabled = False
settings_service.save_settings(session, settings)
with pytest.raises(pomodoro.TimerError, match="view-only"):
pomodoro.start(session, settings, kind="work")
st = pomodoro.state(session, settings)
assert st["timer_enabled"] is False
+11 -3
View File
@@ -5,8 +5,16 @@ from __future__ import annotations
from datetime import timedelta from datetime import timedelta
from app.models import utcnow from app.models import utcnow
from app.services import pomodoro, report, settings_service, stats, tasks_service
from app.timeutil import local_date from app.timeutil import local_date
from app.services import pomodoro, report, settings_service, stats, tasks_service
def _close(session, settings, task) -> None:
"""Simulate what a refresh does when a task has been closed in its own tool."""
task.is_done = True
task.completed_at = utcnow()
task.completed_date = local_date(settings.timezone)
session.commit()
def _run_pomodoros(session, settings, count: int, *, minutes: int = 25) -> None: def _run_pomodoros(session, settings, count: int, *, minutes: int = 25) -> None:
@@ -57,7 +65,7 @@ def test_report_payload_has_the_pieces_the_ui_draws(session, settings):
settings_service.save_settings(session, settings) settings_service.save_settings(session, settings)
_run_pomodoros(session, settings, 2) _run_pomodoros(session, settings, 2)
task = tasks_service.create_local_task(session, title="Ship the thing") task = tasks_service.create_local_task(session, title="Ship the thing")
tasks_service.complete_task(session, settings, task.id) _close(session, settings, task)
payload = report.build_daily(session, settings, local_date(settings.timezone)) payload = report.build_daily(session, settings, local_date(settings.timezone))
assert payload["metrics"]["pomodoros"] == 2 assert payload["metrics"]["pomodoros"] == 2
@@ -69,7 +77,7 @@ def test_report_payload_has_the_pieces_the_ui_draws(session, settings):
def test_rendered_report_escapes_borrowed_text(session, settings): def test_rendered_report_escapes_borrowed_text(session, settings):
task = tasks_service.create_local_task(session, title="Fix <b>&</b> ship") task = tasks_service.create_local_task(session, title="Fix <b>&</b> ship")
tasks_service.complete_task(session, settings, task.id) _close(session, settings, task)
payload = report.build_daily(session, settings, local_date(settings.timezone)) payload = report.build_daily(session, settings, local_date(settings.timezone))
text = report.render_daily_telegram(payload, settings) text = report.render_daily_telegram(payload, settings)
assert "Fix &lt;b&gt;&amp;&lt;/b&gt; ship" in text assert "Fix &lt;b&gt;&amp;&lt;/b&gt; ship" in text
+6 -12
View File
@@ -28,25 +28,19 @@ def test_stop_without_a_timer_explains_itself(session):
assert "No interval is running" in telegram_bot.handle_text("/stop") assert "No interval is running" in telegram_bot.handle_text("/stop")
def test_today_numbers_tasks_and_done_closes_them(session): def test_today_lists_and_numbers_the_tasks(session):
settings_service.load_settings(session) settings_service.load_settings(session)
first = tasks_service.create_local_task(session, title="Alpha task") tasks_service.create_local_task(session, title="Alpha task")
tasks_service.create_local_task(session, title="Beta task") tasks_service.create_local_task(session, title="Beta task")
listing = telegram_bot.handle_text("/today") listing = telegram_bot.handle_text("/today")
assert "Alpha task" in listing and "<b>1.</b>" in listing assert "Alpha task" in listing and "<b>1.</b>" in listing
reply = telegram_bot.handle_text("/done 1")
assert "Alpha task" in reply
session.expire_all()
assert session.get(type(first), first.id).is_done is True
assert "No task with that number" in telegram_bot.handle_text("/done 99") def test_there_is_no_done_command(session):
"""Tasks are closed in their own tool; the bot must not pretend otherwise."""
assert telegram_bot.handle_text("/done 1") is None
def test_done_without_a_listing_asks_for_one(session): assert "/done" not in telegram_bot.handle_text("/help")
telegram_bot._INDEX.clear()
assert "/today" in telegram_bot.handle_text("/done 1")
def test_report_and_streak_render(session): def test_report_and_streak_render(session):
+71 -3
View File
@@ -7,7 +7,9 @@ system package `python-pyqt6`; everything else — HTTP, config, process scannin
is the Python standard library. is the Python standard library.
It is also the piece that *enforces* things, because it is the only part running It is also the piece that *enforces* things, because it is the only part running
on your machine. on your machine. The interface is the same warm, hairline-ruled design as the
web app — the `Theme` singleton in `qml/` holds the same tokens as the web
app's `tokens.ts`.
```bash ```bash
sudo pacman -S --needed python-pyqt6 qt6-declarative # Arch sudo pacman -S --needed python-pyqt6 qt6-declarative # Arch
@@ -24,6 +26,9 @@ Run it straight from the checkout instead: `python3 -m kadence_desktop`.
| Feature | How | | Feature | How |
| --- | --- | | --- | --- |
| **A floating mini timer** | A small pill with the countdown, the task you are on, and the controls — *Pause / Resume / Finish / +5*, or *Start focus / Break* when idle — all on their own rows and all visible without hovering. Open it with **Ctrl+M**, the tray menu, or `kadence-desktop --mini`; it also appears by itself while an interval runs and the main window is away. Dragging moves it (through the compositor, so it works on Wayland) and double-clicking opens the main window. It is **not** a `Qt.Tool` window — Qt hides those whenever the application is deactivated, which made it vanish on every click in another app — and it refuses keyboard focus instead. |
| **Where it appears** | Settings → *Mini timer appears*: any of the four corners, the top/bottom edge centres, the centre of the screen, or *where I last dragged it*, with a configurable gap from the edges. It is placed on the screen the app is on, inside the usable area (panels excluded), and it returns to that spot every time it comes up. On X11 the app positions the window; on Wayland — where a client may not — the point is written into the same KWin rule. |
| **Above everything, on Wayland** | Wayland gives an application no way to ask for "always on top", so on KDE the app installs a **KWin window rule** (keep above, skip task bar, skip switcher, skip pager) and asks KWin to re-read its config over D-Bus. The rule matches the window title as a **substring**: Qt appends the application's display name, so KWin sees `Kadence mini — Kadence`, and an exact match silently never fires. The app backs the file up first, leaves your other rules alone, re-writes the rule when an upgrade changes it, and *Settings → Keep the mini timer above everything* removes it. Verify with `busctl --user call org.kde.KWin /Scripting org.kde.kwin.Scripting loadScript …` or simply: the mini timer should stay on top of a maximised window. |
| **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. | | **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. | | **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. | | **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. |
@@ -52,6 +57,8 @@ edit them and a second machine obeys the same list. This app enforces them:
* **Applications** — `/proc` is scanned every few seconds for your own processes * **Applications** — `/proc` is scanned every few seconds for your own processes
matching the list; they are terminated (SIGTERM, then SIGKILL) or merely matching the list; they are terminated (SIGTERM, then SIGKILL) or merely
reported, and every attempt is sent to the backend for the evening report. reported, and every attempt is sent to the backend for the evening report.
List entries may be globs — `steam*`, `*discord*`, `telegram-?` — matched
against both the process name and its command line.
* Everything is released when the interval ends — and on exit, including a crash, * Everything is released when the interval ends — and on exit, including a crash,
because `shutdown()` clears the block and the inhibits. because `shutdown()` clears the block and the inhibits.
@@ -76,6 +83,45 @@ sudo ./helper/install.sh --uninstall # unblocks first, then removes itself
--- ---
## Application menu and rofi
```bash
./packaging/install-desktop.sh
```
That writes `~/.local/share/applications/kadence.desktop`, the icon, and a
`~/.local/bin/kadence-desktop` launcher, then refreshes the caches each
launcher keeps (`update-desktop-database`, `gtk-update-icon-cache`,
`kbuildsycoca6` for KDE's menu).
Two details that decide whether an entry shows up at all:
* **`Exec`/`TryExec` are written as absolute paths.** Launchers run with the
*session* PATH, which usually does not include `~/.local/bin`; an entry whose
`TryExec` cannot be resolved is silently hidden. Absolute paths cannot be.
* **One main category.** `Categories=Office;ProjectManagement;` — listing both
`Utility` and `Office` makes the app appear twice in some menus.
It then shows up in KDE's launcher under *Office* and in `rofi -show drun`
(search for *pomodoro*, *timer* or *focus*), with the right-click actions
*Start a pomodoro* / *Start a break* / *Start / pause* / *Send today's report*.
If your rofi runs in **`run` mode** rather than `drun`, it lists binaries from
`PATH`, so `~/.local/bin` has to be on the session PATH:
```bash
printf 'PATH=$HOME/.local/bin:$PATH\n' > ~/.config/environment.d/10-local-bin.conf
# for the session you are already in:
systemctl --user set-environment PATH="$HOME/.local/bin:$PATH"
dbus-update-activation-environment --systemd PATH
```
To see the entry (and its actions) in rofi with its icon instead, add `drun`:
`modes: "drun,run";` in `~/.config/rofi/config.rasi`, plus
`drun-show-actions: true;` if you want the actions listed too.
---
## Global shortcuts ## Global shortcuts
System Settings → Shortcuts → Custom Shortcuts → New → D-Bus command: System Settings → Shortcuts → Custom Shortcuts → New → D-Bus command:
@@ -94,13 +140,35 @@ kadence-desktop --toggle # start → pause → resume
kadence-desktop --start # start a focus interval kadence-desktop --start # start a focus interval
kadence-desktop --break kadence-desktop --break
kadence-desktop --complete kadence-desktop --complete
kadence-desktop --mini # show/hide the floating mini timer
kadence-desktop --status # "Focus 18:42 · Write the docs · 3/8 today" kadence-desktop --status # "Focus 18:42 · Write the docs · 3/8 today"
kadence-desktop --report # send today's report to Telegram kadence-desktop --report # send today's report to Telegram
``` ```
On Qt 6 the command-line D-Bus tool is **`qdbus6`** (plain `qdbus` is the Qt 5
one and may not be installed):
```bash
qdbus6 org.kadence.Desktop /Timer org.kadence.Timer.Status
busctl --user introspect org.kadence.Desktop /Timer # lists every verb
```
The verbs live on a `QDBusAbstractAdaptor` carrying
`pyqtClassInfo("D-Bus Interface", "org.kadence.Timer")`. That detail matters:
export a plain `QObject` instead and Qt names the interface after the class
(`local.kadence.TimerDBusService`), so every documented command fails with
*"No such interface"*.
In-window shortcuts: **Space** start/pause · **Ctrl+Enter** finish · In-window shortcuts: **Space** start/pause · **Ctrl+Enter** finish ·
**Ctrl+E** +5 min · **Ctrl+R** refresh sources · **Ctrl+1…5** pages · **Ctrl+E** +5 min · **Ctrl+R** refresh sources · **Ctrl+M** mini timer ·
**Ctrl+W** hide to tray. **Ctrl+1…5** pages · **Ctrl+W** hide to tray.
> **Wayland note.** The mini timer's placement and "always on top" are a KWin rule written to
> `~/.config/kwinrulesrc` (backed up once as `kwinrulesrc.kadence-backup`); you
> can see it in System Settings → Window Management → Window Rules as *Kadence
> mini timer*, and the switch in Settings removes it. Its position is remembered
> on X11; on Wayland the compositor places it and dragging goes through
> `startSystemMove()`.
--- ---
+6 -1
View File
@@ -5,6 +5,7 @@ talk to a *running* instance over D-Bus instead of starting a second one, which
is what makes them usable as global shortcuts: is what makes them usable as global shortcuts:
kadence-desktop --toggle # bind this to a key in System Settings kadence-desktop --toggle # bind this to a key in System Settings
kadence-desktop --mini # show/hide the floating mini timer
kadence-desktop --status # prints "Focus 18:42 · Write the docs · 3/8 today" kadence-desktop --status # prints "Focus 18:42 · Write the docs · 3/8 today"
""" """
@@ -27,6 +28,7 @@ REMOTE_VERBS = {
"interrupt": "Interrupt", "interrupt": "Interrupt",
"status": "Status", "status": "Status",
"report": "SendReport", "report": "SendReport",
"mini": "ToggleMini",
"show": "Raise", "show": "Raise",
} }
@@ -53,6 +55,9 @@ def _remote(verb: str) -> int:
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser(prog="kadence-desktop", description=__doc__) parser = argparse.ArgumentParser(prog="kadence-desktop", description=__doc__)
parser.add_argument("--minimized", action="store_true", help="start in the tray only") parser.add_argument("--minimized", action="store_true", help="start in the tray only")
parser.add_argument(
"--view-only", action="store_true", help="start in view-only mode with timer turned off"
)
parser.add_argument("--verbose", action="store_true") parser.add_argument("--verbose", action="store_true")
for verb in REMOTE_VERBS: for verb in REMOTE_VERBS:
parser.add_argument( parser.add_argument(
@@ -77,7 +82,7 @@ def main() -> int:
print("Kadence is already running — raised its window.") print("Kadence is already running — raised its window.")
return 0 return 0
return KadenceApp(sys.argv, minimized=args.minimized).run() return KadenceApp(sys.argv, minimized=args.minimized, view_only=args.view_only).run()
if __name__ == "__main__": if __name__ == "__main__":
+3 -2
View File
@@ -100,8 +100,9 @@ class Api:
def refresh_tasks(self) -> dict: def refresh_tasks(self) -> dict:
return self._request("POST", "/api/tasks/refresh", {}) return self._request("POST", "/api/tasks/refresh", {})
def task_done(self, task_id: int, done: bool = True) -> dict: def patch_task(self, task_id: int, patch: dict) -> dict:
return self._request("POST", f"/api/tasks/{task_id}/done", {"done": done}) """Edit a task's local fields (pinning, estimate). Completion is not ours."""
return self._request("PATCH", f"/api/tasks/{task_id}", patch)
def create_task(self, title: str, due_date: str | None = None) -> dict: def create_task(self, title: str, due_date: str | None = None) -> dict:
return self._request("POST", "/api/tasks", {"title": title, "due_date": due_date}) return self._request("POST", "/api/tasks", {"title": title, "due_date": due_date})
+88 -2
View File
@@ -20,7 +20,7 @@ os.environ.setdefault("QT_QUICK_CONTROLS_MATERIAL_VARIANT", "Normal")
from PyQt6.QtCore import QObject, QTimer, QUrl from PyQt6.QtCore import QObject, QTimer, QUrl
from PyQt6.QtGui import QIcon from PyQt6.QtGui import QIcon
from PyQt6.QtQml import QQmlApplicationEngine from PyQt6.QtQml import QQmlApplicationEngine, QQmlComponent
from PyQt6.QtQuick import QQuickWindow from PyQt6.QtQuick import QQuickWindow
from PyQt6.QtWidgets import QApplication, QSystemTrayIcon from PyQt6.QtWidgets import QApplication, QSystemTrayIcon
@@ -28,6 +28,7 @@ from kadence_desktop import APP_ID, __version__
from kadence_desktop.config import DesktopConfig from kadence_desktop.config import DesktopConfig
from kadence_desktop.controller import Controller from kadence_desktop.controller import Controller
from kadence_desktop.integrations import dbus_service from kadence_desktop.integrations import dbus_service
from kadence_desktop.integrations.placement import Rect, point_for
from kadence_desktop.tray import Tray, render_icon from kadence_desktop.tray import Tray, render_icon
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -36,7 +37,9 @@ QML_DIR = Path(__file__).resolve().parent / "qml"
class KadenceApp(QObject): class KadenceApp(QObject):
def __init__(self, argv: list[str], *, minimized: bool = False) -> None: def __init__(
self, argv: list[str], *, minimized: bool = False, view_only: bool = False
) -> None:
super().__init__() super().__init__()
# QApplication (not QGuiApplication): the tray icon and its menu are # QApplication (not QGuiApplication): the tray icon and its menu are
# widgets, and they are what makes the app usable with no window open. # widgets, and they are what makes the app usable with no window open.
@@ -52,6 +55,8 @@ class KadenceApp(QObject):
self._app.setQuitOnLastWindowClosed(False) self._app.setQuitOnLastWindowClosed(False)
self._config = DesktopConfig.load() self._config = DesktopConfig.load()
if view_only:
self._config.view_only = True
self._controller = Controller(self._config) self._controller = Controller(self._config)
self._engine = QQmlApplicationEngine() self._engine = QQmlApplicationEngine()
@@ -63,6 +68,8 @@ class KadenceApp(QObject):
raise SystemExit("Could not load the QML interface.") raise SystemExit("Could not load the QML interface.")
self._window: QQuickWindow = self._engine.rootObjects()[0] self._window: QQuickWindow = self._engine.rootObjects()[0]
self._mini = self._create_mini_window()
self._tray: Tray | None = None self._tray: Tray | None = None
if self._config.tray_icon and QSystemTrayIcon.isSystemTrayAvailable(): if self._config.tray_icon and QSystemTrayIcon.isSystemTrayAvailable():
self._tray = Tray(self._controller, self) self._tray = Tray(self._controller, self)
@@ -73,6 +80,15 @@ class KadenceApp(QObject):
self._controller.showWindowRequested.connect(self.show_window) self._controller.showWindowRequested.connect(self.show_window)
self._app.aboutToQuit.connect(self._controller.shutdown) self._app.aboutToQuit.connect(self._controller.shutdown)
# Anything that can change "should the mini timer be up?" lands here.
self._controller.miniChanged.connect(self._update_mini)
self._controller.miniPlacementChanged.connect(self._place_mini)
self._controller.stateChanged.connect(self._update_mini)
self._controller.configChanged.connect(self._update_mini)
self._window.visibleChanged.connect(self._update_mini)
self._update_mini()
self._place_mini()
if self._config.dbus_service: if self._config.dbus_service:
self._register_dbus() self._register_dbus()
@@ -88,6 +104,73 @@ class KadenceApp(QObject):
self._wakeup.start(500) self._wakeup.start(500)
self._wakeup.timeout.connect(lambda: None) self._wakeup.timeout.connect(lambda: None)
# ---- the floating mini timer --------------------------------------------
def _create_mini_window(self) -> QObject | None:
"""Build MiniTimer.qml as a top-level window of its own."""
component = QQmlComponent(self._engine, QUrl.fromLocalFile(str(QML_DIR / "MiniTimer.qml")))
if component.isError(): # pragma: no cover - a QML typo, caught in dev
for error in component.errors():
log.error("mini timer: %s", error.toString())
return None
# No parent on purpose: a parented QML Window becomes transient and
# cannot outlive — or out-show — its parent. The Python reference on
# `self._mini` is what keeps it alive.
window = component.create()
if window is not None:
window.setProperty("objectName", "miniTimer")
return window
def _update_mini(self) -> None:
"""Show it when pinned, or when an interval runs with the window away."""
if self._mini is None:
return
config = self._config
wanted = bool(config.mini_timer) and (
self._controller.miniVisible
or (config.mini_timer_auto and self._controller.running and not self._window.isVisible())
)
if bool(self._mini.property("visible")) != wanted:
if wanted:
# Each time it comes up it lands where you asked for, even if it
# was dragged elsewhere during the last interval.
self._place_mini()
self._mini.setProperty("visible", wanted)
def _mini_screen_rect(self) -> Rect:
"""The usable area of the screen the app is on — panels excluded."""
screen = self._window.screen() or self._app.primaryScreen()
area = screen.availableGeometry()
return Rect(area.x(), area.y(), area.width(), area.height())
def _place_mini(self) -> None:
"""Put the mini timer in the configured corner.
Setting `x`/`y` is what works on X11; on Wayland the client may not
place itself, so the same point goes into the KWin rule, which is
honoured when the window is mapped.
"""
if self._mini is None:
return
config = self._config
if config.mini_timer_corner == "remember":
self._controller.ensure_keep_above(None)
if config.mini_timer_x >= 0 and config.mini_timer_y >= 0:
self._mini.setProperty("x", config.mini_timer_x)
self._mini.setProperty("y", config.mini_timer_y)
return
size = (
int(self._mini.property("width") or 252),
int(self._mini.property("height") or 152),
)
point = point_for(
config.mini_timer_corner, self._mini_screen_rect(), size, config.mini_timer_margin
)
self._mini.setProperty("x", point[0])
self._mini.setProperty("y", point[1])
self._controller.ensure_keep_above(point)
# ---- window ------------------------------------------------------------ # ---- window ------------------------------------------------------------
def show_window(self) -> None: def show_window(self) -> None:
@@ -117,6 +200,7 @@ class KadenceApp(QObject):
"interrupt": lambda: (controller.interrupt(), "noted")[1], "interrupt": lambda: (controller.interrupt(), "noted")[1],
"status": controller.statusLine, "status": controller.statusLine,
"send_report": lambda: (controller.sendReport(), "sending")[1], "send_report": lambda: (controller.sendReport(), "sending")[1],
"toggle_mini": controller.toggleMini,
"raise_window": lambda: (self.show_window(), "shown")[1], "raise_window": lambda: (self.show_window(), "shown")[1],
} }
self._dbus = dbus_service.TimerDBusService(actions, self) self._dbus = dbus_service.TimerDBusService(actions, self)
@@ -126,6 +210,8 @@ class KadenceApp(QObject):
# ---- lifecycle --------------------------------------------------------- # ---- lifecycle ---------------------------------------------------------
def quit(self) -> None: def quit(self) -> None:
if self._mini is not None:
self._mini.setProperty("visible", False)
self._controller.shutdown() self._controller.shutdown()
if self._tray: if self._tray:
self._tray.hide() self._tray.hide()
+24 -6
View File
@@ -5,9 +5,11 @@ the cmdline, matches against the configured names, and either terminates the
process (SIGTERM, then SIGKILL if it ignores us) or just reports it, depending process (SIGTERM, then SIGKILL if it ignores us) or just reports it, depending
on the setting. on the setting.
Matching is deliberately forgiving — `steam` should catch `steamwebhelper`, and Matching is deliberately forgiving — `steam` catches `steamwebhelper`, and
`telegram` should catch `Telegram` — but it never matches ourselves, PID 1, or `telegram` catches `Telegram` — and an entry containing `*` or `?` is treated as
anything outside the current user's processes. a glob (`steam*`, `*discord*`, `telegram-?`), matched against both the process
name and its command line. It never matches ourselves, PID 1, or anything
outside the current user's processes.
""" """
from __future__ import annotations from __future__ import annotations
@@ -16,6 +18,7 @@ import logging
import os import os
import signal import signal
import time import time
from fnmatch import fnmatch
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@@ -24,6 +27,21 @@ log = logging.getLogger(__name__)
_SELF_MARKERS = ("kadence", "python3 -m kadence_desktop") _SELF_MARKERS = ("kadence", "python3 -m kadence_desktop")
def matches(pattern: str, name: str, cmdline: str) -> bool:
"""Whether one block-list entry matches a process.
A mask is anchored to the process *name* (so `steam*` does not match every
command that merely mentions steam) but also tried against the whole command
line, which is how `*discord*` catches an Electron app launched by path.
"""
pattern = pattern.strip().lower()
if not pattern:
return False
if any(char in pattern for char in "*?["):
return fnmatch(name, pattern) or fnmatch(cmdline, pattern)
return pattern in name or pattern in cmdline
@dataclass(slots=True) @dataclass(slots=True)
class RunningApp: class RunningApp:
pid: int pid: int
@@ -65,11 +83,11 @@ def scan(patterns: list[str]) -> list[RunningApp]:
if not comm: if not comm:
continue continue
cmdline = _read(entry / "cmdline").replace("\x00", " ").strip() cmdline = _read(entry / "cmdline").replace("\x00", " ").strip()
haystack = f"{comm} {cmdline}".lower() name, command = comm.lower(), cmdline.lower()
if any(marker in haystack for marker in _SELF_MARKERS): if any(marker in f"{name} {command}" for marker in _SELF_MARKERS):
continue continue
match = next((pattern for pattern in wanted if pattern in haystack), None) match = next((pattern for pattern in wanted if matches(pattern, name, command)), None)
if match: if match:
found.append(RunningApp(pid=pid, name=comm, cmdline=cmdline[:200], matched=match)) found.append(RunningApp(pid=pid, name=comm, cmdline=cmdline[:200], matched=match))
return found return found
+32
View File
@@ -38,6 +38,38 @@ class DesktopConfig:
start_minimized: bool = False start_minimized: bool = False
close_to_tray: bool = True close_to_tray: bool = True
view_only: bool = False
"""When true, the app runs in view-only mode with the timer turned off."""
# ---- the floating mini timer ------------------------------------------
mini_timer: bool = True
"""Allow the small always-on-top countdown window at all."""
mini_timer_auto: bool = True
"""Show it by itself while an interval runs and the main window is away."""
mini_timer_keep_above: bool = True
"""On KDE, install a KWin rule so it really floats above every window.
Wayland gives an application no way to ask for "always on top", so this is
the only honest route; see `integrations.keep_above`. Switching it off
removes the rule again.
"""
mini_timer_corner: str = "bottom-right"
"""Where it appears: a screen corner, an edge centre, or `remember`.
One of: top-left, top-centre, top-right, bottom-left, bottom-centre,
bottom-right, centre, remember. `remember` puts it back where you last
dragged it instead.
"""
mini_timer_margin: int = 24
"""Gap between the mini timer and the screen edges, in pixels."""
mini_timer_x: int = -1
mini_timer_y: int = -1
"""Where it was last dragged to. -1 = let the compositor place it."""
# ---- system integration (each one is a real desktop feature) ----------- # ---- system integration (each one is a real desktop feature) -----------
tray_icon: bool = True tray_icon: bool = True
+103 -6
View File
@@ -25,7 +25,7 @@ from PyQt6.QtGui import QDesktopServices
from kadence_desktop.api import Api from kadence_desktop.api import Api
from kadence_desktop.blocking.manager import BlockerManager from kadence_desktop.blocking.manager import BlockerManager
from kadence_desktop.config import DesktopConfig from kadence_desktop.config import DesktopConfig
from kadence_desktop.integrations import autostart from kadence_desktop.integrations import autostart, keep_above
from kadence_desktop.integrations.inhibit import DoNotDisturb, ScreensaverInhibitor from kadence_desktop.integrations.inhibit import DoNotDisturb, ScreensaverInhibitor
from kadence_desktop.integrations.launcher import LauncherEntry from kadence_desktop.integrations.launcher import LauncherEntry
from kadence_desktop.integrations.mpris import MediaController from kadence_desktop.integrations.mpris import MediaController
@@ -53,6 +53,8 @@ class Controller(QObject):
blockerChanged = pyqtSignal() blockerChanged = pyqtSignal()
connectionChanged = pyqtSignal() connectionChanged = pyqtSignal()
configChanged = pyqtSignal() configChanged = pyqtSignal()
miniChanged = pyqtSignal()
miniPlacementChanged = pyqtSignal()
toast = pyqtSignal(str) toast = pyqtSignal(str)
showWindowRequested = pyqtSignal() showWindowRequested = pyqtSignal()
@@ -77,6 +79,7 @@ class Controller(QObject):
self._last_status = "" self._last_status = ""
self._completing = False self._completing = False
self._paused_by_lock = False self._paused_by_lock = False
self._mini_visible = False
# ---- system integrations ------------------------------------------- # ---- system integrations -------------------------------------------
self._notifier = Notifier() self._notifier = Notifier()
@@ -119,6 +122,9 @@ class Controller(QObject):
self._tick.timeout.connect(self._on_tick) self._tick.timeout.connect(self._on_tick)
self._tick.start() self._tick.start()
# A Wayland compositor ignores "stay on top", so ask KWin properly.
self.ensure_keep_above()
self._idle_check = QTimer(self) self._idle_check = QTimer(self)
self._idle_check.setInterval(30_000) self._idle_check.setInterval(30_000)
self._idle_check.timeout.connect(self._check_idle) self._idle_check.timeout.connect(self._check_idle)
@@ -219,6 +225,14 @@ class Controller(QObject):
def nextKindLabel(self) -> str: def nextKindLabel(self) -> str:
return KIND_LABELS.get(str(self._state.get("next_kind", "work")), "Focus") return KIND_LABELS.get(str(self._state.get("next_kind", "work")), "Focus")
@pyqtProperty(bool, notify=settingsChanged)
def timerEnabled(self) -> bool:
"""When False the app is in view-only mode — no timer controls."""
if self._config.view_only:
return False
pomodoro = self._settings.get("pomodoro", {})
return bool(pomodoro.get("timer_enabled", True))
@pyqtProperty("QVariantList", notify=tasksChanged) @pyqtProperty("QVariantList", notify=tasksChanged)
def tasks(self) -> list[dict[str, Any]]: def tasks(self) -> list[dict[str, Any]]:
return self._tasks return self._tasks
@@ -263,6 +277,11 @@ class Controller(QObject):
def blockerError(self) -> str: def blockerError(self) -> str:
return self._blocker.last_error or "" return self._blocker.last_error or ""
@pyqtProperty(bool, notify=miniChanged)
def miniVisible(self) -> bool:
"""Whether the floating mini timer is pinned open by the user."""
return self._mini_visible
@pyqtProperty("QVariantMap", notify=configChanged) @pyqtProperty("QVariantMap", notify=configChanged)
def config(self) -> dict[str, Any]: def config(self) -> dict[str, Any]:
from dataclasses import asdict from dataclasses import asdict
@@ -270,6 +289,8 @@ class Controller(QObject):
data = asdict(self._config) data = asdict(self._config)
data["autostart"] = autostart.autostart_enabled() data["autostart"] = autostart.autostart_enabled()
data["service_enabled"] = autostart.service_enabled() data["service_enabled"] = autostart.service_enabled()
data["keep_above_supported"] = keep_above.supported()
data["keep_above_installed"] = keep_above.installed()
return data return data
# ====================================================================== # ======================================================================
@@ -289,6 +310,8 @@ class Controller(QObject):
@pyqtSlot() @pyqtSlot()
@pyqtSlot(int) @pyqtSlot(int)
def startWork(self, minutes: int = 0) -> None: def startWork(self, minutes: int = 0) -> None:
if not self.timerEnabled:
return
args: dict[str, Any] = {"kind": "work"} args: dict[str, Any] = {"kind": "work"}
if minutes: if minutes:
args["minutes"] = minutes args["minutes"] = minutes
@@ -301,12 +324,16 @@ class Controller(QObject):
@pyqtSlot() @pyqtSlot()
def startBreak(self) -> None: def startBreak(self) -> None:
if not self.timerEnabled:
return
kind = str(self._state.get("next_kind", "short_break")) kind = str(self._state.get("next_kind", "short_break"))
self._send("start", {"kind": "long_break" if kind == "long_break" else "short_break"}) self._send("start", {"kind": "long_break" if kind == "long_break" else "short_break"})
@pyqtSlot() @pyqtSlot()
def toggle(self) -> None: def toggle(self) -> None:
"""One verb for a global hotkey: start → pause → resume.""" """One verb for a global hotkey: start → pause → resume."""
if not self.timerEnabled:
return
if not self.running: if not self.running:
self.startWork() self.startWork()
elif self.status == "paused": elif self.status == "paused":
@@ -314,6 +341,14 @@ class Controller(QObject):
else: else:
self.pause() self.pause()
@pyqtSlot(bool)
def setTimerEnabled(self, enabled: bool) -> None:
"""Toggle view-only mode on or off."""
self._config.update(view_only=not enabled)
self.configChanged.emit()
self.settingsChanged.emit()
self.patchSettings({"pomodoro": {"timer_enabled": enabled}})
@pyqtSlot() @pyqtSlot()
def pause(self) -> None: def pause(self) -> None:
self._send("pause") self._send("pause")
@@ -345,8 +380,9 @@ class Controller(QObject):
self._send("attach_task", {"task_id": task_id if task_id >= 0 else None}) self._send("attach_task", {"task_id": task_id if task_id >= 0 else None})
@pyqtSlot(int, bool) @pyqtSlot(int, bool)
def setTaskDone(self, task_id: int, done: bool) -> None: def setTaskPinned(self, task_id: int, pinned: bool) -> None:
self._send("task_done", {"task_id": task_id, "done": done}) """Pinning is the only task edit this app makes — closing one is not."""
self._send("patch_task", {"task_id": task_id, "patch": {"pinned": pinned}})
@pyqtSlot(str, str) @pyqtSlot(str, str)
def createTask(self, title: str, due_date: str) -> None: def createTask(self, title: str, due_date: str) -> None:
@@ -413,10 +449,66 @@ class Controller(QObject):
self._blocker.enforce_hosts = bool(value) self._blocker.enforce_hosts = bool(value)
elif key == "block_apps": elif key == "block_apps":
self._blocker.enforce_apps = bool(value) self._blocker.enforce_apps = bool(value)
elif key == "mini_timer_keep_above":
ok, message = keep_above.apply() if value else keep_above.remove()
self.toast.emit(message)
if not ok:
self._config.update(mini_timer_keep_above=not value)
elif key in ("mini_timer_corner", "mini_timer_margin"):
self.miniPlacementChanged.emit()
elif key == "enforce_blocking" and not value: elif key == "enforce_blocking" and not value:
self._blocker.release() self._blocker.release()
self.configChanged.emit() self.configChanged.emit()
@pyqtSlot()
@pyqtSlot(bool)
def setMiniVisible(self, visible: bool = True) -> None:
if visible == self._mini_visible:
return
self._mini_visible = visible
self.miniChanged.emit()
@pyqtSlot(result=str)
def toggleMini(self) -> str:
"""Tray menu, Ctrl+M and the D-Bus verb all land here."""
if not self._config.mini_timer:
self._config.update(mini_timer=True)
self.configChanged.emit()
self.setMiniVisible(not self._mini_visible)
if self._mini_visible:
self.ensure_keep_above()
return "shown" if self._mini_visible else "hidden"
def ensure_keep_above(
self, position: tuple[int, int] | None = None, *, announce: bool = False
) -> None:
"""Install or refresh the KWin rule, including where to place the window.
A no-op when the rule already says exactly this, so it can be called
freely — it only ever touches KWin when something really changed.
"""
config = self._config
if not (config.mini_timer and config.mini_timer_keep_above):
return
if not keep_above.supported() or keep_above.up_to_date(position):
return
ok, message = keep_above.apply(position)
log.info("keep-above rule: %s", message)
if announce or not ok:
self.toast.emit(message)
self.configChanged.emit()
@pyqtSlot(int, int)
def saveMiniPosition(self, x: int, y: int) -> None:
"""Remember where it was dragged to — only in "remember" mode.
In corner mode the app places the window itself, and recording that
placement would quietly overwrite the position you had dragged it to.
"""
if self._config.mini_timer_corner != "remember":
return
self._config.update(mini_timer_x=int(x), mini_timer_y=int(y))
@pyqtSlot() @pyqtSlot()
def openWebUi(self) -> None: def openWebUi(self) -> None:
QDesktopServices.openUrl(QUrl(self._config.server_url)) QDesktopServices.openUrl(QUrl(self._config.server_url))
@@ -431,6 +523,8 @@ class Controller(QObject):
"""One line for the tray tooltip and the D-Bus `Status` method.""" """One line for the tray tooltip and the D-Bus `Status` method."""
if not self._connected: if not self._connected:
return "Kadence — backend unreachable" return "Kadence — backend unreachable"
if not self.timerEnabled:
return f"Kadence — view mode · {self.completedToday}/{self.goal} today"
if not self.running: if not self.running:
return f"Kadence — idle · {self.completedToday}/{self.goal} today" return f"Kadence — idle · {self.completedToday}/{self.goal} today"
return ( return (
@@ -503,11 +597,14 @@ class Controller(QObject):
self.toast.emit("Daily report sent" if result.get("sent") else "Report not sent — check Telegram settings") self.toast.emit("Daily report sent" if result.get("sent") else "Report not sent — check Telegram settings")
elif name == "refresh_sources": elif name == "refresh_sources":
failed = [item for item in result.get("sources", []) if not item.get("ok")] failed = [item for item in result.get("sources", []) if not item.get("ok")]
closed = result.get("closed", 0)
self.toast.emit( self.toast.emit(
f"{len(failed)} source(s) failed" if failed else f"{result.get('total', 0)} tasks pulled" f"{len(failed)} source(s) failed"
if failed
else f"{result.get('total', 0)} open · {closed} closed since last refresh"
) )
elif name == "task_done" and result.get("warning"): elif name == "refresh_sources" and result.get("closed"):
self.toast.emit(str(result["warning"])) self.toast.emit(f"{result['closed']} task(s) closed in your tools")
# ====================================================================== # ======================================================================
# Transitions → the system integrations # Transitions → the system integrations
@@ -8,6 +8,12 @@ hotkey for "start a pomodoro" needs no X11 key-grabbing hack:
Owning the name also gives single-instance behaviour for free: a second launch 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. sees the name is taken, calls `Raise()` on the first, and exits.
The verbs live on a **QDBusAbstractAdaptor**, not on the exported object
itself. Qt only honours the "D-Bus Interface" class info on an adaptor; export
a plain QObject and the interface is named after its class
(`local.kadence.TimerDBusService`), which is exactly the "No such interface"
every documented command then hits.
""" """
from __future__ import annotations from __future__ import annotations
@@ -15,8 +21,8 @@ from __future__ import annotations
import logging import logging
from typing import Callable from typing import Callable
from PyQt6.QtCore import QObject, pyqtSlot from PyQt6.QtCore import QObject, pyqtClassInfo, pyqtSlot
from PyQt6.QtDBus import QDBusConnection from PyQt6.QtDBus import QDBusAbstractAdaptor, QDBusConnection
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -26,13 +32,14 @@ INTERFACE = "org.kadence.Timer"
class TimerDBusService(QObject): class TimerDBusService(QObject):
"""The exported object. Every slot is a verb you can bind to a key.""" """Holds the actions; the adaptor below is what the bus sees."""
def __init__(self, actions: dict[str, Callable[[], object]], parent: QObject | None = None) -> None: def __init__(self, actions: dict[str, Callable[[], object]], parent: QObject | None = None) -> None:
super().__init__(parent) super().__init__(parent)
self._actions = actions self._actions = actions
self.adaptor = TimerAdaptor(self)
def _run(self, name: str) -> str: def run(self, name: str) -> str:
action = self._actions.get(name) action = self._actions.get(name)
if action is None: if action is None:
return f"unknown action: {name}" return f"unknown action: {name}"
@@ -43,71 +50,113 @@ class TimerDBusService(QObject):
log.exception("D-Bus action %s failed", name) log.exception("D-Bus action %s failed", name)
return f"error: {exc}" return f"error: {exc}"
# Qt generates no introspection XML for a Python adaptor, and without it
# `busctl introspect` and KDE's D-Bus shortcut browser have nothing to list —
# so the interface describes itself. Every verb takes no arguments and returns
# one string.
VERBS = (
"StartWork",
"StartBreak",
"Toggle",
"Pause",
"Resume",
"Complete",
"Stop",
"Extend",
"Interrupt",
"Status",
"SendReport",
"ToggleMini",
"Raise",
)
INTROSPECTION = "\n".join(
[f'<interface name="{INTERFACE}">']
+ [
f' <method name="{verb}">\n <arg type="s" direction="out"/>\n </method>'
for verb in VERBS
]
+ ["</interface>"]
)
@pyqtClassInfo("D-Bus Interface", INTERFACE)
@pyqtClassInfo("D-Bus Introspection", INTROSPECTION)
class TimerAdaptor(QDBusAbstractAdaptor):
"""The published interface. Every slot is a verb you can bind to a key."""
def __init__(self, parent: TimerDBusService) -> None:
super().__init__(parent)
self._service = parent
# D-Bus methods are PascalCase by convention.
@pyqtSlot(result=str) @pyqtSlot(result=str)
def StartWork(self) -> str: # noqa: N802 - D-Bus methods are PascalCase def StartWork(self) -> str: # noqa: N802
"""Start a focus interval.""" """Start a focus interval."""
return self._run("start_work") return self._service.run("start_work")
@pyqtSlot(result=str) @pyqtSlot(result=str)
def StartBreak(self) -> str: # noqa: N802 def StartBreak(self) -> str: # noqa: N802
return self._run("start_break") return self._service.run("start_break")
@pyqtSlot(result=str) @pyqtSlot(result=str)
def Toggle(self) -> str: # noqa: N802 def Toggle(self) -> str: # noqa: N802
"""Start if idle, pause if running, resume if paused — one hotkey.""" """Start if idle, pause if running, resume if paused — one hotkey."""
return self._run("toggle") return self._service.run("toggle")
@pyqtSlot(result=str) @pyqtSlot(result=str)
def Pause(self) -> str: # noqa: N802 def Pause(self) -> str: # noqa: N802
return self._run("pause") return self._service.run("pause")
@pyqtSlot(result=str) @pyqtSlot(result=str)
def Resume(self) -> str: # noqa: N802 def Resume(self) -> str: # noqa: N802
return self._run("resume") return self._service.run("resume")
@pyqtSlot(result=str) @pyqtSlot(result=str)
def Complete(self) -> str: # noqa: N802 def Complete(self) -> str: # noqa: N802
return self._run("complete") return self._service.run("complete")
@pyqtSlot(result=str) @pyqtSlot(result=str)
def Stop(self) -> str: # noqa: N802 def Stop(self) -> str: # noqa: N802
return self._run("stop") return self._service.run("stop")
@pyqtSlot(result=str) @pyqtSlot(result=str)
def Extend(self) -> str: # noqa: N802 def Extend(self) -> str: # noqa: N802
return self._run("extend") return self._service.run("extend")
@pyqtSlot(result=str) @pyqtSlot(result=str)
def Interrupt(self) -> str: # noqa: N802 def Interrupt(self) -> str: # noqa: N802
"""Count a distraction without stopping the clock.""" """Count a distraction without stopping the clock."""
return self._run("interrupt") return self._service.run("interrupt")
@pyqtSlot(result=str) @pyqtSlot(result=str)
def Status(self) -> str: # noqa: N802 def Status(self) -> str: # noqa: N802
"""One line: what is running and how much is left.""" """One line: what is running and how much is left."""
return self._run("status") return self._service.run("status")
@pyqtSlot(result=str) @pyqtSlot(result=str)
def SendReport(self) -> str: # noqa: N802 def SendReport(self) -> str: # noqa: N802
return self._run("send_report") return self._service.run("send_report")
@pyqtSlot(result=str)
def ToggleMini(self) -> str: # noqa: N802
"""Show or hide the floating mini timer."""
return self._service.run("toggle_mini")
@pyqtSlot(result=str) @pyqtSlot(result=str)
def Raise(self) -> str: # noqa: N802 def Raise(self) -> str: # noqa: N802
"""Show the window (used by the second instance).""" """Show the main window (used by a second instance)."""
return self._run("raise_window") return self._service.run("raise_window")
def register(service: TimerDBusService) -> bool: def register(service: TimerDBusService) -> bool:
"""Own the name and export the object. False means another instance has it.""" """Own the name and export the adaptor. False means another instance has it."""
bus = QDBusConnection.sessionBus() bus = QDBusConnection.sessionBus()
if not bus.registerService(SERVICE_NAME): if not bus.registerService(SERVICE_NAME):
return False return False
bus.registerObject( bus.registerObject(OBJECT_PATH, service, QDBusConnection.RegisterOption.ExportAdaptors)
OBJECT_PATH, log.info("D-Bus service %s%s is up as %s", SERVICE_NAME, OBJECT_PATH, INTERFACE)
service,
QDBusConnection.RegisterOption.ExportAllSlots | QDBusConnection.RegisterOption.ExportAllInvokables,
)
log.info("D-Bus service %s%s is up", SERVICE_NAME, OBJECT_PATH)
return True return True
@@ -0,0 +1,215 @@
"""Keeping the mini timer above everything else — on Wayland, for real.
Wayland has no "always on top" an ordinary application can ask for: the
`Qt.WindowStaysOnTopHint` flag is honoured on X11 and quietly ignored by a
Wayland compositor. KWin does have the mechanism, but it lives in *window
rules*, so the only honest way to float above other applications on KDE is to
install one — which is what this module does, reversibly and with a backup.
The rule matches this one window (title `Kadence mini`, app id `kadence`) and
forces: keep above, skip the task bar, skip the switcher, skip the pager. KWin
is asked to re-read its configuration over D-Bus, so it takes effect at once
without logging out.
Everywhere else this is a no-op and the Qt flag does the job.
"""
from __future__ import annotations
import configparser
import logging
import os
import shutil
import uuid
from pathlib import Path
from PyQt6.QtDBus import QDBusConnection, QDBusInterface
log = logging.getLogger(__name__)
DESCRIPTION = "Kadence mini timer"
WINDOW_TITLE = "Kadence mini"
WINDOW_CLASS = "kadence"
# KWin's rule-policy codes: 2 is "Force".
_FORCE = "2"
# KWin's string-match codes: 1 is "Exact Match", 2 is "Substring Match".
_EXACT = "1"
_SUBSTRING = "2"
# Matched on the window title, as a **substring**.
#
# Two traps here, both found by asking KWin what it actually sees:
# * Qt appends the application's display name, so the window KWin knows is
# "Kadence mini — Kadence", and an exact match on "Kadence mini" never
# fired;
# * the app id depends on how the app was started (`kadence` from the desktop
# entry, `python3` from a bare `python3 -m ...`), so requiring the class as
# well would make the rule stop matching for some launches.
RULE = {
"Description": DESCRIPTION,
"above": "true",
"aboverule": _FORCE,
"skiptaskbar": "true",
"skiptaskbarrule": _FORCE,
"skipswitcher": "true",
"skipswitcherrule": _FORCE,
"skippager": "true",
"skippagerrule": _FORCE,
"title": WINDOW_TITLE,
"titlematch": _SUBSTRING,
"wmclassmatch": "0",
}
def rules_path() -> Path:
base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
return Path(base) / "kwinrulesrc"
def supported() -> bool:
"""Only KDE has this mechanism; elsewhere the Qt flag is all there is."""
desktop = os.environ.get("XDG_CURRENT_DESKTOP", "")
return "KDE" in desktop.upper()
def _parser() -> configparser.RawConfigParser:
parser = configparser.RawConfigParser(strict=False)
# KDE keys are case-sensitive ("Description" must not become "description").
parser.optionxform = str # type: ignore[assignment]
return parser
def _load() -> configparser.RawConfigParser:
parser = _parser()
path = rules_path()
if path.exists():
parser.read(path, encoding="utf-8")
if not parser.has_section("General"):
parser.add_section("General")
return parser
def _our_section(parser: configparser.RawConfigParser) -> str | None:
for section in parser.sections():
if section == "General":
continue
if parser.get(section, "Description", fallback="") == DESCRIPTION:
return section
return None
def _save(parser: configparser.RawConfigParser) -> None:
path = rules_path()
path.parent.mkdir(parents=True, exist_ok=True)
# One backup, before the first time we ever touch the file.
backup = path.with_suffix(path.suffix + ".kadence-backup")
if path.exists() and not backup.exists():
shutil.copy2(path, backup)
temp = path.with_suffix(path.suffix + ".kadence-tmp")
with temp.open("w", encoding="utf-8") as handle:
parser.write(handle, space_around_delimiters=False)
temp.replace(path)
def _reconfigure() -> None:
"""Ask KWin to re-read its rules; without it the change waits for a relogin."""
interface = QDBusInterface("org.kde.KWin", "/KWin", "org.kde.KWin", QDBusConnection.sessionBus())
if interface.isValid():
interface.call("reconfigure")
def _wanted(position: tuple[int, int] | None) -> dict[str, str]:
"""The rule as it should be, with the forced position when there is one."""
rule = dict(RULE)
if position is not None:
# KWin stores a forced position as "x,y" — this is what places the
# window on Wayland, where a client may not place itself.
rule["position"] = f"{position[0]},{position[1]}"
rule["positionrule"] = _FORCE
return rule
def installed() -> bool:
try:
return _our_section(_load()) is not None
except (configparser.Error, OSError):
return False
def up_to_date(position: tuple[int, int] | None = None) -> bool:
"""Whether the installed rule matches what this version would write.
An older build wrote an exact title match that never fired; without this
check an upgrade would leave the broken rule in place forever, because the
rule *exists*. The position is part of the comparison, so moving the mini
timer to another corner rewrites the rule — and nothing else does.
"""
try:
parser = _load()
section = _our_section(parser)
if section is None:
return False
wanted = _wanted(position)
if any(parser.get(section, key, fallback=None) != value for key, value in wanted.items()):
return False
# A position left over from a previous corner must not linger.
if position is None and parser.has_option(section, "position"):
return False
return True
except (configparser.Error, OSError):
return False
def apply(position: tuple[int, int] | None = None) -> tuple[bool, str]:
"""Install (or refresh) the rule, optionally forcing a position."""
if not supported():
return False, "Keeping a window above others this way needs KWin (KDE)."
try:
parser = _load()
section = _our_section(parser) or str(uuid.uuid4())
if not parser.has_section(section):
parser.add_section(section)
for key in ("position", "positionrule"):
parser.remove_option(section, key)
for key, value in _wanted(position).items():
parser.set(section, key, value)
listed = [item for item in parser.get("General", "rules", fallback="").split(",") if item]
if section not in listed:
listed.append(section)
parser.set("General", "rules", ",".join(listed))
parser.set("General", "count", str(len(listed)))
_save(parser)
except (configparser.Error, OSError) as exc:
log.warning("could not write the KWin rule: %s", exc)
return False, f"Could not write {rules_path()}: {exc}"
_reconfigure()
if position is not None:
return True, "KWin keeps the mini timer above other windows, in the corner you chose."
return True, "KWin will now keep the mini timer above other windows."
def remove() -> tuple[bool, str]:
"""Take the rule back out, leaving the rest of the file alone."""
try:
parser = _load()
section = _our_section(parser)
if section is None:
return True, "No KWin rule to remove."
parser.remove_section(section)
listed = [
item
for item in parser.get("General", "rules", fallback="").split(",")
if item and item != section
]
parser.set("General", "rules", ",".join(listed))
parser.set("General", "count", str(len(listed)))
_save(parser)
except (configparser.Error, OSError) as exc:
return False, f"Could not update {rules_path()}: {exc}"
_reconfigure()
return True, "Removed the KWin rule; the mini timer floats only as far as Qt can ask."
@@ -0,0 +1,78 @@
"""Where the mini timer appears.
Two mechanisms, because one is not enough:
* **X11** — the application may place its own window, so the computed point is
applied directly.
* **Wayland** — it may not: a client cannot position an xdg-toplevel, and
setting `x`/`y` is quietly ignored. KWin *can* place it, through the same
window rule that already forces "keep above", so the point is written there.
The geometry maths is a pure function so it can be checked without a screen.
"""
from __future__ import annotations
from dataclasses import dataclass
CORNERS = (
"top-left",
"top-centre",
"top-right",
"bottom-left",
"bottom-centre",
"bottom-right",
"centre",
"remember",
)
# What the settings page shows, in the order it shows them.
CORNER_LABELS = {
"top-left": "Top left",
"top-centre": "Top centre",
"top-right": "Top right",
"bottom-left": "Bottom left",
"bottom-centre": "Bottom centre",
"bottom-right": "Bottom right",
"centre": "Centre of the screen",
"remember": "Where I last dragged it",
}
@dataclass(frozen=True, slots=True)
class Rect:
"""A screen's usable area — panels and docks already taken off."""
x: int
y: int
width: int
height: int
def point_for(corner: str, screen: Rect, size: tuple[int, int], margin: int = 24) -> tuple[int, int]:
"""Top-left point that puts a `size` window in `corner` of `screen`."""
width, height = size
margin = max(0, margin)
vertical, _, horizontal = corner.partition("-")
if corner == "centre":
vertical, horizontal = "centre", "centre"
if horizontal == "left":
x = screen.x + margin
elif horizontal == "right":
x = screen.x + screen.width - width - margin
else: # centre
x = screen.x + (screen.width - width) // 2
if vertical == "top":
y = screen.y + margin
elif vertical == "bottom":
y = screen.y + screen.height - height - margin
else: # centre
y = screen.y + (screen.height - height) // 2
# Never place it off-screen, however small the screen or large the margin.
x = max(screen.x, min(x, screen.x + max(screen.width - width, 0)))
y = max(screen.y, min(y, screen.y + max(screen.height - height, 0)))
return int(x), int(y)
+267 -134
View File
@@ -1,12 +1,13 @@
// What the blocker is doing right now, and what it will do. // The focus guard: what is blocked, when, and what it caught.
// //
// The rules themselves are server-side (edit them in the web UI or below); this // Lists accept masks. A hosts file has no wildcards of its own, so `*.x.com` is
// page is about *enforcement on this machine* — the helper, the hosts file and // expanded server-side into real hostnames; this page shows that count and says
// the process watcher. // plainly when a pattern cannot be expressed that way.
import QtQuick import QtQuick
import QtQuick.Controls.Material import QtQuick.Controls
import QtQuick.Layouts import QtQuick.Layouts
import "."
import "components" import "components"
ScrollView { ScrollView {
@@ -15,138 +16,83 @@ ScrollView {
clip: true clip: true
ColumnLayout { ColumnLayout {
width: page.availableWidth width: page.availableWidth - 56
spacing: 16 x: 28
y: 26
spacing: 22
Banner { PageHeader {
Layout.fillWidth: true eyebrow: controller.blockerReason
visible: !controller.helperInstalled title: controller.blockerActive ? "Blocking is on" : "Blocking is off"
text: "The privileged helper is not installed, so websites cannot be blocked. " caption: "Rules are shared with the web app; this machine enforces them."
+ "Run: sudo desktop/helper/install.sh"
actionText: "" Switch {
text: "Enabled"
checked: controller.settings.blocker ? controller.settings.blocker.enabled : false
onToggled: controller.patchSettings({ blocker: { enabled: checked } })
}
} }
Banner { 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"
}
Banner {
visible: controller.blockerError !== "" visible: controller.blockerError !== ""
text: controller.blockerError text: controller.blockerError
} }
Panel {
padding: 0
RowLayout {
Layout.fillWidth: true
spacing: 0
Repeater {
model: [
{ label: "Hostnames", value: "" + controller.blockedDomains.length, caption: "after expansion" },
{ label: "Apps", value: "" + controller.blockedApps.length, caption: "closed on sight" },
{ label: "Attempts today",
value: controller.stats.today ? "" + controller.stats.today.blocked_attempts : "0",
caption: "turned away" },
{ label: "Enforcing",
value: controller.config.enforce_blocking ? "Yes" : "No",
caption: "on this computer" }
]
delegate: RowLayout {
required property int index
required property var modelData
Layout.fillWidth: true
spacing: 0
Rectangle {
visible: index > 0
width: 1
Layout.preferredHeight: 70
color: Theme.rule
}
Stat {
Layout.fillWidth: true
Layout.margins: 18
label: modelData.label
value: modelData.value
caption: modelData.caption
}
}
}
}
}
ColumnLayout { ColumnLayout {
Layout.fillWidth: true Layout.fillWidth: true
Layout.margins: 20 spacing: 12
SectionTitle { text: "When" }
RowLayout {
Layout.fillWidth: true
spacing: 16 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 { ComboBox {
Layout.preferredWidth: 220 Layout.preferredWidth: 240
model: ["During focus intervals", "Always", "On a schedule"] font.pixelSize: 13
model: ["While a focus interval runs", "Always", "On a schedule"]
currentIndex: { currentIndex: {
var mode = controller.settings.blocker ? controller.settings.blocker.mode : "during_work" var mode = controller.settings.blocker ? controller.settings.blocker.mode : "during_work"
return mode === "always" ? 1 : (mode === "scheduled" ? 2 : 0) return mode === "always" ? 1 : (mode === "scheduled" ? 2 : 0)
@@ -157,34 +103,221 @@ ScrollView {
} }
} }
Switch { Switch {
text: "Keep blocking during breaks" text: "Through breaks"
checked: controller.settings.blocker ? controller.settings.blocker.block_during_breaks : false checked: controller.settings.blocker ? controller.settings.blocker.block_during_breaks : false
onToggled: controller.patchSettings({ blocker: { block_during_breaks: checked } }) onToggled: controller.patchSettings({ blocker: { block_during_breaks: checked } })
} }
Switch {
text: "Close blocked apps"
checked: controller.settings.blocker ? controller.settings.blocker.kill_blocked_apps : true
onToggled: controller.patchSettings({ blocker: { kill_blocked_apps: checked } })
}
Item { Layout.fillWidth: true }
} }
} }
Card { ColumnLayout {
title: "Today's temptations" Layout.fillWidth: true
subtitle: "Every attempt the blocker turned away — this is what the daily report jokes about." spacing: 12
SectionTitle { text: "Masks" }
Panel {
spacing: 0
Repeater {
model: [
{ pattern: "youtube.com", meaning: "the site itself, plus www. and m." },
{ pattern: "*.google.com", meaning: "every subdomain in the expansion list" },
{ pattern: "*gram*", meaning: "matches apps; a hosts file cannot express it" }
]
delegate: RowLayout {
required property var modelData
Layout.fillWidth: true
spacing: 16
Label {
text: modelData.pattern
color: Theme.ink
font.pixelSize: 12
font.family: Theme.monoFamily
Layout.preferredWidth: 140
}
Label {
text: modelData.meaning
color: Theme.muted
font.pixelSize: 12
font.family: Theme.sansFamily
Layout.fillWidth: true
}
}
}
}
}
GridLayout {
Layout.fillWidth: true
columns: page.width > 900 ? 3 : 1
columnSpacing: 18
rowSpacing: 18
ColumnLayout {
Layout.fillWidth: true
spacing: 8
SectionTitle { text: "Blocked websites" }
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 160
radius: 10
color: Theme.surface
border.width: 1
border.color: domainsArea.activeFocus ? Theme.ember : Theme.rule
ScrollView {
anchors.fill: parent
anchors.margins: 8
clip: true
TextArea {
id: domainsArea
background: null
wrapMode: TextArea.NoWrap
font.family: Theme.monoFamily
font.pixelSize: 12
color: Theme.ink
placeholderText: "youtube.com\n*.reddit.com"
text: (controller.settings.blocker ? controller.settings.blocker.blocked_domains : []).join("
")
}
}
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 8
SectionTitle { text: "Exceptions" }
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 160
radius: 10
color: Theme.surface
border.width: 1
border.color: allowArea.activeFocus ? Theme.ember : Theme.rule
ScrollView {
anchors.fill: parent
anchors.margins: 8
clip: true
TextArea {
id: allowArea
background: null
wrapMode: TextArea.NoWrap
font.family: Theme.monoFamily
font.pixelSize: 12
color: Theme.ink
placeholderText: "music.youtube.com\n*.docs.google.com"
text: (controller.settings.blocker ? controller.settings.blocker.allowed_domains : []).join("
")
}
}
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 8
SectionTitle { text: "Blocked applications" }
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 160
radius: 10
color: Theme.surface
border.width: 1
border.color: appsArea.activeFocus ? Theme.ember : Theme.rule
ScrollView {
anchors.fill: parent
anchors.margins: 8
clip: true
TextArea {
id: appsArea
background: null
wrapMode: TextArea.NoWrap
font.family: Theme.monoFamily
font.pixelSize: 12
color: Theme.ink
placeholderText: "steam*\n*discord*"
text: (controller.settings.blocker ? controller.settings.blocker.blocked_apps : []).join("
")
}
}
}
}
}
RowLayout {
Layout.fillWidth: true
spacing: 12
TextField {
id: subdomainField
Layout.preferredWidth: 420
placeholderText: "*.site expands to: www, m, app…"
text: (controller.settings.blocker && controller.settings.blocker.subdomain_expansions
? controller.settings.blocker.subdomain_expansions : []).join(", ")
}
Item { Layout.fillWidth: true }
PrimaryButton {
text: "Save lists"
onClicked: {
function split(text, sep) {
return text.split(sep).map(function (s) { return s.trim() })
.filter(function (s) { return s.length > 0 })
}
controller.patchSettings({
blocker: {
blocked_domains: split(domainsArea.text, "\n"),
allowed_domains: split(allowArea.text, "\n"),
blocked_apps: split(appsArea.text, "\n"),
subdomain_expansions: split(subdomainField.text, ",")
}
})
}
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 8
SectionTitle { text: "Today's temptations" }
Repeater { Repeater {
model: controller.stats.today ? controller.stats.today.blocked_top : [] model: controller.stats.today ? controller.stats.today.blocked_top : []
delegate: RowLayout { delegate: RowLayout {
required property var modelData required property var modelData
Layout.fillWidth: true Layout.fillWidth: true
Label { text: modelData.target; Layout.fillWidth: true } Label {
Label { text: "× " + modelData.count; font.weight: Font.DemiBold } text: modelData.target
color: Theme.ink
font.pixelSize: 12
font.family: Theme.monoFamily
Layout.fillWidth: true
}
Label {
text: "× " + modelData.count
color: Theme.muted
font.pixelSize: 12
font.family: Theme.sansFamily
}
} }
} }
Label { Label {
visible: !controller.stats.today || controller.stats.today.blocked_top.length === 0 visible: !controller.stats.today || controller.stats.today.blocked_top.length === 0
text: "Nothing yet today. Either the blocker is off, or you are behaving." text: "Nothing yet — either the blocker is off, or you are behaving."
opacity: 0.6 color: Theme.faint
font.pixelSize: 12 font.pixelSize: 12
font.family: Theme.sansFamily
} }
} }
}
Item { Layout.preferredHeight: 10 }
} }
Component.onCompleted: controller.loadSettings() Component.onCompleted: controller.loadSettings()
+150 -124
View File
@@ -1,46 +1,40 @@
// The window. // The window.
// //
// Qt Quick Controls' Material style, following the session's own light/dark // A quiet left column — wordmark, navigation as words, and the two things worth
// preference, with a navigation rail on the left and the pages stacked on the // knowing from anywhere (what the timer is doing, whether the blocker is on) —
// right. Everything it shows comes from `controller`, the Python object bound // with the pages on the right. Qt Quick Controls supply the behaviour; the
// into the QML context — there is no second copy of the state here. // chrome comes from `Theme`, so the desktop app and the web app are the same
// product rather than two takes on it.
import QtQuick import QtQuick
import QtQuick.Controls
import QtQuick.Controls.Material import QtQuick.Controls.Material
import QtQuick.Layouts import QtQuick.Layouts
import QtQuick.Window
import "."
import "components" import "components"
ApplicationWindow { ApplicationWindow {
id: window id: window
width: 1100 width: 1180
height: 760 height: 820
minimumWidth: 420 minimumWidth: 460
minimumHeight: 560 minimumHeight: 600
visible: false visible: false
color: Theme.paper
title: controller.running title: controller.running
? controller.clockText + " · " + controller.kindLabel + " — Kadence" ? controller.clockText + " · " + controller.kindLabel + " — Kadence"
: "Kadence" : "Kadence"
Material.theme: Material.System Material.theme: Theme.dark ? Material.Dark : Material.Light
Material.primary: "#6750A4" Material.primary: Theme.ember
Material.accent: "#6750A4" Material.accent: Theme.ember
Material.background: Theme.paper
Material.foreground: Theme.ink
readonly property color workColor: "#2a78d6"
readonly property color shortBreakColor: "#eb6834"
readonly property color longBreakColor: "#1baf7a"
readonly property bool wide: width >= 820 readonly property bool wide: width >= 820
property int page: 0 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) { onClosing: function (close) {
if (controller.config.close_to_tray && controller.config.tray_icon) { if (controller.config.close_to_tray && controller.config.tray_icon) {
close.accepted = false close.accepted = false
@@ -54,10 +48,9 @@ ApplicationWindow {
function onShowWindowRequested() { window.show(); window.raise(); window.requestActivate() } function onShowWindowRequested() { window.show(); window.raise(); window.requestActivate() }
} }
// ---- shortcuts --------------------------------------------------------- Shortcut { sequence: "Space"; onActivated: if (controller.timerEnabled) controller.toggle() }
Shortcut { sequence: "Space"; onActivated: controller.toggle() } Shortcut { sequence: "Ctrl+Return"; onActivated: if (controller.timerEnabled) controller.complete() }
Shortcut { sequence: "Ctrl+Return"; onActivated: controller.complete() } Shortcut { sequence: "Ctrl+E"; onActivated: if (controller.timerEnabled) controller.extend(5) }
Shortcut { sequence: "Ctrl+E"; onActivated: controller.extend(5) }
Shortcut { sequence: "Ctrl+R"; onActivated: controller.refreshSources() } Shortcut { sequence: "Ctrl+R"; onActivated: controller.refreshSources() }
Shortcut { sequence: "Ctrl+1"; onActivated: window.page = 0 } Shortcut { sequence: "Ctrl+1"; onActivated: window.page = 0 }
Shortcut { sequence: "Ctrl+2"; onActivated: window.page = 1 } Shortcut { sequence: "Ctrl+2"; onActivated: window.page = 1 }
@@ -65,143 +58,167 @@ ApplicationWindow {
Shortcut { sequence: "Ctrl+4"; onActivated: window.page = 3 } Shortcut { sequence: "Ctrl+4"; onActivated: window.page = 3 }
Shortcut { sequence: "Ctrl+5"; onActivated: window.page = 4 } Shortcut { sequence: "Ctrl+5"; onActivated: window.page = 4 }
Shortcut { sequence: "Ctrl+W"; onActivated: window.hide() } Shortcut { sequence: "Ctrl+W"; onActivated: window.hide() }
Shortcut { sequence: "Ctrl+M"; onActivated: controller.toggleMini() }
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 { RowLayout {
anchors.fill: parent anchors.fill: parent
spacing: 0 spacing: 0
// ---- navigation rail ------------------------------------------------ // ---- the rail --------------------------------------------------------
Pane { Item {
visible: window.wide visible: window.wide
Layout.fillHeight: true Layout.fillHeight: true
Layout.preferredWidth: 212 Layout.preferredWidth: 212
Material.elevation: 0
padding: 12 Rectangle {
anchors.right: parent.right
width: 1
height: parent.height
color: Theme.rule
}
ColumnLayout { ColumnLayout {
anchors.fill: parent anchors.fill: parent
spacing: 4 anchors.margins: 22
spacing: 2
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 { ColumnLayout {
Layout.bottomMargin: 26
spacing: 0 spacing: 0
Label { text: "Kadence"; font.pixelSize: 17; font.weight: Font.DemiBold }
Label { Label {
text: controller.connected ? "connected" : "offline" text: "Kadence"
font.pixelSize: 11 color: Theme.ink
opacity: 0.6 font.pixelSize: 23
font.family: Theme.displayFamily
font.letterSpacing: -0.5
} }
Label {
text: "find your rhythm"
color: Theme.muted
font.pixelSize: 11
font.family: Theme.sansFamily
} }
} }
Repeater { Repeater {
model: [ model: ["Timer", "Tasks", "Blocker", "Report", "Settings"]
{ label: "Timer", icon: "⏱" }, delegate: Item {
{ label: "Tasks", icon: "✓" },
{ label: "Blocker", icon: "🛡" },
{ label: "Report", icon: "📊" },
{ label: "Settings", icon: "⚙" }
]
delegate: ItemDelegate {
required property int index required property int index
required property var modelData required property string modelData
Layout.fillWidth: true Layout.fillWidth: true
height: 44 implicitHeight: 34
highlighted: window.page === index
onClicked: window.page = index
background: Rectangle { Rectangle {
radius: 22 anchors.verticalCenter: parent.verticalCenter
color: parent.highlighted ? Material.accent width: 2
: (parent.hovered ? Material.listHighlightColor : "transparent") height: 16
opacity: parent.highlighted ? 0.18 : 1 radius: 1
color: Theme.ember
visible: window.page === index
} }
contentItem: RowLayout {
spacing: 12
Label { text: modelData.icon; font.pixelSize: 16 }
Label { Label {
text: modelData.label anchors.left: parent.left
font.weight: parent.parent.highlighted ? Font.DemiBold : Font.Normal anchors.leftMargin: 14
Layout.fillWidth: true anchors.verticalCenter: parent.verticalCenter
text: modelData
color: window.page === index ? Theme.ink : (mouse.containsMouse ? Theme.ink : Theme.muted)
font.pixelSize: 14
font.weight: window.page === index ? Font.DemiBold : Font.Normal
font.family: Theme.sansFamily
} }
MouseArea {
id: mouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: window.page = index
} }
} }
} }
Item { Layout.fillHeight: true } Item { Layout.fillHeight: true }
StatusPill { Rectangle { Layout.fillWidth: true; height: 1; color: Theme.rule; Layout.bottomMargin: 10 }
RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
running: controller.running spacing: 8
paused: controller.status === "paused" Rectangle {
connected: controller.connected width: 7; height: 7; radius: 3.5
text: controller.connected ? (controller.running ? controller.clockText : "idle") : "offline" color: controller.running ? Theme.kindColor(controller.kind) : Theme.faint
accent: window.kindColor(controller.kind) SequentialAnimation on opacity {
running: controller.running && controller.status === "running"
loops: Animation.Infinite
NumberAnimation { to: 0.35; duration: 900 }
NumberAnimation { to: 1.0; duration: 900 }
}
} }
Label { Label {
Layout.fillWidth: true text: controller.connected
Layout.topMargin: 6 ? (controller.timerEnabled
text: controller.completedToday + "/" + controller.goal + " today" ? (controller.running ? controller.clockText : "Idle")
: "View mode")
: "Offline"
color: Theme.ink
font.pixelSize: 13
font.weight: Font.Medium
font.family: Theme.monoFamily
}
Item { Layout.fillWidth: true }
Label {
text: controller.completedToday + "/" + controller.goal
color: Theme.muted
font.pixelSize: 12 font.pixelSize: 12
opacity: 0.7 font.family: Theme.sansFamily
horizontalAlignment: Text.AlignHCenter }
} }
Label { Label {
Layout.fillWidth: true
visible: controller.blockerActive visible: controller.blockerActive
text: "🛡 blocking is on" text: "● BLOCKING"
font.pixelSize: 12 color: Theme.clay
color: "#e34948" font.pixelSize: 10
horizontalAlignment: Text.AlignHCenter font.letterSpacing: 1.1
font.weight: Font.DemiBold
font.family: Theme.sansFamily
} }
} }
} }
// ---- pages ---------------------------------------------------------- // ---- pages -----------------------------------------------------------
ColumnLayout { ColumnLayout {
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
spacing: 0 spacing: 0
Banner { ToolBar {
visible: !window.wide
Layout.fillWidth: true Layout.fillWidth: true
visible: !controller.connected background: Rectangle {
text: controller.connectionError !== "" color: Theme.paper
? controller.connectionError Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: Theme.rule }
: "Cannot reach the backend. Start it with `docker compose up`." }
actionText: "Settings" RowLayout {
onActionClicked: window.page = 4 anchors.fill: parent
anchors.leftMargin: 16
anchors.rightMargin: 16
Label {
text: "Kadence"
color: Theme.ink
font.pixelSize: 18
font.family: Theme.displayFamily
Layout.fillWidth: true
}
Label {
text: controller.connected
? (controller.timerEnabled
? (controller.running ? controller.clockText : "Idle")
: "View mode")
: "Offline"
color: Theme.ink
font.pixelSize: 13
font.family: Theme.monoFamily
}
}
} }
StackLayout { StackLayout {
@@ -215,20 +232,29 @@ ApplicationWindow {
ReportPage {} ReportPage {}
SettingsPage {} SettingsPage {}
} }
}
}
// ---- footer: bottom navigation on a narrow window ---------------------- TabBar {
footer: TabBar {
visible: !window.wide visible: !window.wide
Layout.fillWidth: true
currentIndex: window.page currentIndex: window.page
onCurrentIndexChanged: window.page = currentIndex onCurrentIndexChanged: window.page = currentIndex
TabButton { text: "⏱" } background: Rectangle {
TabButton { text: "✓" } color: Theme.paper
TabButton { text: "🛡" } Rectangle { width: parent.width; height: 1; color: Theme.rule }
TabButton { text: "📊" } }
TabButton { text: "⚙" } TabButton { text: "Timer" }
TabButton { text: "Tasks" }
TabButton { text: "Blocker" }
TabButton { text: "Report" }
TabButton { text: "Settings" }
}
}
} }
Snackbar { id: snackbar } Snackbar { id: snackbar }
// The floating mini timer is created by app.py as its own top-level window:
// nested inside this one it would be transient, and a transient window
// cannot be shown while its parent is hidden — which is precisely when the
// mini timer earns its keep.
} }
+241
View File
@@ -0,0 +1,241 @@
// The floating mini timer.
//
// A small pill you keep in a corner while you work: the countdown, what you are
// working on, and the three controls you actually reach for. It appears on
// demand (tray, Ctrl+M, the D-Bus verb) and — if you let it — by itself
// whenever an interval is running and the main window is out of the way.
//
// Everything has its own row. An earlier version saved space by swapping the
// task line for the controls on hover and by drawing the progress bar over the
// bottom edge; both of those put things on top of each other, and a timer you
// have to hover to operate is a timer you cannot operate at a glance.
//
// Dragging uses the compositor's own move (`startSystemMove`), the only thing
// that works on Wayland, and the window refuses keyboard focus rather than
// being a `Qt.Tool` — Qt hides tool windows whenever the application is
// deactivated, which made this one vanish on every click in another app.
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Window
import "."
Window {
id: mini
width: 252
height: card.implicitHeight + 12
color: "transparent"
// KWin's rule matches a substring of this, so it must stay distinct from
// the main window's caption. Qt appends " — Kadence" to it.
title: "Kadence mini"
flags: Qt.Window | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint
| Qt.WindowDoesNotAcceptFocus
readonly property color accent: Theme.kindColor(controller.kind)
readonly property bool paused: controller.status === "paused"
// Placement is decided in app.py: it is the only piece that knows about
// screens, and on Wayland it has to go through a KWin rule anyway.
onXChanged: saveTimer.restart()
onYChanged: saveTimer.restart()
Timer {
id: saveTimer
interval: 800
onTriggered: if (mini.visible) controller.saveMiniPosition(mini.x, mini.y)
}
// A small pill button. `filled` marks the one action you want by default —
// Resume when paused, Start when idle.
component MiniButton: Rectangle {
id: button
property string label: ""
property bool filled: false
signal clicked()
implicitWidth: buttonText.implicitWidth + 18
implicitHeight: 24
radius: 7
color: filled
? (buttonHover.hovered ? Qt.lighter(mini.accent, 1.08) : mini.accent)
: (buttonHover.hovered ? Qt.alpha(mini.accent, 0.12) : "transparent")
border.width: filled ? 0 : 1
border.color: buttonHover.hovered ? mini.accent : Theme.rule
Behavior on color { ColorAnimation { duration: 110 } }
Label {
id: buttonText
anchors.fill: parent
text: button.label
color: button.filled ? "white" : Theme.ink
font.pixelSize: 11
font.weight: button.filled ? Font.DemiBold : Font.Normal
font.family: Theme.sansFamily
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
HoverHandler { id: buttonHover; cursorShape: Qt.PointingHandCursor }
TapHandler { onTapped: button.clicked() }
}
Rectangle {
id: card
anchors.fill: parent
anchors.margins: 6
radius: 14
color: Theme.surface
border.width: 1
border.color: Theme.rule
implicitHeight: body.implicitHeight + 24 + progressLane.height + 5
HoverHandler { id: hover }
DragHandler {
target: null
grabPermissions: PointerHandler.CanTakeOverFromAnything
onActiveChanged: if (active) mini.startSystemMove()
}
TapHandler {
acceptedButtons: Qt.LeftButton
onDoubleTapped: controller.showWindowRequested()
}
ColumnLayout {
id: body
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 12
spacing: 3
// ---- state -------------------------------------------------------
RowLayout {
Layout.fillWidth: true
spacing: 7
Rectangle {
width: 6; height: 6; radius: 3
color: controller.running ? mini.accent : Theme.faint
SequentialAnimation on opacity {
running: controller.running && !mini.paused
loops: Animation.Infinite
NumberAnimation { to: 0.3; duration: 900 }
NumberAnimation { to: 1.0; duration: 900 }
}
}
Label {
text: (controller.running ? controller.kindLabel : "Idle").toUpperCase()
+ (mini.paused ? " · PAUSED" : "")
color: mini.paused ? Theme.amber : (controller.running ? mini.accent : Theme.muted)
font.pixelSize: 9
font.letterSpacing: 1.2
font.weight: Font.DemiBold
font.family: Theme.sansFamily
Layout.fillWidth: true
elide: Text.ElideRight
}
Label {
text: controller.completedToday + "/" + controller.goal
color: Theme.faint
font.pixelSize: 10
font.family: Theme.sansFamily
}
Label {
text: "✕"
color: closeHover.hovered ? Theme.ink : Theme.faint
font.pixelSize: 11
opacity: hover.hovered ? 1 : 0.35
Behavior on opacity { NumberAnimation { duration: 120 } }
HoverHandler { id: closeHover; cursorShape: Qt.PointingHandCursor }
TapHandler { onTapped: controller.setMiniVisible(false) }
}
}
// ---- the time ----------------------------------------------------
Label {
text: controller.clockText
color: Theme.ink
opacity: mini.paused ? 0.55 : 1
font.pixelSize: 32
font.family: Theme.displayFamily
font.letterSpacing: -1
Layout.topMargin: -2
Behavior on opacity { NumberAnimation { duration: 150 } }
}
// ---- what you are on ---------------------------------------------
Label {
Layout.fillWidth: true
text: controller.taskTitle !== ""
? controller.taskTitle
: (controller.running ? "no task attached" : "double-click to open Kadence")
color: Theme.muted
font.pixelSize: 11
font.family: Theme.sansFamily
elide: Text.ElideRight
}
// ---- controls, always visible --------------------------------------
RowLayout {
Layout.fillWidth: true
Layout.topMargin: 4
spacing: 6
MiniButton {
label: mini.paused ? "Resume" : "Pause"
filled: mini.paused
visible: controller.running
onClicked: mini.paused ? controller.resume() : controller.pause()
}
MiniButton {
label: "Finish"
visible: controller.running
onClicked: controller.complete()
}
MiniButton {
label: "+5"
visible: controller.running
onClicked: controller.extend(5)
}
MiniButton {
label: "Start focus"
filled: true
visible: !controller.running
onClicked: controller.startWork()
}
MiniButton {
label: "Break"
visible: !controller.running
onClicked: controller.startBreak()
}
Item { Layout.fillWidth: true }
}
}
// ---- progress, in a lane of its own ----------------------------------
Item {
id: progressLane
objectName: "progressLane"
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.leftMargin: 1
anchors.rightMargin: 1
anchors.bottomMargin: 1
height: 3
clip: true
Rectangle {
width: parent.width * Math.max(0, Math.min(1, controller.progress))
height: parent.height
color: mini.accent
opacity: controller.running ? (mini.paused ? 0.45 : 1) : 0.25
Behavior on width { NumberAnimation { duration: 400 } }
}
}
}
}
+137 -88
View File
@@ -1,8 +1,9 @@
// The day, summarised — the same payload the Telegram message is built from. // The day in numbers — the same payload the Telegram message is built from.
import QtQuick import QtQuick
import QtQuick.Controls.Material import QtQuick.Controls
import QtQuick.Layouts import QtQuick.Layouts
import "."
import "components" import "components"
ScrollView { ScrollView {
@@ -14,95 +15,115 @@ ScrollView {
readonly property var metrics: payload ? payload.metrics : null readonly property var metrics: payload ? payload.metrics : null
ColumnLayout { ColumnLayout {
width: page.availableWidth width: page.availableWidth - 56
spacing: 16 x: 28
y: 26
spacing: 22
ColumnLayout { PageHeader {
Layout.fillWidth: true eyebrow: controller.report.sent_at ? "sent to Telegram" : "not sent yet"
Layout.margins: 20 title: page.payload ? "Report · " + page.payload.date : "Report"
spacing: 16 caption: page.payload ? page.payload.verdict : ""
RowLayout { GhostButton { text: "Reload"; onClicked: controller.loadReport() }
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() } PrimaryButton { text: "Send to Telegram"; onClicked: controller.sendReport() }
} }
GridLayout { Panel {
padding: 0
RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
columns: page.width > 760 ? 4 : 2 spacing: 0
columnSpacing: 12 Repeater {
rowSpacing: 12 model: [
{ label: "Pomodoros",
StatTile { value: page.metrics ? page.metrics.pomodoros + "/" + page.metrics.goal : "—",
label: "Pomodoros" caption: page.metrics && page.metrics.goal_met ? "goal met" : "goal not met yet",
value: page.metrics ? page.metrics.pomodoros + "/" + page.metrics.goal : "—" accent: true },
caption: page.metrics && page.metrics.goal_met ? "goal met 🎯" : "keep going" { label: "Focus",
accent: window.workColor value: page.metrics ? page.metrics.focus_minutes + "m" : "—",
caption: page.metrics ? page.metrics.break_minutes + "m resting" : "" },
{ label: "Score",
value: page.payload ? "" + Math.round(page.payload.score) : "—",
caption: "out of 100" },
{ label: "Streak",
value: page.payload ? page.payload.streak.current + "d" : "—",
caption: page.payload ? "best " + page.payload.streak.best : "" }
]
delegate: RowLayout {
required property int index
required property var modelData
Layout.fillWidth: true
spacing: 0
Rectangle {
visible: index > 0
width: 1
Layout.preferredHeight: 84
color: Theme.rule
}
Stat {
Layout.fillWidth: true
Layout.margins: 20
label: modelData.label
value: modelData.value
caption: modelData.caption
size: 38
accent: modelData.accent ? Theme.work : Theme.ink
} }
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 { ColumnLayout {
title: "Last 30 days" Layout.fillWidth: true
subtitle: "Goal line is the dashed row; darker bars cleared it." spacing: 10
SectionTitle { text: "Last 30 days"; trailing: "goal line dashed" }
Panel {
BarRow { BarRow {
Layout.fillWidth: true Layout.fillWidth: true
days: controller.stats.range ? controller.stats.range : [] days: controller.stats.range ? controller.stats.range : []
goal: controller.goal goal: controller.goal
} }
} }
}
Card { ColumnLayout {
Layout.fillWidth: true
visible: page.payload && page.payload.achievements.length > 0 visible: page.payload && page.payload.achievements.length > 0
title: "Achievements" spacing: 10
SectionTitle { text: "Achievements" }
Flow { Flow {
Layout.fillWidth: true Layout.fillWidth: true
spacing: 8 spacing: 8
Repeater { Repeater {
model: page.payload ? page.payload.achievements : [] model: page.payload ? page.payload.achievements : []
delegate: Pane { delegate: Rectangle {
required property var modelData required property var modelData
Material.elevation: 0 implicitWidth: badge.implicitWidth + 24
Material.roundedScale: Material.SmallScale implicitHeight: badge.implicitHeight + 18
padding: 10
background: Rectangle {
radius: 10 radius: 10
color: Material.accent color: Qt.alpha(Theme.ember, 0.08)
opacity: 0.14 border.width: 1
} border.color: Qt.alpha(Theme.ember, 0.22)
ColumnLayout { ColumnLayout {
id: badge
anchors.centerIn: parent
spacing: 1 spacing: 1
Label { Label {
text: modelData.emoji + " " + modelData.title text: modelData.emoji + " " + modelData.title
font.weight: Font.DemiBold color: Theme.ink
font.pixelSize: 13 font.pixelSize: 13
font.weight: Font.DemiBold
font.family: Theme.sansFamily
} }
Label { Label {
text: modelData.detail
visible: modelData.detail !== "" visible: modelData.detail !== ""
text: modelData.detail
color: Theme.muted
font.pixelSize: 11 font.pixelSize: 11
opacity: 0.75 font.family: Theme.sansFamily
} }
} }
} }
@@ -110,70 +131,98 @@ ScrollView {
} }
} }
Card { ColumnLayout {
Layout.fillWidth: true
visible: page.payload && page.payload.fun_facts.length > 0 visible: page.payload && page.payload.fun_facts.length > 0
title: "The funny part" spacing: 8
SectionTitle { text: "The funny part" }
Repeater { Repeater {
model: page.payload ? page.payload.fun_facts : [] model: page.payload ? page.payload.fun_facts : []
delegate: Label { delegate: Label {
required property var modelData required property var modelData
text: modelData text: modelData
color: Theme.ink
font.pixelSize: 13
font.family: Theme.sansFamily
wrapMode: Text.WordWrap wrapMode: Text.WordWrap
Layout.fillWidth: true Layout.fillWidth: true
font.pixelSize: 13
} }
} }
} }
Card { ColumnLayout {
Layout.fillWidth: true
visible: page.metrics && page.metrics.tasks_completed.length > 0 visible: page.metrics && page.metrics.tasks_completed.length > 0
title: "Closed today" spacing: 6
SectionTitle { text: "Closed today" }
Repeater { Repeater {
model: page.metrics ? page.metrics.tasks_completed : [] model: page.metrics ? page.metrics.tasks_completed : []
delegate: RowLayout { delegate: RowLayout {
required property var modelData required property var modelData
Layout.fillWidth: true Layout.fillWidth: true
Label { text: "✅"; font.pixelSize: 12 } spacing: 12
Label { text: modelData.title; Layout.fillWidth: true; elide: Text.ElideRight } Label {
Label { text: modelData.source; font.pixelSize: 11; opacity: 0.6 } text: modelData.title
color: Theme.ink
font.pixelSize: 13
font.family: Theme.sansFamily
elide: Text.ElideRight
Layout.fillWidth: true
}
Label {
text: modelData.source
color: Theme.faint
font.pixelSize: 11
font.family: Theme.sansFamily
}
} }
} }
} }
Card { ColumnLayout {
title: "All time" Layout.fillWidth: true
spacing: 10
SectionTitle { text: "All time" }
Panel {
padding: 0
RowLayout { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
spacing: 12 spacing: 0
StatTile { Repeater {
label: "Pomodoros" model: [
value: controller.stats.lifetime ? controller.stats.lifetime.pomodoros : "—" { label: "Pomodoros",
value: controller.stats.lifetime ? "" + controller.stats.lifetime.pomodoros : "—" },
{ label: "Hours focused",
value: controller.stats.lifetime ? "" + controller.stats.lifetime.focus_hours : "—" },
{ label: "Tasks closed",
value: controller.stats.lifetime ? "" + controller.stats.lifetime.tasks_completed : "—" },
{ label: "Blocked",
value: controller.stats.lifetime ? "" + controller.stats.lifetime.blocked_attempts : "—" }
]
delegate: RowLayout {
required property int index
required property var modelData
Layout.fillWidth: true
spacing: 0
Rectangle {
visible: index > 0
width: 1
Layout.preferredHeight: 64
color: Theme.rule
}
Stat {
Layout.fillWidth: true
Layout.margins: 18
label: modelData.label
value: modelData.value
} }
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 { Item { Layout.preferredHeight: 10 }
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() Component.onCompleted: controller.loadReport()
+209 -87
View File
@@ -1,12 +1,12 @@
// Settings, split by where they live. // Settings, split by where they live.
// //
// Anything under "This computer" is local to this machine (and saved in // "This computer" is local to this machine (~/.config/kadence/desktop.json).
// ~/.config/kadence/desktop.json). Everything else is server-side, shared with // Everything else is server-side, shared with the web app and the Telegram bot.
// the web app and the Telegram bot.
import QtQuick import QtQuick
import QtQuick.Controls.Material import QtQuick.Controls
import QtQuick.Layouts import QtQuick.Layouts
import "."
import "components" import "components"
ScrollView { ScrollView {
@@ -29,23 +29,43 @@ ScrollView {
controller.patchSettings({ notifications: { telegram: patch } }) controller.patchSettings({ notifications: { telegram: patch } })
} }
ColumnLayout { component Field: ColumnLayout {
width: page.availableWidth property string label: ""
spacing: 16 default property alias content: holder.data
spacing: 4
Label {
text: parent.label
color: Theme.muted
font.pixelSize: 11
font.family: Theme.sansFamily
}
ColumnLayout { id: holder; spacing: 0 }
}
ColumnLayout {
width: page.availableWidth - 56
x: 28
y: 26
spacing: 26
PageHeader {
eyebrow: "Shared with the web app and the Telegram bot"
title: "Settings"
caption: "Secrets are masked once stored — leave the dots alone to keep the saved value."
GhostButton { text: "Open the web app"; onClicked: controller.openWebUi() }
}
// ---- backend ----------------------------------------------------------
ColumnLayout { ColumnLayout {
Layout.fillWidth: true Layout.fillWidth: true
Layout.margins: 20 spacing: 12
spacing: 16 SectionTitle {
text: "Backend"
// ---- connection -------------------------------------------------- trailing: controller.connected ? "connected" : "offline"
Card { }
title: "Backend"
subtitle: controller.connected ? "Connected to " + controller.config.server_url
: controller.connectionError
RowLayout { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
spacing: 10
TextField { TextField {
id: serverField id: serverField
Layout.fillWidth: true Layout.fillWidth: true
@@ -67,64 +87,60 @@ ScrollView {
} }
} }
} }
RowLayout { Label {
Label { text: "Poll every"; font.pixelSize: 12 } visible: !controller.connected && controller.connectionError !== ""
SpinBox { text: controller.connectionError
from: 1; to: 60 color: Theme.clay
value: controller.config.poll_seconds font.pixelSize: 12
onValueModified: controller.setConfig("poll_seconds", value) font.family: Theme.sansFamily
}
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 Layout.fillWidth: true
columns: page.width > 700 ? 4 : 2 wrapMode: Text.WordWrap
columnSpacing: 16 }
rowSpacing: 8 }
// ---- timer -------------------------------------------------------------
ColumnLayout { ColumnLayout {
Label { text: "Focus (min)"; font.pixelSize: 11; opacity: 0.7 } Layout.fillWidth: true
spacing: 12
SectionTitle { text: "Timer" }
Flow {
Layout.fillWidth: true
spacing: 20
Field {
label: "Focus (min)"
SpinBox { SpinBox {
from: 1; to: 240 from: 1; to: 240
value: page.pomodoro ? page.pomodoro.work_minutes : 25 value: page.pomodoro ? page.pomodoro.work_minutes : 25
onValueModified: page.patchPomodoro("work_minutes", value) onValueModified: page.patchPomodoro("work_minutes", value)
} }
} }
ColumnLayout { Field {
Label { text: "Short break"; font.pixelSize: 11; opacity: 0.7 } label: "Short break"
SpinBox { SpinBox {
from: 1; to: 120 from: 1; to: 120
value: page.pomodoro ? page.pomodoro.short_break_minutes : 5 value: page.pomodoro ? page.pomodoro.short_break_minutes : 5
onValueModified: page.patchPomodoro("short_break_minutes", value) onValueModified: page.patchPomodoro("short_break_minutes", value)
} }
} }
ColumnLayout { Field {
Label { text: "Long break"; font.pixelSize: 11; opacity: 0.7 } label: "Long break"
SpinBox { SpinBox {
from: 1; to: 240 from: 1; to: 240
value: page.pomodoro ? page.pomodoro.long_break_minutes : 15 value: page.pomodoro ? page.pomodoro.long_break_minutes : 15
onValueModified: page.patchPomodoro("long_break_minutes", value) onValueModified: page.patchPomodoro("long_break_minutes", value)
} }
} }
ColumnLayout { Field {
Label { text: "Long break every"; font.pixelSize: 11; opacity: 0.7 } label: "Long break every"
SpinBox { SpinBox {
from: 1; to: 12 from: 1; to: 12
value: page.pomodoro ? page.pomodoro.cycles_before_long_break : 4 value: page.pomodoro ? page.pomodoro.cycles_before_long_break : 4
onValueModified: page.patchPomodoro("cycles_before_long_break", value) onValueModified: page.patchPomodoro("cycles_before_long_break", value)
} }
} }
ColumnLayout { Field {
Label { text: "Daily goal"; font.pixelSize: 11; opacity: 0.7 } label: "Daily goal"
SpinBox { SpinBox {
from: 1; to: 40 from: 1; to: 40
value: page.pomodoro ? page.pomodoro.daily_goal_pomodoros : 8 value: page.pomodoro ? page.pomodoro.daily_goal_pomodoros : 8
@@ -135,7 +151,12 @@ ScrollView {
Flow { Flow {
Layout.fillWidth: true Layout.fillWidth: true
spacing: 16 spacing: 18
Switch {
text: "Timer enabled (off = view-only mode)"
checked: controller.timerEnabled
onToggled: controller.setTimerEnabled(checked)
}
Switch { Switch {
text: "Auto-start breaks" text: "Auto-start breaks"
checked: page.pomodoro ? page.pomodoro.auto_start_breaks : true checked: page.pomodoro ? page.pomodoro.auto_start_breaks : true
@@ -161,15 +182,20 @@ ScrollView {
} }
} }
// ---- desktop integration ---------------------------------------- // ---- this computer ------------------------------------------------------
Card { ColumnLayout {
title: "This computer" Layout.fillWidth: true
subtitle: "Native desktop features. Stored locally, not on the server." spacing: 12
SectionTitle { text: "This computer"; trailing: "native desktop features" }
Flow { Flow {
Layout.fillWidth: true Layout.fillWidth: true
spacing: 16 spacing: 18
Switch {
text: "Start in view-only mode"
checked: controller.config.view_only
onToggled: controller.setConfig("view_only", checked)
}
Switch { Switch {
text: "Tray icon" text: "Tray icon"
checked: controller.config.tray_icon checked: controller.config.tray_icon
@@ -189,8 +215,6 @@ ScrollView {
text: "Notification buttons" text: "Notification buttons"
checked: controller.config.notification_actions checked: controller.config.notification_actions
onToggled: controller.setConfig("notification_actions", checked) onToggled: controller.setConfig("notification_actions", checked)
ToolTip.visible: hovered
ToolTip.text: "“Start break” / “Skip” directly on the notification."
} }
Switch { Switch {
text: "Keep the screen awake" text: "Keep the screen awake"
@@ -203,12 +227,37 @@ ScrollView {
onToggled: controller.setConfig("do_not_disturb", checked) onToggled: controller.setConfig("do_not_disturb", checked)
} }
Switch { Switch {
text: "Pause music when focus starts" text: "Pause music on focus"
checked: controller.config.pause_media checked: controller.config.pause_media
onToggled: controller.setConfig("pause_media", checked) onToggled: controller.setConfig("pause_media", checked)
ToolTip.visible: hovered ToolTip.visible: hovered
ToolTip.text: "Any MPRIS player: Spotify, VLC, mpv, browsers." ToolTip.text: "Any MPRIS player: Spotify, VLC, mpv, browsers."
} }
Switch {
text: "Mini timer"
checked: controller.config.mini_timer
onToggled: controller.setConfig("mini_timer", checked)
ToolTip.visible: hovered
ToolTip.text: "A small always-on-top countdown. Ctrl+M, or the tray menu."
}
Switch {
text: "Mini timer follows the window"
checked: controller.config.mini_timer_auto
onToggled: controller.setConfig("mini_timer_auto", checked)
ToolTip.visible: hovered
ToolTip.text: "Show it while an interval runs and the main window is hidden."
}
Switch {
text: "Keep the mini timer above everything"
enabled: controller.config.keep_above_supported
checked: controller.config.mini_timer_keep_above
onToggled: controller.setConfig("mini_timer_keep_above", checked)
ToolTip.visible: hovered
ToolTip.text: controller.config.keep_above_supported
? "Installs a KWin window rule — the only way to float above "
+ "other apps on Wayland. Switching this off removes it again."
: "Needs KWin (KDE). Elsewhere the Qt window flag is used."
}
Switch { Switch {
text: "Taskbar progress" text: "Taskbar progress"
checked: controller.config.taskbar_progress checked: controller.config.taskbar_progress
@@ -230,18 +279,70 @@ ScrollView {
RowLayout { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
Label { text: "Pause after"; font.pixelSize: 12 } spacing: 20
SpinBox { Field {
from: 0; to: 120 label: "Mini timer appears"
value: controller.config.pause_on_idle_minutes ComboBox {
onValueModified: controller.setConfig("pause_on_idle_minutes", value) implicitWidth: 210
textRole: "label"
valueRole: "key"
model: [
{ key: "top-left", label: "Top left" },
{ key: "top-centre", label: "Top centre" },
{ key: "top-right", label: "Top right" },
{ key: "bottom-left", label: "Bottom left" },
{ key: "bottom-centre", label: "Bottom centre" },
{ key: "bottom-right", label: "Bottom right" },
{ key: "centre", label: "Centre of the screen" },
{ key: "remember", label: "Where I last dragged it" }
]
currentIndex: {
var corner = controller.config.mini_timer_corner
for (var i = 0; i < model.length; i++)
if (model[i].key === corner) return i
return 5
}
onActivated: function (index) {
controller.setConfig("mini_timer_corner", model[index].key)
}
}
}
Field {
label: "Gap from the edges (px)"
SpinBox {
from: 0; to: 400; stepSize: 4
value: controller.config.mini_timer_margin
onValueModified: controller.setConfig("mini_timer_margin", value)
}
} }
Label { text: "idle minutes (0 = never)"; font.pixelSize: 12 }
Item { Layout.fillWidth: true } Item { Layout.fillWidth: true }
} }
RowLayout { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
spacing: 20
Field {
label: "Pause after idle (min, 0 = never)"
SpinBox {
from: 0; to: 120
value: controller.config.pause_on_idle_minutes
onValueModified: controller.setConfig("pause_on_idle_minutes", value)
}
}
Field {
label: "Poll the backend every (s)"
SpinBox {
from: 1; to: 60
value: controller.config.poll_seconds
onValueModified: controller.setConfig("poll_seconds", value)
}
}
Item { Layout.fillWidth: true }
}
Flow {
Layout.fillWidth: true
spacing: 18
Switch { Switch {
text: "Start with the session" text: "Start with the session"
checked: controller.config.autostart checked: controller.config.autostart
@@ -262,13 +363,15 @@ ScrollView {
} }
} }
// ---- telegram ---------------------------------------------------- // ---- telegram -------------------------------------------------------------
Card { ColumnLayout {
title: "Telegram" Layout.fillWidth: true
subtitle: "Daily report, morning briefing and the nudges that get you back to work." spacing: 12
SectionTitle { text: "Telegram"; trailing: "reports, briefings and nudges" }
RowLayout { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
spacing: 10
Switch { Switch {
text: "Enabled" text: "Enabled"
checked: page.telegram ? page.telegram.enabled : false checked: page.telegram ? page.telegram.enabled : false
@@ -290,7 +393,7 @@ ScrollView {
Flow { Flow {
Layout.fillWidth: true Layout.fillWidth: true
spacing: 16 spacing: 18
Switch { Switch {
text: "Daily report" text: "Daily report"
checked: page.telegram ? page.telegram.daily_report : true checked: page.telegram ? page.telegram.daily_report : true
@@ -318,33 +421,37 @@ ScrollView {
} }
} }
RowLayout { Flow {
Layout.fillWidth: true Layout.fillWidth: true
ColumnLayout { spacing: 20
Label { text: "Report at (HH:MM)"; font.pixelSize: 11; opacity: 0.7 } Field {
label: "Report at (HH:MM)"
TextField { TextField {
implicitWidth: 110
text: page.telegram ? page.telegram.daily_report_time : "21:00" text: page.telegram ? page.telegram.daily_report_time : "21:00"
onEditingFinished: page.patchTelegram("daily_report_time", text) onEditingFinished: page.patchTelegram("daily_report_time", text)
} }
} }
ColumnLayout { Field {
Label { text: "Briefing at (HH:MM)"; font.pixelSize: 11; opacity: 0.7 } label: "Briefing at (HH:MM)"
TextField { TextField {
implicitWidth: 110
text: page.telegram ? page.telegram.morning_briefing_time : "09:00" text: page.telegram ? page.telegram.morning_briefing_time : "09:00"
onEditingFinished: page.patchTelegram("morning_briefing_time", text) onEditingFinished: page.patchTelegram("morning_briefing_time", text)
} }
} }
ColumnLayout { Field {
Label { text: "Nudge after (min idle)"; font.pixelSize: 11; opacity: 0.7 } label: "Nudge after (min idle)"
SpinBox { SpinBox {
from: 10; to: 600 from: 10; to: 600
value: page.telegram ? page.telegram.nudge_after_idle_minutes : 90 value: page.telegram ? page.telegram.nudge_after_idle_minutes : 90
onValueModified: page.patchTelegram("nudge_after_idle_minutes", value) onValueModified: page.patchTelegram("nudge_after_idle_minutes", value)
} }
} }
ColumnLayout { Field {
Label { text: "Tone"; font.pixelSize: 11; opacity: 0.7 } label: "Tone"
ComboBox { ComboBox {
implicitWidth: 170
model: ["friendly", "coach", "drill_sergeant", "dry"] model: ["friendly", "coach", "drill_sergeant", "dry"]
currentIndex: page.report ? model.indexOf(page.report.tone) : 0 currentIndex: page.report ? model.indexOf(page.report.tone) : 0
onActivated: function (index) { onActivated: function (index) {
@@ -352,9 +459,10 @@ ScrollView {
} }
} }
} }
ColumnLayout { Field {
Label { text: "Language"; font.pixelSize: 11; opacity: 0.7 } label: "Language"
ComboBox { ComboBox {
implicitWidth: 110
model: ["en", "ru"] model: ["en", "ru"]
currentIndex: page.report && page.report.language === "ru" ? 1 : 0 currentIndex: page.report && page.report.language === "ru" ? 1 : 0
onActivated: function (index) { onActivated: function (index) {
@@ -365,27 +473,41 @@ ScrollView {
} }
} }
Card { // ---- scripting -------------------------------------------------------------
title: "Shortcuts and scripting" ColumnLayout {
subtitle: "The app exposes org.kadence.Desktop on the session bus." Layout.fillWidth: true
spacing: 12
SectionTitle { text: "Shortcuts and scripting" }
Panel {
Label { Label {
Layout.fillWidth: true Layout.fillWidth: true
wrapMode: Text.WordWrap wrapMode: Text.WordWrap
font.family: "Roboto Mono, monospace" color: Theme.muted
font.pixelSize: 11 font.pixelSize: 11
font.family: Theme.monoFamily
text: "qdbus org.kadence.Desktop /Timer org.kadence.Timer.Toggle\n" text: "qdbus org.kadence.Desktop /Timer org.kadence.Timer.Toggle\n"
+ "qdbus org.kadence.Desktop /Timer org.kadence.Timer.Status\n" + "qdbus org.kadence.Desktop /Timer org.kadence.Timer.Status\n"
+ "kadence-desktop --toggle # same thing from a terminal\n\n" + "kadence-desktop --toggle"
+ "System Settings → Shortcuts → Custom → D-Bus command binds these to a key."
} }
Label { Label {
text: "Kadence " + appVersion + " · sources, blocking rules and reports live on the backend." Layout.fillWidth: true
wrapMode: Text.WordWrap
color: Theme.faint
font.pixelSize: 11 font.pixelSize: 11
opacity: 0.6 font.family: Theme.sansFamily
text: "System Settings → Shortcuts → Custom → D-Bus command binds these to a key."
} }
} }
Label {
text: "Kadence " + appVersion
color: Theme.faint
font.pixelSize: 11
font.family: Theme.sansFamily
} }
} }
Item { Layout.preferredHeight: 10 }
}
Component.onCompleted: controller.loadSettings() Component.onCompleted: controller.loadSettings()
} }
+140 -86
View File
@@ -1,12 +1,19 @@
// Everything you owe, from every source, grouped by deadline. // Everything you owe, grouped by deadline.
//
// There is no checkbox: a task is closed in Jira, Todoist or Google Tasks, and
// the next refresh notices. Two places to tick something off is two places to
// be wrong.
import QtQuick import QtQuick
import QtQuick.Controls.Material import QtQuick.Controls
import QtQuick.Layouts import QtQuick.Layouts
import "."
import "components" import "components"
Item { ScrollView {
id: page id: page
contentWidth: availableWidth
clip: true
function bucketTasks(bucket) { function bucketTasks(bucket) {
var out = [] var out = []
@@ -17,130 +24,170 @@ Item {
return out return out
} }
ColumnLayout { function bucketColor(bucket) {
anchors.fill: parent if (bucket === "overdue") return Theme.clay
anchors.margins: 20 if (bucket === "today") return Theme.amber
spacing: 12 if (bucket === "active") return Theme.pine
if (bucket === "upcoming") return Theme.faint
RowLayout { return Theme.rule
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 { ColumnLayout {
width: parent.parent.availableWidth width: page.availableWidth - 56
spacing: 12 x: 28
y: 26
spacing: 22
PageHeader {
eyebrow: "From Jira, Todoist, Google Tasks and your own list"
title: "Tasks"
caption: "Close them where they live — the next refresh credits them to today."
GhostButton { text: "Add"; onClicked: addDialog.open() }
PrimaryButton { text: "Refresh sources"; onClicked: controller.refreshSources() }
}
Repeater { Repeater {
model: [ model: [
{ key: "overdue", label: "Overdue", mark: "🔴" }, { key: "overdue", label: "Overdue" },
{ key: "today", label: "Due today", mark: "🟡" }, { key: "today", label: "Due today" },
{ key: "upcoming", label: "Coming up", mark: "🔵" }, { key: "active", label: "In progress" },
{ key: "someday", label: "No deadline", mark: "⚪" } { key: "upcoming", label: "Coming up" },
{ key: "someday", label: "No deadline" }
] ]
delegate: Card { delegate: ColumnLayout {
required property var modelData required property var modelData
property var items: page.bucketTasks(modelData.key) property var items: page.bucketTasks(modelData.key)
visible: items.length > 0 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 Layout.fillWidth: true
spacing: 2 spacing: 2
SectionTitle { text: modelData.label; trailing: "" + parent.items.length }
Repeater {
model: parent.items
delegate: Item {
required property var modelData
Layout.fillWidth: true
implicitHeight: row.implicitHeight + 20
Rectangle {
anchors.bottom: parent.bottom
width: parent.width
height: 1
color: Theme.rule
}
RowLayout {
id: row
anchors.fill: parent
anchors.topMargin: 10
anchors.bottomMargin: 10
spacing: 14
Rectangle {
width: 2
Layout.fillHeight: true
radius: 1
color: page.bucketColor(modelData.bucket)
}
ColumnLayout {
Layout.fillWidth: true
spacing: 3
Label { Label {
text: modelData.title text: modelData.title
color: Theme.ink
font.pixelSize: 14 font.pixelSize: 14
font.weight: modelData.pinned ? Font.DemiBold : Font.Normal
font.family: Theme.sansFamily
elide: Text.ElideRight elide: Text.ElideRight
Layout.fillWidth: true Layout.fillWidth: true
} }
RowLayout { RowLayout {
spacing: 6 spacing: 12
Label { Label {
text: modelData.source_label text: modelData.source_label
font.pixelSize: 10 color: Theme.faint; font.pixelSize: 11
opacity: 0.65 font.family: Theme.sansFamily
} }
Label { Label {
visible: !!modelData.project visible: !!modelData.project && modelData.project !== modelData.source_label
text: "· " + modelData.project text: modelData.project || ""
font.pixelSize: 10 color: Theme.faint; font.pixelSize: 11
opacity: 0.65 font.family: Theme.sansFamily
}
Label {
visible: !!modelData.status
text: modelData.status || ""
color: Theme.faint; font.pixelSize: 11
font.family: Theme.sansFamily
} }
Label { Label {
visible: modelData.days_overdue > 0 visible: modelData.days_overdue > 0
text: "· " + modelData.days_overdue + "d overdue" text: modelData.days_overdue + "d overdue"
font.pixelSize: 10 color: Theme.clay; font.pixelSize: 11
color: "#e34948" font.weight: Font.DemiBold
font.family: Theme.sansFamily
} }
Label { Label {
visible: !!modelData.due_date && modelData.days_overdue === 0 visible: !!modelData.due_date && modelData.days_overdue === 0
text: "· " + modelData.due_date text: "due " + (modelData.due_date || "")
font.pixelSize: 10 color: Theme.faint; font.pixelSize: 11
opacity: 0.65 font.family: Theme.sansFamily
}
Label {
visible: !!modelData.priority
text: "· " + modelData.priority
font.pixelSize: 10
opacity: 0.65
} }
} }
} }
ToolButton {
text: "↗" GhostButton {
text: modelData.pinned ? "Unpin" : "Pin"
implicitHeight: 28
onClicked: controller.setTaskPinned(modelData.id, !modelData.pinned)
}
GhostButton {
visible: !!modelData.url visible: !!modelData.url
text: "Open"
implicitHeight: 28
onClicked: controller.openUrl(modelData.url) onClicked: controller.openUrl(modelData.url)
ToolTip.visible: hovered
ToolTip.text: "Open in " + modelData.source_label
} }
ToolButton { GhostButton {
text: "▶" text: "Focus"
onClicked: { controller.attachTask(modelData.id); controller.startWork(); window.page = 0 } implicitHeight: 28
ToolTip.visible: hovered onClicked: {
ToolTip.text: "Start a pomodoro on this" controller.attachTask(modelData.id)
controller.startWork()
window.page = 0
}
}
} }
} }
} }
} }
} }
Card { Panel {
visible: controller.tasks.length === 0 visible: controller.tasks.length === 0
title: "Nothing on the list" ColumnLayout {
subtitle: "Connect Jira, Todoist or Google Tasks in Settings → Sources, " spacing: 8
+ "or add something here yourself." Label {
Button { text: "Nothing on the list"
text: "Open settings" color: Theme.ink
onClicked: window.page = 4 font.pixelSize: 17
} font.family: Theme.displayFamily
} }
Label {
text: "Connect Jira, Todoist or Google Tasks in Settings, or add something yourself."
color: Theme.muted
font.pixelSize: 12
font.family: Theme.sansFamily
Layout.fillWidth: true
wrapMode: Text.WordWrap
}
GhostButton { text: "Open settings"; onClicked: window.page = 4 }
} }
} }
Item { Layout.preferredHeight: 10 }
} }
Dialog { Dialog {
@@ -153,7 +200,7 @@ Item {
ColumnLayout { ColumnLayout {
anchors.fill: parent anchors.fill: parent
spacing: 10 spacing: 12
TextField { TextField {
id: titleField id: titleField
Layout.fillWidth: true Layout.fillWidth: true
@@ -162,8 +209,15 @@ Item {
TextField { TextField {
id: dueField id: dueField
Layout.fillWidth: true Layout.fillWidth: true
placeholderText: "Due date (YYYY-MM-DD, optional)" placeholderText: "Due date — YYYY-MM-DD (optional)"
inputMask: "" }
Label {
text: "Local tasks live only here, so they are the one kind you can delete."
color: Theme.faint
font.pixelSize: 11
font.family: Theme.sansFamily
Layout.fillWidth: true
wrapMode: Text.WordWrap
} }
PrimaryButton { PrimaryButton {
text: "Add" text: "Add"
+73
View File
@@ -0,0 +1,73 @@
pragma Singleton
import QtQuick
/**
* The design tokens, shared with the web app's `tokens.ts`.
*
* Kadence reads like a well-set page rather than a dashboard: warm paper, ink
* type, hairline rules instead of boxes and shadows, one warm accent. Light and
* dark follow the desktop's own colour scheme.
*/
QtObject {
id: theme
property SystemPalette system: SystemPalette { colorGroup: SystemPalette.Active }
// The session decides; we only read it.
readonly property bool dark: system.window.hslLightness < 0.5
readonly property color paper: dark ? "#121110" : "#FAF7F2"
readonly property color surface: dark ? "#1A1917" : "#FFFFFF"
readonly property color sunken: dark ? "#201E1B" : "#F3EFE8"
readonly property color ink: dark ? "#F4F1EA" : "#17150F"
readonly property color muted: dark ? "#A79F93" : "#6E675B"
readonly property color faint: dark ? "#776F64" : "#9A9388"
readonly property color rule: dark ? Qt.rgba(0.96, 0.95, 0.92, 0.14) : Qt.rgba(0.09, 0.08, 0.06, 0.12)
readonly property color ember: dark ? "#E0724A" : "#B4441E"
readonly property color pine: dark ? "#6FB394" : "#2F6B55"
readonly property color amber: dark ? "#D9A441" : "#8A6200"
readonly property color clay: dark ? "#E07A6E" : "#A33A2E"
// Chart colours are the validated data palette, not the brand accent —
// a chart has to stay readable for colour-blind readers in both themes.
readonly property color work: dark ? "#d95926" : "#eb6834"
readonly property color shortBreak: dark ? "#3987e5" : "#2a78d6"
readonly property color longBreak: dark ? "#199e70" : "#1baf7a"
readonly property var sequential: dark
? ["#104281", "#184f95", "#256abf", "#3987e5", "#6da7ec", "#9ec5f4"]
: ["#cde2fb", "#9ec5f4", "#6da7ec", "#3987e5", "#256abf", "#184f95"]
// QML's `font.family` takes one name, so the preference list is resolved
// once against what is actually installed on this machine.
function pickFamily(candidates) {
var available = Qt.fontFamilies()
for (var i = 0; i < candidates.length; i++)
if (available.indexOf(candidates[i]) >= 0)
return candidates[i]
return candidates[candidates.length - 1]
}
readonly property string displayFamily: pickFamily(
["Fraunces", "Noto Serif", "DejaVu Serif", "Liberation Serif", "Georgia", "serif"])
readonly property string sansFamily: pickFamily(
["Inter", "Noto Sans", "DejaVu Sans", "Liberation Sans", "sans-serif"])
readonly property string monoFamily: pickFamily(
["JetBrains Mono", "Noto Sans Mono", "DejaVu Sans Mono", "Liberation Mono", "monospace"])
readonly property int radius: 12
readonly property int gap: 12
readonly property int pad: 18
function kindColor(kind) {
if (kind === "short_break") return shortBreak
if (kind === "long_break") return longBreak
return work
}
function kindLabel(kind) {
if (kind === "short_break") return "Short break"
if (kind === "long_break") return "Long break"
return "Focus"
}
}
+198 -151
View File
@@ -1,8 +1,9 @@
// The timer page: the dial, the controls, and what you are working on. // The timer: the dial, the controls, and what you are working on.
import QtQuick import QtQuick
import QtQuick.Controls.Material import QtQuick.Controls
import QtQuick.Layouts import QtQuick.Layouts
import "."
import "components" import "components"
ScrollView { ScrollView {
@@ -10,149 +11,165 @@ ScrollView {
contentWidth: availableWidth contentWidth: availableWidth
clip: true clip: true
property var openTasks: { readonly property var openTasks: {
var list = [] var list = []
for (var i = 0; i < controller.tasks.length; i++) for (var i = 0; i < controller.tasks.length; i++)
if (!controller.tasks[i].is_done) list.push(controller.tasks[i]) if (!controller.tasks[i].is_done) list.push(controller.tasks[i])
return list return list
} }
function suggestion() {
var buckets = ["overdue", "today", "upcoming", "someday"]
for (var b = 0; b < buckets.length; b++)
for (var i = 0; i < openTasks.length; i++)
if (openTasks[i].pinned || openTasks[i].bucket === buckets[b]) return openTasks[i]
return null
}
ColumnLayout { ColumnLayout {
width: page.availableWidth width: page.availableWidth - 56
spacing: 16 x: 28
anchors.margins: 20 y: 26
spacing: 22
GridLayout { PageHeader {
Layout.fillWidth: true eyebrow: Qt.formatDate(new Date(), "dddd, d MMMM")
Layout.margins: 20 title: !controller.timerEnabled ? "View mode" : (controller.running ? controller.kindLabel : "Ready when you are")
columns: page.width > 900 ? 2 : 1 caption: !controller.timerEnabled
columnSpacing: 16 ? "Timer is disabled · view-only mode"
rowSpacing: 16 : (controller.taskTitle !== ""
? "Tracking “" + controller.taskTitle + "”"
: "Cycle " + controller.cyclePosition + " of " + controller.cycleLength
+ " before a long break")
// ---- dial + controls -------------------------------------------- GhostButton {
Card { visible: controller.timerEnabled && controller.running && controller.status !== "paused"
Layout.fillWidth: true text: "Pause"
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() onClicked: controller.pause()
} }
PrimaryButton { GhostButton {
visible: controller.running && controller.status === "paused" visible: controller.timerEnabled && controller.running && controller.status === "paused"
text: "▶ Resume" text: "Resume"
onClicked: controller.resume() onClicked: controller.resume()
} }
PrimaryButton { PrimaryButton {
visible: controller.running visible: controller.timerEnabled && controller.running
text: "✓ Done" text: "Finish"
onClicked: controller.complete() onClicked: controller.complete()
} }
Button { PrimaryButton {
visible: controller.running visible: controller.timerEnabled && !controller.running
text: "+5" text: "Start focus"
onClicked: controller.extend(5) onClicked: controller.startWork()
ToolTip.visible: hovered
ToolTip.text: "Add five minutes"
} }
Button { GhostButton {
visible: controller.running visible: controller.timerEnabled && !controller.running
text: "🔔" text: controller.nextKindLabel
onClicked: controller.startBreak()
}
GhostButton {
text: controller.timerEnabled ? "Turn off timer" : "Turn on timer"
onClicked: controller.setTimerEnabled(!controller.timerEnabled)
}
}
GridLayout {
Layout.fillWidth: true
columns: page.width > 940 ? 2 : 1
columnSpacing: 22
rowSpacing: 22
Panel {
Layout.fillWidth: true
Layout.preferredWidth: 1
padding: 28
TimerDial {
Layout.alignment: Qt.AlignHCenter
Layout.preferredWidth: Math.min(290, page.availableWidth * 0.62)
Layout.preferredHeight: Layout.preferredWidth
progress: controller.timerEnabled ? controller.progress : 0
clockText: controller.timerEnabled ? controller.clockText : "—"
minutes: Math.max(1, Math.round(controller.plannedSeconds / 60))
label: !controller.timerEnabled ? "View mode" : (controller.running ? controller.kindLabel : "Next · " + controller.nextKindLabel)
sublabel: !controller.timerEnabled
? "Timer disabled"
: (controller.taskTitle !== ""
? controller.taskTitle
: (controller.running ? "" : "Pick something below, or just begin"))
accent: controller.timerEnabled ? Theme.kindColor(controller.kind) : Theme.muted
paused: controller.status === "paused"
idle: !controller.timerEnabled || !controller.running
}
RowLayout {
visible: controller.timerEnabled && controller.running
Layout.alignment: Qt.AlignHCenter
Layout.topMargin: 14
spacing: 6
GhostButton {
text: "+5 min"
onClicked: controller.extend(5)
}
GhostButton {
text: "Interruption"
onClicked: controller.interrupt() onClicked: controller.interrupt()
ToolTip.visible: hovered ToolTip.visible: hovered
ToolTip.text: "Count an interruption without stopping" ToolTip.text: "Count a distraction without stopping the clock"
} }
Button { GhostButton {
visible: controller.running text: "Abandon"
text: "⏹"
flat: true
Material.foreground: "#e34948"
onClicked: abandonDialog.open() onClicked: abandonDialog.open()
ToolTip.visible: hovered
ToolTip.text: "Abandon this interval"
} }
} }
Label { Label {
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
visible: controller.interruptions > 0 visible: controller.timerEnabled && controller.interruptions > 0
text: controller.interruptions + " interruption(s) this interval" text: controller.interruptions + " interruption(s) so far"
color: Theme.faint
font.pixelSize: 11 font.pixelSize: 11
opacity: 0.6 font.family: Theme.sansFamily
} }
Label { Label {
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
visible: controller.timerEnabled
text: "Space start/pause · Ctrl+Enter finish · Ctrl+E +5 min" text: "Space start/pause · Ctrl+Enter finish · Ctrl+E +5 min"
color: Theme.faint
font.pixelSize: 10 font.pixelSize: 10
opacity: 0.45 font.family: Theme.sansFamily
} }
} }
// ---- today + task picker ----------------------------------------
ColumnLayout { ColumnLayout {
Layout.fillWidth: true Layout.fillWidth: true
Layout.preferredWidth: 1 Layout.preferredWidth: 1
Layout.alignment: Qt.AlignTop Layout.alignment: Qt.AlignTop
spacing: 16 spacing: 20
Card { ColumnLayout {
title: "Today" Layout.fillWidth: true
subtitle: "Cycle " + controller.cyclePosition + " of " + controller.cycleLength spacing: 10
+ " before a long break" SectionTitle { text: "Today" }
RowLayout { RowLayout {
Layout.fillWidth: true
spacing: 8 spacing: 8
Label { Label {
text: controller.completedToday text: controller.completedToday
font.pixelSize: 34 color: Theme.ink
font.weight: Font.Light font.pixelSize: 40
font.family: Theme.displayFamily
font.letterSpacing: -1
} }
Label { Label {
text: "/ " + controller.goal + " pomodoros" text: "of " + controller.goal + " pomodoros"
font.pixelSize: 16 color: Theme.muted
opacity: 0.7 font.pixelSize: 14
font.family: Theme.sansFamily
Layout.alignment: Qt.AlignBottom Layout.alignment: Qt.AlignBottom
Layout.bottomMargin: 6 Layout.bottomMargin: 7
} }
Item { Layout.fillWidth: true }
}
ProgressBar {
Layout.fillWidth: true
from: 0
to: Math.max(controller.goal, 1)
value: Math.min(controller.completedToday, controller.goal)
} }
Flow { Flow {
@@ -162,25 +179,43 @@ ScrollView {
model: Math.max(controller.goal, controller.completedToday) model: Math.max(controller.goal, controller.completedToday)
delegate: Rectangle { delegate: Rectangle {
required property int index required property int index
width: 14; height: 14; radius: 7 width: 11; height: 11; radius: 5.5
color: index < controller.completedToday ? window.workColor : "transparent" color: index < controller.completedToday ? Theme.work : "transparent"
border.width: index < controller.completedToday ? 0 : 1 border.width: index < controller.completedToday ? 0 : 1
border.color: Material.hintTextColor border.color: Theme.rule
}
} }
} }
} }
Card { Label {
title: "What are you working on?" Layout.fillWidth: true
text: controller.stats.today
? controller.stats.today.focus_minutes + "m focused · "
+ controller.stats.today.break_minutes + "m rested"
+ (controller.stats.today.longest_stretch_minutes > 0
? " · longest run " + controller.stats.today.longest_stretch_minutes + "m" : "")
: ""
color: Theme.muted
font.pixelSize: 12
font.family: Theme.sansFamily
wrapMode: Text.WordWrap
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 10
SectionTitle { text: "Working on" }
ComboBox { ComboBox {
id: taskPicker id: taskPicker
Layout.fillWidth: true Layout.fillWidth: true
font.pixelSize: 13
font.family: Theme.sansFamily
model: { model: {
var names = ["No task"] var names = ["Nothing in particular"]
for (var i = 0; i < page.openTasks.length; i++) for (var i = 0; i < page.openTasks.length; i++)
names.push((page.openTasks[i].pinned ? "📌 " : "") names.push((page.openTasks[i].pinned ? "★ " : "")
+ page.openTasks[i].title + page.openTasks[i].title
+ (page.openTasks[i].due_date ? " · " + page.openTasks[i].due_date : "")) + (page.openTasks[i].due_date ? " · " + page.openTasks[i].due_date : ""))
return names return names
@@ -190,66 +225,77 @@ ScrollView {
} }
} }
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 { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
spacing: 8 spacing: 14
Label { Label {
text: "🔴 " + (controller.taskCounts.overdue || 0) + " overdue" text: (controller.taskCounts.overdue || 0) + " overdue"
font.pixelSize: 12 color: (controller.taskCounts.overdue || 0) > 0 ? Theme.clay : Theme.muted
font.pixelSize: 11
font.family: Theme.sansFamily
} }
Label { Label {
text: "🟡 " + (controller.taskCounts.today || 0) + " due today" text: (controller.taskCounts.today || 0) + " due today"
font.pixelSize: 12 color: Theme.muted
font.pixelSize: 11
font.family: Theme.sansFamily
} }
Item { Layout.fillWidth: true } Item { Layout.fillWidth: true }
Button { text: "Tasks"; flat: true; onClicked: window.page = 1 } GhostButton { text: "All tasks"; onClicked: window.page = 1 }
} }
} }
} }
} }
// ---- the day's numbers ------------------------------------------------ // ---- the day's numbers -------------------------------------------------
GridLayout { Panel {
padding: 0
RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
Layout.leftMargin: 20 spacing: 0
Layout.rightMargin: 20
Layout.bottomMargin: 20
columns: page.width > 760 ? 4 : 2
columnSpacing: 12
rowSpacing: 12
StatTile { Repeater {
label: "Focus today" model: [
value: controller.stats.today ? controller.stats.today.focus_minutes + "m" : "—" { label: "Focus today",
caption: controller.completedToday + " pomodoros" value: controller.stats.today ? controller.stats.today.focus_minutes + "m" : "—",
caption: controller.completedToday + " intervals" },
{ label: "Interruptions",
value: controller.stats.today ? "" + controller.stats.today.interruptions : "—",
caption: controller.stats.today && controller.stats.today.abandoned > 0
? controller.stats.today.abandoned + " abandoned" : "none dropped" },
{ label: "Closed today",
value: controller.stats.today ? "" + controller.stats.today.tasks_completed_count : "—",
caption: "in your own tools" },
{ label: "Blocked",
value: controller.stats.today ? "" + controller.stats.today.blocked_attempts : "—",
caption: "temptations turned away" }
]
delegate: RowLayout {
required property int index
required property var modelData
Layout.fillWidth: true
spacing: 0
Rectangle {
visible: index > 0
width: 1
Layout.fillHeight: true
Layout.preferredHeight: 70
color: Theme.rule
} }
StatTile { Stat {
label: "Longest stretch" Layout.fillWidth: true
value: controller.stats.today ? controller.stats.today.longest_stretch_minutes + "m" : "—" Layout.margins: 18
caption: "unbroken focus" label: modelData.label
} value: modelData.value
StatTile { caption: modelData.caption
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"
} }
} }
} }
}
}
Item { Layout.preferredHeight: 10 }
}
Dialog { Dialog {
id: abandonDialog id: abandonDialog
@@ -261,9 +307,12 @@ ScrollView {
ColumnLayout { ColumnLayout {
anchors.fill: parent anchors.fill: parent
spacing: 10 spacing: 12
Label { Label {
text: controller.clockText + " left. The interval is recorded as abandoned." text: controller.clockText + " left. It will be recorded as abandoned."
color: Theme.muted
font.pixelSize: 12
font.family: Theme.sansFamily
wrapMode: Text.WordWrap wrapMode: Text.WordWrap
Layout.fillWidth: true Layout.fillWidth: true
} }
@@ -272,10 +321,8 @@ ScrollView {
Layout.fillWidth: true Layout.fillWidth: true
placeholderText: "What happened? (optional)" placeholderText: "What happened? (optional)"
} }
Button { PrimaryButton {
text: "Abandon" text: "Abandon"
Material.background: "#e34948"
Material.foreground: "white"
Layout.alignment: Qt.AlignRight Layout.alignment: Qt.AlignRight
onClicked: { controller.abandon(reasonField.text); reasonField.text = ""; abandonDialog.close() } onClicked: { controller.abandon(reasonField.text); reasonField.text = ""; abandonDialog.close() }
} }
@@ -1,27 +1,34 @@
// An inline warning strip — used for "the backend is gone" and "the helper is // An inline warning strip for the two states where the app cannot do its job:
// not installed", the two states where the app cannot do its job. // the backend is unreachable, or the privileged helper is missing.
import QtQuick import QtQuick
import QtQuick.Controls.Material import QtQuick.Controls
import QtQuick.Layouts import QtQuick.Layouts
import ".."
Rectangle { Rectangle {
id: banner id: banner
property string text: "" property string text: ""
property string actionText: "" property string actionText: ""
signal actionClicked() signal actionClicked()
Layout.fillWidth: true
implicitHeight: visible ? row.implicitHeight + 20 : 0 implicitHeight: visible ? row.implicitHeight + 20 : 0
color: "#e34948" radius: Theme.radius
opacity: 0.92 color: Qt.alpha(Theme.clay, 0.12)
border.width: 1
border.color: Qt.alpha(Theme.clay, 0.4)
RowLayout { RowLayout {
id: row id: row
anchors.fill: parent anchors.fill: parent
anchors.margins: 10 anchors.margins: 12
spacing: 12 spacing: 12
Label { Label {
text: banner.text text: banner.text
color: "white" color: Theme.ink
font.pixelSize: 12
font.family: Theme.sansFamily
wrapMode: Text.WordWrap wrapMode: Text.WordWrap
Layout.fillWidth: true Layout.fillWidth: true
} }
@@ -29,7 +36,6 @@ Rectangle {
visible: banner.actionText !== "" visible: banner.actionText !== ""
text: banner.actionText text: banner.actionText
flat: true flat: true
Material.foreground: "white"
onClicked: banner.actionClicked() onClicked: banner.actionClicked()
} }
} }
@@ -1,18 +1,21 @@
// A tiny bar chart: magnitude over time, one hue, baseline at zero. // 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 take a darker
// The ramp step says "did this day clear the goal" without adding a second // step of the same ramp, which keeps "did I hit it" readable without a second
// colour, and every bar carries its own tooltip, so no bar needs a label. // colour.
import QtQuick import QtQuick
import QtQuick.Controls.Material import QtQuick.Controls
import QtQuick.Layouts import QtQuick.Layouts
import ".."
Item { Item {
id: chart id: chart
property var days: [] // [{date, pomodoros, focus_minutes, goal_met}] property var days: []
property int goal: 8 property int goal: 8
property int barHeight: 110 property int barHeight: 108
implicitHeight: barHeight + 18 Layout.fillWidth: true
implicitHeight: barHeight + 16
readonly property int maxValue: { readonly property int maxValue: {
var top = goal var top = goal
@@ -25,8 +28,7 @@ Item {
anchors.right: parent.right anchors.right: parent.right
y: chart.barHeight - (chart.goal / chart.maxValue) * chart.barHeight y: chart.barHeight - (chart.goal / chart.maxValue) * chart.barHeight
height: 1 height: 1
color: Material.hintTextColor color: Theme.rule
opacity: 0.45
visible: chart.goal > 0 visible: chart.goal > 0
} }
@@ -48,15 +50,16 @@ Item {
width: parent.width width: parent.width
height: Math.max(modelData.pomodoros > 0 height: Math.max(modelData.pomodoros > 0
? (modelData.pomodoros / chart.maxValue) * chart.barHeight : 2, 2) ? (modelData.pomodoros / chart.maxValue) * chart.barHeight : 2, 2)
radius: 4 radius: 3
color: modelData.pomodoros === 0 ? Material.dividerColor color: modelData.pomodoros === 0
: (modelData.goal_met ? "#256abf" : "#6da7ec") ? Theme.rule
: (modelData.goal_met ? Theme.sequential[4] : Theme.sequential[2])
ToolTip.visible: hover.hovered ToolTip.visible: hover.hovered
ToolTip.text: modelData.date + ": " + modelData.pomodoros ToolTip.text: modelData.date + " · " + modelData.pomodoros
+ " pomodoros · " + modelData.focus_minutes + " min" + " pomodoros · " + modelData.focus_minutes + " min"
HoverHandler { id: hover } HoverHandler { id: hover }
Behavior on height { NumberAnimation { duration: 250 } } Behavior on height { NumberAnimation { duration: 220 } }
} }
} }
} }
@@ -68,12 +71,12 @@ Item {
anchors.right: parent.right anchors.right: parent.right
Label { Label {
text: chart.days.length ? chart.days[0].date.substring(5) : "" text: chart.days.length ? chart.days[0].date.substring(5) : ""
font.pixelSize: 10; opacity: 0.6 font.pixelSize: 10; color: Theme.faint; font.family: Theme.sansFamily
} }
Item { Layout.fillWidth: true } Item { Layout.fillWidth: true }
Label { Label {
text: chart.days.length ? chart.days[chart.days.length - 1].date.substring(5) : "" text: chart.days.length ? chart.days[chart.days.length - 1].date.substring(5) : ""
font.pixelSize: 10; opacity: 0.6 font.pixelSize: 10; color: Theme.faint; font.family: Theme.sansFamily
} }
} }
} }
@@ -1,51 +0,0 @@
// A Material surface with a title. Used everywhere instead of bare Panes so the
// pages share one radius, one padding and one elevation.
import QtQuick
import QtQuick.Controls.Material
import QtQuick.Layouts
Pane {
id: card
property string title: ""
property string subtitle: ""
default property alias content: inner.data
Material.elevation: 1
Material.roundedScale: Material.LargeScale
padding: 18
Layout.fillWidth: true
background: Rectangle {
radius: 18
color: Material.dialogColor
border.width: 1
border.color: Material.dividerColor
}
ColumnLayout {
anchors.fill: parent
spacing: 10
ColumnLayout {
spacing: 2
visible: card.title !== ""
Layout.fillWidth: true
Label { text: card.title; font.pixelSize: 16; font.weight: Font.DemiBold }
Label {
text: card.subtitle
visible: card.subtitle !== ""
font.pixelSize: 12
opacity: 0.7
Layout.fillWidth: true
wrapMode: Text.WordWrap
}
}
ColumnLayout {
id: inner
Layout.fillWidth: true
Layout.fillHeight: true
spacing: 10
}
}
}
@@ -0,0 +1,29 @@
// The outlined button, for everything that is not the primary action.
import QtQuick
import QtQuick.Controls
import ".."
Button {
id: control
implicitHeight: 34
leftPadding: 14
rightPadding: 14
background: Rectangle {
radius: 8
color: control.hovered ? Qt.alpha(Theme.ember, 0.07) : "transparent"
border.width: 1
border.color: control.hovered ? Theme.ember : Theme.rule
Behavior on color { ColorAnimation { duration: 110 } }
}
contentItem: Label {
text: control.text
color: control.enabled ? Theme.ink : Theme.faint
font.pixelSize: 13
font.weight: Font.Medium
font.family: Theme.sansFamily
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
@@ -0,0 +1,55 @@
// Eyebrow, title, actions, rule — the same opening every page uses.
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import ".."
ColumnLayout {
property string eyebrow: ""
property string title: ""
property string caption: ""
default property alias actions: actionRow.data
Layout.fillWidth: true
spacing: 10
RowLayout {
Layout.fillWidth: true
spacing: 16
ColumnLayout {
Layout.fillWidth: true
spacing: 2
Label {
visible: eyebrow !== ""
text: eyebrow.toUpperCase()
color: Theme.muted
font.pixelSize: 10
font.letterSpacing: 1.3
font.weight: Font.DemiBold
font.family: Theme.sansFamily
}
Label {
text: title
color: Theme.ink
font.pixelSize: 27
font.family: Theme.displayFamily
font.letterSpacing: -0.6
}
Label {
visible: caption !== ""
text: caption
color: Theme.muted
font.pixelSize: 13
font.family: Theme.sansFamily
Layout.fillWidth: true
wrapMode: Text.WordWrap
}
}
RowLayout { id: actionRow; spacing: 8; Layout.alignment: Qt.AlignRight | Qt.AlignVCenter }
}
Rectangle { Layout.fillWidth: true; height: 1; color: Theme.rule }
}
@@ -0,0 +1,28 @@
// A surface with a hairline border. No shadow, no tint — the border does the
// separating, which is what keeps a page of panels calm.
import QtQuick
import QtQuick.Layouts
import ".."
Rectangle {
id: panel
default property alias content: inner.data
property int padding: Theme.pad
property alias spacing: inner.spacing
color: Theme.surface
radius: Theme.radius
border.width: 1
border.color: Theme.rule
implicitHeight: inner.implicitHeight + padding * 2
implicitWidth: inner.implicitWidth + padding * 2
Layout.fillWidth: true
ColumnLayout {
id: inner
anchors.fill: parent
anchors.margins: panel.padding
spacing: 10
}
}
@@ -1,34 +1,28 @@
// The filled Material button, painted explicitly. // The filled button: one per view, painted explicitly so no style can lose it.
//
// 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
import QtQuick.Controls.Material import QtQuick.Controls
import ".."
Button { Button {
id: control id: control
implicitHeight: 34
Material.foreground: "white" leftPadding: 16
font.weight: Font.Medium rightPadding: 16
leftPadding: 20
rightPadding: 20
implicitHeight: 40
background: Rectangle { background: Rectangle {
radius: height / 2 radius: 8
color: control.enabled ? Material.accent : Material.buttonDisabledColor color: control.enabled ? Theme.ember : Theme.rule
opacity: control.pressed ? 0.82 : (control.hovered ? 0.92 : 1) opacity: control.pressed ? 0.82 : (control.hovered ? 0.92 : 1)
Behavior on opacity { NumberAnimation { duration: 120 } } Behavior on opacity { NumberAnimation { duration: 110 } }
} }
contentItem: Label { contentItem: Label {
text: control.text text: control.text
font: control.font color: control.enabled ? "white" : Theme.faint
color: control.enabled ? "white" : Material.hintTextColor font.pixelSize: 13
font.weight: Font.Medium
font.family: Theme.sansFamily
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
} }
} }
@@ -0,0 +1,31 @@
// An eyebrow label with a rule running to the right edge.
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import ".."
RowLayout {
property string text: ""
property string trailing: ""
Layout.fillWidth: true
spacing: 10
Label {
text: parent.text.toUpperCase()
color: Theme.muted
font.pixelSize: 10
font.letterSpacing: 1.3
font.weight: Font.DemiBold
font.family: Theme.sansFamily
}
Rectangle { Layout.fillWidth: true; height: 1; color: Theme.rule }
Label {
visible: parent.trailing !== ""
text: parent.trailing
color: Theme.faint
font.pixelSize: 11
font.family: Theme.sansFamily
}
}
@@ -1,6 +1,8 @@
// Transient messages. Material's own popup, anchored to the bottom. // Transient messages, bottom centre, on an ink slab.
import QtQuick import QtQuick
import QtQuick.Controls.Material import QtQuick.Controls
import ".."
Popup { Popup {
id: snackbar id: snackbar
@@ -13,19 +15,23 @@ Popup {
} }
x: Math.round((parent.width - width) / 2) x: Math.round((parent.width - width) / 2)
y: parent.height - height - 72 y: parent.height - height - 28
width: Math.min(parent.width - 48, Math.max(260, label.implicitWidth + 48)) width: Math.min(parent.width - 48, Math.max(240, label.implicitWidth + 36))
padding: 16 padding: 14
modal: false modal: false
focus: false focus: false
closePolicy: Popup.NoAutoClose closePolicy: Popup.NoAutoClose
Material.elevation: 6
Material.roundedScale: Material.SmallScale
Label { background: Rectangle {
color: Theme.ink
radius: 10
}
contentItem: Label {
id: label id: label
anchors.fill: parent
text: snackbar.message text: snackbar.message
color: Theme.paper
font.pixelSize: 13
font.family: Theme.sansFamily
wrapMode: Text.WordWrap wrapMode: Text.WordWrap
} }
@@ -0,0 +1,44 @@
// A number and its name. The figure is set in the display serif, which is what
// makes a screen of statistics read like a page rather than a readout.
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import ".."
ColumnLayout {
property string label: ""
property string value: "—"
property string caption: ""
property color accent: Theme.ink
property int size: 30
spacing: 2
Layout.fillWidth: true
Label {
text: label.toUpperCase()
color: Theme.muted
font.pixelSize: 10
font.letterSpacing: 1.2
font.weight: Font.DemiBold
font.family: Theme.sansFamily
}
Label {
text: value
color: accent
font.pixelSize: size
font.family: Theme.displayFamily
font.letterSpacing: -0.5
}
Label {
visible: caption !== ""
text: caption
color: Theme.faint
font.pixelSize: 11
font.family: Theme.sansFamily
Layout.fillWidth: true
elide: Text.ElideRight
wrapMode: Text.WordWrap
}
}
@@ -1,38 +0,0 @@
// 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
}
}
}
@@ -1,29 +0,0 @@
// 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
}
}
@@ -1,9 +1,13 @@
// The countdown ring, drawn with Canvas. // The dial.
// //
// A hero number inside a progress arc: the number is what you read, the arc is // A hairline ring with one tick per minute inside it: the ticks light up as the
// the glance. The arc colour is the only thing that says focus vs break. // minutes are spent, so the dial reads as "how much is left" at a glance and as
// "how long was this?" on a second look. The time is the hero, set in the
// display serif.
import QtQuick import QtQuick
import QtQuick.Controls.Material import QtQuick.Controls
import ".."
Item { Item {
id: dial id: dial
@@ -11,15 +15,18 @@ Item {
property string clockText: "00:00" property string clockText: "00:00"
property string label: "" property string label: ""
property string sublabel: "" property string sublabel: ""
property color accent: "#2a78d6" property color accent: Theme.work
property bool paused: false property bool paused: false
property bool idle: false
property int minutes: 25
implicitWidth: 260 implicitWidth: 280
implicitHeight: 260 implicitHeight: 280
onProgressChanged: canvas.requestPaint() onProgressChanged: canvas.requestPaint()
onAccentChanged: canvas.requestPaint() onAccentChanged: canvas.requestPaint()
onPausedChanged: canvas.requestPaint() onPausedChanged: canvas.requestPaint()
onIdleChanged: canvas.requestPaint()
Canvas { Canvas {
id: canvas id: canvas
@@ -29,59 +36,85 @@ Item {
onPaint: { onPaint: {
var ctx = getContext("2d") var ctx = getContext("2d")
ctx.reset() ctx.reset()
var centre = Math.min(width, height) / 2 var c = Math.min(width, height) / 2
var stroke = Math.max(10, centre * 0.11) var radius = c - 26
var radius = centre - stroke
ctx.lineCap = "round"
ctx.lineWidth = stroke
// The ring.
ctx.lineWidth = 1.5
ctx.strokeStyle = Theme.rule
ctx.beginPath() ctx.beginPath()
ctx.strokeStyle = Qt.rgba(0.5, 0.5, 0.5, 0.18) ctx.arc(c, c, radius, 0, Math.PI * 2)
ctx.arc(centre, centre, radius, 0, Math.PI * 2)
ctx.stroke() ctx.stroke()
// Minute ticks, inside the ring.
var count = dial.minutes <= 60 ? Math.max(1, dial.minutes) : Math.round(dial.minutes / 5)
var spent = Math.floor(count * Math.max(0, Math.min(1, dial.progress)))
for (var i = 0; i < count; i++) {
var angle = (i / count) * Math.PI * 2 - Math.PI / 2
var major = i % 5 === 0
var outer = radius - 7
var inner = outer - (major ? 9 : 5)
ctx.beginPath()
ctx.lineWidth = major ? 1.4 : 1
ctx.strokeStyle = (i < spent && !dial.idle) ? dial.accent : Theme.rule
ctx.moveTo(c + inner * Math.cos(angle), c + inner * Math.sin(angle))
ctx.lineTo(c + outer * Math.cos(angle), c + outer * Math.sin(angle))
ctx.stroke()
}
// The remaining arc.
if (!dial.idle) {
var remaining = 1 - Math.max(0, Math.min(1, dial.progress)) var remaining = 1 - Math.max(0, Math.min(1, dial.progress))
if (remaining > 0) { if (remaining > 0) {
ctx.beginPath() ctx.beginPath()
ctx.globalAlpha = dial.paused ? 0.45 : 1 ctx.lineWidth = 2.5
ctx.lineCap = "round"
ctx.globalAlpha = dial.paused ? 0.4 : 1
ctx.strokeStyle = dial.accent ctx.strokeStyle = dial.accent
ctx.arc(centre, centre, radius, -Math.PI / 2, -Math.PI / 2 + remaining * Math.PI * 2) ctx.arc(c, c, radius, -Math.PI / 2, -Math.PI / 2 + remaining * Math.PI * 2)
ctx.stroke() ctx.stroke()
} }
} }
} }
}
Column { Column {
anchors.centerIn: parent anchors.centerIn: parent
spacing: 4 spacing: 6
width: parent.width * 0.72 width: parent.width * 0.7
Label { Label {
width: parent.width width: parent.width
text: dial.label.toUpperCase() text: dial.label.toUpperCase()
font.pixelSize: 11 color: dial.idle ? Theme.muted : dial.accent
font.letterSpacing: 1.5 font.pixelSize: 10
opacity: 0.65 font.letterSpacing: 1.4
font.weight: Font.DemiBold
font.family: Theme.sansFamily
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
} }
Label { Label {
width: parent.width width: parent.width
text: dial.clockText text: dial.clockText
font.pixelSize: Math.round(dial.width * 0.2) color: Theme.ink
font.weight: Font.Light opacity: dial.paused ? 0.55 : 1
font.family: "Roboto Mono, monospace" font.pixelSize: Math.round(dial.width * 0.21)
opacity: dial.paused ? 0.6 : 1 font.family: Theme.displayFamily
font.letterSpacing: -1.5
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
Behavior on opacity { NumberAnimation { duration: 180 } }
} }
Label { Label {
width: parent.width width: parent.width
text: dial.sublabel
visible: dial.sublabel !== "" visible: dial.sublabel !== ""
font.pixelSize: 13 text: dial.sublabel
opacity: 0.75 color: Theme.muted
elide: Text.ElideRight font.pixelSize: 12
font.family: Theme.sansFamily
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
maximumLineCount: 2
wrapMode: Text.WordWrap
} }
} }
} }
+2
View File
@@ -0,0 +1,2 @@
# Makes `Theme` a singleton for every file that does `import "."`.
singleton Theme 1.0 Theme.qml
+33 -8
View File
@@ -82,6 +82,9 @@ class Tray(QObject):
controller.stateChanged.connect(self.refresh) controller.stateChanged.connect(self.refresh)
controller.connectionChanged.connect(self.refresh) controller.connectionChanged.connect(self.refresh)
controller.blockerChanged.connect(self.refresh) controller.blockerChanged.connect(self.refresh)
controller.miniChanged.connect(self.refresh)
controller.settingsChanged.connect(self.refresh)
controller.configChanged.connect(self.refresh)
self.refresh() self.refresh()
def show(self) -> None: def show(self) -> None:
@@ -140,6 +143,18 @@ class Tray(QObject):
self._menu.addAction(refresh) self._menu.addAction(refresh)
self._menu.addSeparator() self._menu.addSeparator()
self._action_mini = QAction("Mini timer", self._menu)
self._action_mini.setCheckable(True)
self._action_mini.triggered.connect(lambda: controller.toggleMini())
self._menu.addAction(self._action_mini)
self._action_view_mode = QAction("View-only mode (timer off)", self._menu)
self._action_view_mode.setCheckable(True)
self._action_view_mode.triggered.connect(
lambda: controller.setTimerEnabled(not controller.timerEnabled)
)
self._menu.addAction(self._action_view_mode)
show = QAction("Open Kadence", self._menu) show = QAction("Open Kadence", self._menu)
show.triggered.connect(self.showRequested.emit) show.triggered.connect(self.showRequested.emit)
self._menu.addAction(show) self._menu.addAction(show)
@@ -157,22 +172,32 @@ class Tray(QObject):
controller = self._controller controller = self._controller
running = controller.running running = controller.running
paused = controller.status == "paused" paused = controller.status == "paused"
timer_enabled = controller.timerEnabled
self._icon.setIcon( self._icon.setIcon(
render_icon(controller.progress, controller.kind, running, controller.connected, paused) render_icon(
controller.progress,
controller.kind,
running if timer_enabled else False,
controller.connected,
paused if timer_enabled else False,
)
) )
status = controller.statusLine() status = controller.statusLine()
blocker = " · 🛡 blocking" if controller.blockerActive else "" blocker = " · 🛡 blocking" if controller.blockerActive else ""
self._icon.setToolTip(f"{status}{blocker}") self._icon.setToolTip(f"{status}{blocker}")
self._action_status.setText(status) self._action_status.setText(status)
self._action_start.setVisible(not running) self._action_start.setVisible(timer_enabled and not running)
self._action_break.setVisible(not running) self._action_break.setVisible(timer_enabled and not running)
self._action_pause.setVisible(running and not paused) self._action_pause.setVisible(timer_enabled and running and not paused)
self._action_resume.setVisible(running and paused) self._action_resume.setVisible(timer_enabled and running and paused)
self._action_done.setVisible(running) self._action_done.setVisible(timer_enabled and running)
self._action_extend.setVisible(running) self._action_extend.setVisible(timer_enabled and running)
self._action_abandon.setVisible(running) self._action_abandon.setVisible(timer_enabled and running)
self._action_mini.setVisible(timer_enabled)
self._action_mini.setChecked(controller.miniVisible)
self._action_view_mode.setChecked(not timer_enabled)
def _on_activated(self, reason: QSystemTrayIcon.ActivationReason) -> None: def _on_activated(self, reason: QSystemTrayIcon.ActivationReason) -> None:
# Left click toggles the window; middle click toggles the timer, which is # Left click toggles the window; middle click toggles the timer, which is
+2 -2
View File
@@ -129,7 +129,7 @@ class ApiWorker(QObject):
"extend": lambda: self._api.extend(int(args.get("minutes", 5))), "extend": lambda: self._api.extend(int(args.get("minutes", 5))),
"interrupt": self._api.interrupt, "interrupt": self._api.interrupt,
"attach_task": lambda: self._api.attach_task(args.get("task_id")), "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))), "patch_task": lambda: self._api.patch_task(int(args["task_id"]), dict(args.get("patch", {}))),
"create_task": lambda: self._api.create_task(str(args["title"]), args.get("due_date")), "create_task": lambda: self._api.create_task(str(args["title"]), args.get("due_date")),
"refresh_sources": self._api.refresh_tasks, "refresh_sources": self._api.refresh_tasks,
"send_report": self._api.send_report, "send_report": self._api.send_report,
@@ -156,7 +156,7 @@ class ApiWorker(QObject):
return return
self.commandDone.emit(name, result if isinstance(result, dict) else {}) self.commandDone.emit(name, result if isinstance(result, dict) else {})
if name in ("refresh_sources", "task_done", "create_task"): if name in ("refresh_sources", "patch_task", "create_task"):
self.refresh_tasks() self.refresh_tasks()
if name == "patch_settings": if name == "patch_settings":
self.refresh_settings() self.refresh_settings()
+31 -5
View File
@@ -13,7 +13,8 @@ BIN="${HOME}/.local/bin"
mkdir -p "$APPS" "$ICONS" "$BIN" mkdir -p "$APPS" "$ICONS" "$BIN"
# A launcher that works whether or not the package is pip-installed. # A launcher that works whether or not the package is pip-installed. The
# desktop entry points at this absolute path, so nothing depends on PATH.
cat > "$BIN/kadence-desktop" <<LAUNCHER cat > "$BIN/kadence-desktop" <<LAUNCHER
#!/usr/bin/env bash #!/usr/bin/env bash
exec python3 -m kadence_desktop "\$@" exec python3 -m kadence_desktop "\$@"
@@ -25,11 +26,36 @@ SITE_PTH="$(python3 -c 'import site; print(site.getusersitepackages())')"
mkdir -p "$SITE_PTH" mkdir -p "$SITE_PTH"
echo "$(dirname "$HERE")" > "$SITE_PTH/kadence-desktop.pth" echo "$(dirname "$HERE")" > "$SITE_PTH/kadence-desktop.pth"
install -m 0644 "$HERE/kadence.desktop" "$APPS/kadence.desktop" # Exec/TryExec are written as absolute paths on purpose.
#
# Application launchers (KDE's Kickoff, rofi -show drun, GNOME's overview) run
# with the *session* PATH, which on most setups does not include ~/.local/bin —
# and an entry whose TryExec cannot be resolved is silently hidden. An absolute
# path cannot be hidden by a PATH that differs from your shell's.
sed -e "s|^Exec=kadence-desktop|Exec=$BIN/kadence-desktop|" \
-e "s|^TryExec=kadence-desktop|TryExec=$BIN/kadence-desktop|" \
-e "s|^Exec=kadence-desktop |Exec=$BIN/kadence-desktop |" \
"$HERE/kadence.desktop" > "$APPS/kadence.desktop"
chmod 0644 "$APPS/kadence.desktop"
install -m 0644 "$HERE/kadence.svg" "$ICONS/kadence.svg" install -m 0644 "$HERE/kadence.svg" "$ICONS/kadence.svg"
# Each launcher keeps its own cache; refresh all of the ones that are present.
command -v update-desktop-database >/dev/null && update-desktop-database "$APPS" || true 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 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
command -v kbuildsycoca6 >/dev/null && kbuildsycoca6 --noincremental >/dev/null 2>&1 || true
# rofi rebuilds its drun cache when the directory changes; nudge the timestamp.
touch "$APPS" 2>/dev/null || true
echo "Installed. Launch it from the menu, or run: kadence-desktop" if command -v desktop-file-validate >/dev/null; then
echo "Make sure ~/.local/bin is on your PATH." desktop-file-validate "$APPS/kadence.desktop" && echo "Desktop entry validates."
fi
echo
echo "Installed:"
echo " launcher $BIN/kadence-desktop"
echo " menu entry $APPS/kadence.desktop"
echo " icon $ICONS/kadence.svg"
echo
echo "It should now appear in the application menu and in \`rofi -show drun\`"
echo "as \"Kadence\" (search: pomodoro, timer, focus)."
+1 -1
View File
@@ -7,7 +7,7 @@ Exec=kadence-desktop
TryExec=kadence-desktop TryExec=kadence-desktop
Icon=kadence Icon=kadence
Terminal=false Terminal=false
Categories=Utility;Office;ProjectManagement; Categories=Office;ProjectManagement;
Keywords=pomodoro;timer;focus;tasks;productivity; Keywords=pomodoro;timer;focus;tasks;productivity;
StartupNotify=true StartupNotify=true
StartupWMClass=kadence StartupWMClass=kadence
+7
View File
@@ -8,6 +8,9 @@ requires-python = ">=3.11"
# so the app starts with the system Python and no virtualenv. # so the app starts with the system Python and no virtualenv.
dependencies = ["PyQt6>=6.6"] dependencies = ["PyQt6>=6.6"]
[project.optional-dependencies]
dev = ["pytest>=8.0"]
[project.scripts] [project.scripts]
kadence-desktop = "kadence_desktop.__main__:main" kadence-desktop = "kadence_desktop.__main__:main"
@@ -17,3 +20,7 @@ build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["kadence_desktop"] packages = ["kadence_desktop"]
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
+53
View File
@@ -0,0 +1,53 @@
"""The mini timer's corner geometry.
A pure function, so it is worth pinning down: this is the sort of arithmetic
that silently puts a window half off the screen on somebody else's monitor.
"""
from __future__ import annotations
import pytest
from kadence_desktop.integrations.placement import CORNER_LABELS, CORNERS, Rect, point_for
SCREEN = Rect(0, 0, 1536, 960)
SIZE = (252, 152)
def test_each_corner_lands_where_its_name_says():
assert point_for("top-left", SCREEN, SIZE, 24) == (24, 24)
assert point_for("top-right", SCREEN, SIZE, 24) == (1260, 24)
assert point_for("bottom-left", SCREEN, SIZE, 24) == (24, 784)
assert point_for("bottom-right", SCREEN, SIZE, 24) == (1260, 784)
def test_edge_centres_and_centre_are_centred():
centre_x = (1536 - 252) // 2
assert point_for("top-centre", SCREEN, SIZE, 24) == (centre_x, 24)
assert point_for("bottom-centre", SCREEN, SIZE, 24) == (centre_x, 784)
assert point_for("centre", SCREEN, SIZE, 24) == (centre_x, (960 - 152) // 2)
def test_a_margin_of_zero_hugs_the_edge():
assert point_for("bottom-right", SCREEN, SIZE, 0) == (1536 - 252, 960 - 152)
def test_the_screen_offset_is_respected_on_a_second_monitor():
"""A monitor to the right of the primary one starts at a non-zero x."""
second = Rect(1536, 0, 1920, 1080)
assert point_for("top-right", second, SIZE, 24) == (1536 + 1920 - 252 - 24, 24)
assert point_for("bottom-left", second, SIZE, 24) == (1536 + 24, 1080 - 152 - 24)
@pytest.mark.parametrize("corner", [c for c in CORNERS if c != "remember"])
def test_the_window_never_leaves_the_screen(corner):
"""Even on a screen smaller than the window, or with a silly margin."""
tiny = Rect(0, 0, 200, 120)
x, y = point_for(corner, tiny, SIZE, 400)
assert x >= tiny.x and y >= tiny.y
assert x <= tiny.x + max(tiny.width - SIZE[0], 0)
assert y <= tiny.y + max(tiny.height - SIZE[1], 0)
def test_every_corner_has_a_label_for_the_settings_page():
assert set(CORNER_LABELS) == set(CORNERS)
+4 -2
View File
@@ -3,13 +3,15 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#6750A4" /> <meta name="theme-color" content="#FAF7F2" />
<title>Kadence</title> <title>Kadence</title>
<meta name="description" content="Pomodoro timer, task list and focus guard." />
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🍅</text></svg>" /> <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🍅</text></svg>" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<!-- Fraunces carries the numbers and the headings; Inter does the work. -->
<link <link
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&family=Roboto+Mono:wght@400;500&display=swap" href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600&family=Inter:wght@400;500;600&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
</head> </head>
-42
View File
@@ -1133,9 +1133,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1263,9 +1260,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1280,9 +1274,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1297,9 +1288,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1314,9 +1302,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1331,9 +1316,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1348,9 +1330,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1365,9 +1344,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1382,9 +1358,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1399,9 +1372,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1416,9 +1386,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1433,9 +1400,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1450,9 +1414,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1467,9 +1428,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
+2 -2
View File
@@ -23,8 +23,8 @@ function Shell() {
}, [settings?.appearance.theme, prefersDark]); }, [settings?.appearance.theme, prefersDark]);
const theme = useMemo( const theme = useMemo(
() => buildTheme(mode, settings?.appearance.accent || "#6750A4", settings?.appearance.compact ?? false), () => buildTheme(mode, settings?.appearance.compact ?? false),
[mode, settings?.appearance.accent, settings?.appearance.compact], [mode, settings?.appearance.compact],
); );
// Keep the browser chrome in step with the app's surface. // Keep the browser chrome in step with the app's surface.
+134 -113
View File
@@ -1,24 +1,18 @@
/** /**
* The frame: a navigation rail on a wide screen, a bottom bar on a phone. * The frame.
* *
* It also carries the two things that have to be visible from every page — what * A quiet left column on a wide screen — wordmark, navigation as a list of
* the timer is doing, and whether the blocker is on — because both are reasons * words, and the two things worth knowing from anywhere (what the timer is
* you might switch pages in the first place. * doing, whether the blocker is on) pinned to the bottom. On a phone it
* collapses to a top bar and a row of tabs.
*/ */
import { useEffect, type ReactNode } from "react"; import { useEffect, type ReactNode } from "react";
import { import {
AppBar, AppBar,
Avatar,
Badge,
Box, Box,
BottomNavigation, BottomNavigation,
BottomNavigationAction, BottomNavigationAction,
Chip, Divider,
Drawer,
List,
ListItemButton,
ListItemIcon,
ListItemText,
Stack, Stack,
Toolbar, Toolbar,
Tooltip, Tooltip,
@@ -26,26 +20,27 @@ import {
useMediaQuery, useMediaQuery,
useTheme, useTheme,
} from "@mui/material"; } from "@mui/material";
import AssignmentTurnedInIcon from "@mui/icons-material/AssignmentTurnedIn"; import AssignmentOutlinedIcon from "@mui/icons-material/AssignmentOutlined";
import BarChartIcon from "@mui/icons-material/BarChart"; import BarChartOutlinedIcon from "@mui/icons-material/BarChartOutlined";
import ShieldIcon from "@mui/icons-material/Shield"; import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import SettingsIcon from "@mui/icons-material/Settings"; import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
import TimerIcon from "@mui/icons-material/Timer"; import TimerOutlinedIcon from "@mui/icons-material/TimerOutlined";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import { clock } from "../lib/format"; import { clock } from "../lib/format";
import { useResource } from "../lib/hooks"; import { useResource } from "../lib/hooks";
import type { BlockerPolicy, TimerState } from "../lib/types"; import type { BlockerPolicy, TimerState } from "../lib/types";
import { kindColor, vizPalette } from "../lib/viz";
const NAV = [ const NAV = [
{ to: "/", label: "Timer", icon: <TimerIcon /> }, { to: "/", label: "Timer", icon: <TimerOutlinedIcon fontSize="small" /> },
{ to: "/tasks", label: "Tasks", icon: <AssignmentTurnedInIcon /> }, { to: "/tasks", label: "Tasks", icon: <AssignmentOutlinedIcon fontSize="small" /> },
{ to: "/blocker", label: "Blocker", icon: <ShieldIcon /> }, { to: "/blocker", label: "Blocker", icon: <ShieldOutlinedIcon fontSize="small" /> },
{ to: "/report", label: "Report", icon: <BarChartIcon /> }, { to: "/report", label: "Report", icon: <BarChartOutlinedIcon fontSize="small" /> },
{ to: "/settings", label: "Settings", icon: <SettingsIcon /> }, { to: "/settings", label: "Settings", icon: <SettingsOutlinedIcon fontSize="small" /> },
]; ];
const RAIL_WIDTH = 220; const RAIL_WIDTH = 216;
export default function AppShell({ export default function AppShell({
children, children,
@@ -59,6 +54,7 @@ export default function AppShell({
const wide = useMediaQuery(theme.breakpoints.up("md")); const wide = useMediaQuery(theme.breakpoints.up("md"));
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const palette = vizPalette(dark);
const timer = useResource<TimerState>("/api/timer/state", 5000); const timer = useResource<TimerState>("/api/timer/state", 5000);
const policy = useResource<BlockerPolicy>("/api/blocker/policy", 10000); const policy = useResource<BlockerPolicy>("/api/blocker/policy", 10000);
@@ -66,84 +62,152 @@ export default function AppShell({
const current = timer.data?.current ?? null; const current = timer.data?.current ?? null;
const remaining = current ? current.remaining_seconds : 0; const remaining = current ? current.remaining_seconds : 0;
// The tab title doubles as a timer you can read from another window.
useEffect(() => { useEffect(() => {
document.title = current document.title = current
? `${clock(remaining)} · ${current.kind === "work" ? "Focus" : "Break"} — Kadence` ? `${clock(remaining)} · ${current.kind === "work" ? "Focus" : "Break"} — Kadence`
: "Kadence"; : "Kadence";
}, [current, remaining]); }, [current, remaining]);
const activeIndex = Math.max( const activeIndex = Math.max(0, NAV.findIndex((item) => item.to === location.pathname));
0,
NAV.findIndex((item) => item.to === location.pathname), const status = (
<Stack spacing={0.75} sx={{ width: "100%" }}>
<Stack direction="row" alignItems="center" spacing={1}>
<Box
sx={{
width: 7,
height: 7,
borderRadius: "50%",
flexShrink: 0,
bgcolor: current ? kindColor(current.kind, palette) : "text.disabled",
animation: current?.status === "running" ? "pulse 2s ease-in-out infinite" : "none",
"@keyframes pulse": { "0%,100%": { opacity: 1 }, "50%": { opacity: 0.35 } },
}}
/>
<Typography
variant="body2"
sx={{ fontVariantNumeric: "tabular-nums", fontWeight: 500, letterSpacing: "0.01em" }}
>
{timer.data
? (timer.data.timer_enabled === false ? "View mode" : (current ? clock(remaining) : "Idle"))
: "—"}
</Typography>
<Box sx={{ flexGrow: 1 }} />
<Typography variant="caption" color="text.secondary">
{timer.data ? `${timer.data.completed_today}/${timer.data.goal}` : ""}
</Typography>
</Stack>
{policy.data?.active && (
<Tooltip title={policy.data.reason}>
<Typography variant="caption" sx={{ color: "error.main", letterSpacing: "0.04em" }}>
● BLOCKING
</Typography>
</Tooltip>
)}
</Stack>
); );
return ( return (
<Box sx={{ display: "flex", minHeight: "100vh", bgcolor: "background.default" }}> <Box sx={{ display: "flex", minHeight: "100vh", bgcolor: "background.default" }}>
{wide && ( {wide && (
<Drawer <Box
variant="permanent" component="nav"
sx={{ sx={{
width: RAIL_WIDTH, width: RAIL_WIDTH,
flexShrink: 0, flexShrink: 0,
"& .MuiDrawer-paper": { borderRight: 1,
width: RAIL_WIDTH, borderColor: "divider",
borderRight: "none", px: 2.5,
bgcolor: "background.default", py: 3,
px: 1.5, display: "flex",
py: 2, flexDirection: "column",
}, position: "sticky",
top: 0,
height: "100vh",
}}
>
<Box sx={{ mb: 4 }}>
<Typography
sx={{
fontFamily: theme.typography.h1.fontFamily,
fontSize: 23,
fontWeight: 500,
letterSpacing: "-0.02em",
lineHeight: 1,
}} }}
> >
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ px: 1.5, pb: 3 }}>
<Avatar sx={{ bgcolor: "primary.main", width: 36, height: 36 }}>🍅</Avatar>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 600, lineHeight: 1.1 }}>
Kadence Kadence
</Typography> </Typography>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
find your rhythm find your rhythm
</Typography> </Typography>
</Box> </Box>
</Stack>
<List sx={{ flexGrow: 1 }}> <Stack spacing={0.25} sx={{ flexGrow: 1 }}>
{NAV.map((item) => { {NAV.map((item) => {
const selected = location.pathname === item.to; const selected = location.pathname === item.to;
return ( return (
<ListItemButton <Box
key={item.to} key={item.to}
selected={selected}
onClick={() => navigate(item.to)} onClick={() => navigate(item.to)}
sx={{ sx={{
borderRadius: 999, display: "flex",
mb: 0.5, alignItems: "center",
"&.Mui-selected": { gap: 1.25,
bgcolor: "secondary.main", px: 1,
color: dark ? "common.black" : "common.white", py: 0.9,
"& .MuiListItemIcon-root": { color: "inherit" }, ml: -1,
"&:hover": { bgcolor: "secondary.main" }, borderRadius: 2,
}, cursor: "pointer",
color: selected ? "text.primary" : "text.secondary",
position: "relative",
"&:hover": { color: "text.primary", bgcolor: "action.hover" },
"&::before": selected
? {
content: '""',
position: "absolute",
left: -10,
top: 10,
bottom: 10,
width: 2,
bgcolor: "primary.main",
borderRadius: 2,
}
: undefined,
}} }}
> >
<ListItemIcon sx={{ minWidth: 40 }}>{item.icon}</ListItemIcon> {item.icon}
<ListItemText primary={item.label} primaryTypographyProps={{ fontWeight: 500 }} /> <Typography variant="body2" sx={{ fontWeight: selected ? 600 : 400 }}>
</ListItemButton> {item.label}
</Typography>
</Box>
); );
})} })}
</List> </Stack>
<StatusChips timer={timer.data} policy={policy.data} />
</Drawer> <Divider sx={{ mb: 1.5 }} />
{status}
</Box>
)} )}
<Box sx={{ flexGrow: 1, display: "flex", flexDirection: "column", minWidth: 0 }}> <Box sx={{ flexGrow: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
{!wide && ( {!wide && (
<AppBar position="sticky" sx={{ bgcolor: "background.default" }}> <AppBar
<Toolbar sx={{ gap: 1 }}> position="sticky"
<Avatar sx={{ bgcolor: "primary.main", width: 32, height: 32 }}>🍅</Avatar> sx={{ bgcolor: "background.default", borderBottom: 1, borderColor: "divider" }}
<Typography variant="h6" sx={{ flexGrow: 1, color: "text.primary" }}> >
{NAV[activeIndex]?.label ?? "Kadence"} <Toolbar sx={{ gap: 1, minHeight: 56 }}>
<Typography
sx={{
fontFamily: theme.typography.h1.fontFamily,
fontSize: 19,
color: "text.primary",
flexGrow: 1,
}}
>
Kadence
</Typography> </Typography>
<StatusChips timer={timer.data} policy={policy.data} compact /> <Box sx={{ width: 120 }}>{status}</Box>
</Toolbar> </Toolbar>
</AppBar> </AppBar>
)} )}
@@ -152,10 +216,10 @@ export default function AppShell({
component="main" component="main"
sx={{ sx={{
flexGrow: 1, flexGrow: 1,
px: { xs: 2, md: 4 }, px: { xs: 2, md: 5 },
py: { xs: 2, md: 3 }, py: { xs: 3, md: 4 },
pb: { xs: 12, md: 4 }, pb: { xs: 12, md: 6 },
maxWidth: 1280, maxWidth: 1180,
width: "100%", width: "100%",
mx: "auto", mx: "auto",
}} }}
@@ -176,7 +240,8 @@ export default function AppShell({
borderTop: 1, borderTop: 1,
borderColor: "divider", borderColor: "divider",
bgcolor: "background.paper", bgcolor: "background.paper",
zIndex: (zTheme) => zTheme.zIndex.appBar, zIndex: (z) => z.zIndex.appBar,
"& .MuiBottomNavigationAction-label": { fontSize: 11 },
}} }}
> >
{NAV.map((item) => ( {NAV.map((item) => (
@@ -188,47 +253,3 @@ export default function AppShell({
</Box> </Box>
); );
} }
function StatusChips({
timer,
policy,
compact = false,
}: {
timer: TimerState | null;
policy: BlockerPolicy | null;
compact?: boolean;
}) {
const current = timer?.current ?? null;
return (
<Stack direction={compact ? "row" : "column"} spacing={1} sx={{ px: compact ? 0 : 1 }}>
<Tooltip title={current ? `${current.kind} · ${current.status}` : "Timer idle"}>
<Chip
size="small"
color={current ? (current.kind === "work" ? "primary" : "success") : "default"}
variant={current ? "filled" : "outlined"}
label={current ? clock(current.remaining_seconds) : "idle"}
sx={{ fontVariantNumeric: "tabular-nums", fontWeight: 600 }}
/>
</Tooltip>
{!compact && timer && (
<Chip
size="small"
variant="outlined"
label={`${timer.completed_today}/${timer.goal} today`}
sx={{ justifyContent: "flex-start" }}
/>
)}
<Tooltip title={policy?.reason ?? "Blocker off"}>
<Badge color="error" variant="dot" invisible={!policy?.active}>
<Chip
size="small"
variant={policy?.active ? "filled" : "outlined"}
color={policy?.active ? "error" : "default"}
icon={<ShieldIcon fontSize="small" />}
label={policy?.active ? "blocking" : "open"}
/>
</Badge>
</Tooltip>
</Stack>
);
}
+158
View File
@@ -0,0 +1,158 @@
/**
* The page furniture: headers, rules, panels and statistics.
*
* Every page is built from these four, so spacing and hierarchy are decided
* once. The look is editorial — an eyebrow label, a serif title, a hairline,
* then content — rather than a grid of floating cards.
*/
import { Box, Divider, Stack, Typography, type SxProps, type Theme } from "@mui/material";
import type { ReactNode } from "react";
export function PageHeader({
eyebrow,
title,
actions,
caption,
}: {
eyebrow?: string;
title: string;
actions?: ReactNode;
caption?: ReactNode;
}) {
return (
<Box sx={{ mb: 3 }}>
<Stack
direction={{ xs: "column", sm: "row" }}
alignItems={{ xs: "flex-start", sm: "flex-end" }}
spacing={2}
sx={{ mb: 1.5 }}
>
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
{eyebrow && (
<Typography variant="overline" color="text.secondary" sx={{ display: "block" }}>
{eyebrow}
</Typography>
)}
<Typography variant="h4" sx={{ fontSize: { xs: 26, sm: 30 }, lineHeight: 1.15 }}>
{title}
</Typography>
{caption && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.75 }}>
{caption}
</Typography>
)}
</Box>
{actions && (
<Stack direction="row" spacing={1} sx={{ flexShrink: 0, flexWrap: "wrap", rowGap: 1 }}>
{actions}
</Stack>
)}
</Stack>
<Divider />
</Box>
);
}
export function SectionTitle({
children,
action,
sx,
}: {
children: ReactNode;
action?: ReactNode;
sx?: SxProps<Theme>;
}) {
return (
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1.5, ...sx }}>
<Typography variant="overline" color="text.secondary">
{children}
</Typography>
<Box sx={{ flexGrow: 1, height: "1px", bgcolor: "divider" }} />
{action}
</Stack>
);
}
export function Panel({
children,
sx,
padded = true,
}: {
children: ReactNode;
sx?: SxProps<Theme>;
padded?: boolean;
}) {
return (
<Box
sx={{
border: 1,
borderColor: "divider",
borderRadius: "14px",
bgcolor: "background.paper",
p: padded ? { xs: 2, sm: 2.5 } : 0,
...sx,
}}
>
{children}
</Box>
);
}
/**
* A number and its name. The figure is set in the display serif, which is what
* makes a page of statistics read like a page rather than a readout.
*/
export function Stat({
label,
value,
caption,
accent,
size = "md",
}: {
label: string;
value: ReactNode;
caption?: ReactNode;
accent?: string;
size?: "sm" | "md" | "lg";
}) {
const fontSize = { sm: 24, md: 32, lg: 44 }[size];
return (
<Box sx={{ minWidth: 0 }}>
<Typography variant="overline" color="text.secondary" sx={{ display: "block" }}>
{label}
</Typography>
<Typography
sx={{
fontFamily: (theme) => theme.typography.h4.fontFamily,
fontSize,
fontWeight: 500,
lineHeight: 1.1,
letterSpacing: "-0.02em",
color: accent ?? "text.primary",
}}
>
{value}
</Typography>
{caption && (
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 0.25 }}>
{caption}
</Typography>
)}
</Box>
);
}
/** A row of statistics separated by hairlines, not boxes. */
export function StatRow({ children }: { children: ReactNode }) {
return (
<Panel padded={false}>
<Stack
direction={{ xs: "column", sm: "row" }}
divider={<Divider flexItem orientation="vertical" sx={{ display: { xs: "none", sm: "block" } }} />}
sx={{ "& > *": { flex: 1, p: { xs: 2, sm: 2.5 } } }}
>
{children}
</Stack>
</Panel>
);
}
-43
View File
@@ -1,43 +0,0 @@
/**
* A number with its label. Not a chart: one value, read in a glance.
*/
import { Card, CardContent, Stack, Typography } from "@mui/material";
import type { ReactNode } from "react";
export default function StatTile({
label,
value,
caption,
icon,
accent,
}: {
label: string;
value: ReactNode;
caption?: ReactNode;
icon?: ReactNode;
accent?: string;
}) {
return (
<Card sx={{ height: "100%" }}>
<CardContent sx={{ py: 2 }}>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 0.5 }}>
{icon}
<Typography variant="overline" color="text.secondary" noWrap>
{label}
</Typography>
</Stack>
<Typography
variant="h4"
sx={{ fontVariantNumeric: "tabular-nums", color: accent ?? "text.primary" }}
>
{value}
</Typography>
{caption && (
<Typography variant="body2" color="text.secondary">
{caption}
</Typography>
)}
</CardContent>
</Card>
);
}
+69 -39
View File
@@ -1,16 +1,16 @@
/** /**
* The countdown ring. * The dial.
* *
* A hero number with a progress arc around it: the number is what you read, the * A thin ring with one tick per minute of the interval: the ticks go quiet as
* arc is the glanceable "how much is left". The track stays recessive and the * the minutes are spent, so the dial reads as "how much is left" at a glance
* arc carries the state colour, so the dial says work/break without a label. * and as "how long was this?" on a second look. The time itself is the hero —
* set in the display serif, tabular, so it never jitters.
*/ */
import { Box, Stack, Typography, useTheme } from "@mui/material"; import { Box, Stack, Typography, useTheme } from "@mui/material";
import { clock } from "../lib/format"; import { clock } from "../lib/format";
const SIZE = 280; const SIZE = 300;
const STROKE = 14;
export default function TimerDial({ export default function TimerDial({
remaining, remaining,
@@ -19,6 +19,7 @@ export default function TimerDial({
sublabel, sublabel,
color, color,
paused, paused,
idle,
}: { }: {
remaining: number; remaining: number;
total: number; total: number;
@@ -26,23 +27,20 @@ export default function TimerDial({
sublabel?: string; sublabel?: string;
color: string; color: string;
paused?: boolean; paused?: boolean;
idle?: boolean;
}) { }) {
const theme = useTheme(); const theme = useTheme();
const radius = (SIZE - STROKE) / 2; const radius = SIZE / 2 - 26;
const circumference = 2 * Math.PI * radius; const circumference = 2 * Math.PI * radius;
const progress = total > 0 ? Math.min(1, Math.max(0, 1 - remaining / total)) : 0; const elapsed = total > 0 ? Math.min(1, Math.max(0, 1 - remaining / total)) : 0;
// One tick per minute, capped so a 90-minute interval does not become a comb.
const minutes = Math.max(1, Math.round(total / 60));
const tickCount = minutes <= 60 ? minutes : Math.round(minutes / 5);
const spentTicks = Math.floor(tickCount * elapsed);
return ( return (
<Box <Box sx={{ position: "relative", width: "100%", maxWidth: SIZE, aspectRatio: "1 / 1", mx: "auto" }}>
sx={{
position: "relative",
width: SIZE,
height: SIZE,
maxWidth: "100%",
aspectRatio: "1 / 1",
mx: "auto",
}}
>
<svg <svg
viewBox={`0 0 ${SIZE} ${SIZE}`} viewBox={`0 0 ${SIZE} ${SIZE}`}
width="100%" width="100%"
@@ -50,57 +48,89 @@ export default function TimerDial({
role="img" role="img"
aria-label={`${label}: ${clock(remaining)} remaining`} aria-label={`${label}: ${clock(remaining)} remaining`}
> >
<g transform={`rotate(-90 ${SIZE / 2} ${SIZE / 2})`}>
{Array.from({ length: tickCount }, (_unused, index) => {
const angle = (index / tickCount) * 2 * Math.PI;
const major = index % 5 === 0;
const outer = radius - 7;
const inner = outer - (major ? 9 : 5);
const spent = index < spentTicks;
return (
<line
key={index}
x1={SIZE / 2 + inner * Math.cos(angle)}
y1={SIZE / 2 + inner * Math.sin(angle)}
x2={SIZE / 2 + outer * Math.cos(angle)}
y2={SIZE / 2 + outer * Math.sin(angle)}
stroke={spent && !idle ? color : theme.palette.divider}
strokeWidth={major ? 1.4 : 1}
strokeLinecap="round"
opacity={idle ? 0.5 : spent ? 0.9 : 1}
/>
);
})}
</g>
<circle <circle
cx={SIZE / 2} cx={SIZE / 2}
cy={SIZE / 2} cy={SIZE / 2}
r={radius} r={radius}
fill="none" fill="none"
stroke={theme.palette.action.hover} stroke={theme.palette.divider}
strokeWidth={STROKE} strokeWidth={2}
/> />
{!idle && (
<circle <circle
cx={SIZE / 2} cx={SIZE / 2}
cy={SIZE / 2} cy={SIZE / 2}
r={radius} r={radius}
fill="none" fill="none"
stroke={color} stroke={color}
strokeWidth={STROKE} strokeWidth={2.5}
strokeLinecap="round" strokeLinecap="round"
strokeDasharray={circumference} strokeDasharray={circumference}
strokeDashoffset={circumference * (1 - progress)} strokeDashoffset={circumference * elapsed}
transform={`rotate(-90 ${SIZE / 2} ${SIZE / 2})`} transform={`rotate(-90 ${SIZE / 2} ${SIZE / 2})`}
opacity={paused ? 0.45 : 1} opacity={paused ? 0.4 : 1}
style={{ transition: "stroke-dashoffset 1s linear, opacity .2s" }} style={{ transition: "stroke-dashoffset 1s linear, opacity .25s" }}
/> />
)}
</svg> </svg>
<Stack <Stack
spacing={0.5} spacing={0.75}
sx={{ sx={{ position: "absolute", inset: 0, alignItems: "center", justifyContent: "center", px: 5 }}
position: "absolute",
inset: 0,
alignItems: "center",
justifyContent: "center",
textAlign: "center",
px: 4,
}}
> >
<Typography variant="overline" color="text.secondary"> <Typography variant="overline" sx={{ color: idle ? "text.secondary" : color }}>
{label} {label}
</Typography> </Typography>
<Typography <Typography
sx={{ sx={{
fontSize: { xs: 48, sm: 56 }, fontFamily: (muiTheme) => muiTheme.typography.h1.fontFamily,
fontWeight: 300, fontSize: { xs: 52, sm: 62 },
fontWeight: 400,
lineHeight: 1, lineHeight: 1,
letterSpacing: "-0.03em",
fontVariantNumeric: "tabular-nums", fontVariantNumeric: "tabular-nums",
fontFamily: '"Roboto Mono", ui-monospace, monospace', opacity: paused ? 0.55 : 1,
opacity: paused ? 0.6 : 1, transition: "opacity .25s",
}} }}
> >
{clock(remaining)} {clock(remaining)}
</Typography> </Typography>
{sublabel && ( {sublabel && (
<Typography variant="body2" color="text.secondary" noWrap sx={{ maxWidth: "100%" }}> <Typography
variant="body2"
color="text.secondary"
align="center"
sx={{
maxWidth: "100%",
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}
>
{sublabel} {sublabel}
</Typography> </Typography>
)} )}
+14 -27
View File
@@ -1,7 +1,9 @@
/** Shared chrome for the charts: title, optional legend, and a caption slot. */ /** Shared chrome for a chart: an eyebrow, a hairline, the plot, a caption. */
import { Box, Card, CardContent, Stack, Typography } from "@mui/material"; import { Box, Stack, Typography } from "@mui/material";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Panel, SectionTitle } from "../Layout";
export interface LegendItem { export interface LegendItem {
label: string; label: string;
color: string; color: string;
@@ -21,32 +23,13 @@ export default function ChartFrame({
action?: ReactNode; action?: ReactNode;
}) { }) {
return ( return (
<Card sx={{ height: "100%" }}> <Panel sx={{ height: "100%" }}>
<CardContent> <SectionTitle action={action}>{title}</SectionTitle>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 0.5 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
{title}
</Typography>
{action}
</Stack>
{caption && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
{caption}
</Typography>
)}
{legend && legend.length > 1 && ( {legend && legend.length > 1 && (
<Stack direction="row" spacing={2} sx={{ mb: 1.5, flexWrap: "wrap", rowGap: 1 }}> <Stack direction="row" spacing={2} sx={{ mb: 1.5, flexWrap: "wrap", rowGap: 0.75 }}>
{legend.map((item) => ( {legend.map((item) => (
<Stack key={item.label} direction="row" spacing={0.75} alignItems="center"> <Stack key={item.label} direction="row" spacing={0.75} alignItems="center">
<Box <Box sx={{ width: 8, height: 8, borderRadius: "2px", bgcolor: item.color }} />
sx={{
width: 10,
height: 10,
borderRadius: "3px",
bgcolor: item.color,
flexShrink: 0,
}}
/>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
{item.label} {item.label}
</Typography> </Typography>
@@ -55,7 +38,11 @@ export default function ChartFrame({
</Stack> </Stack>
)} )}
<Box>{children}</Box> <Box>{children}</Box>
</CardContent> {caption && (
</Card> <Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 1.5 }}>
{caption}
</Typography>
)}
</Panel>
); );
} }
+2 -3
View File
@@ -4,7 +4,7 @@
* The goal line is the only reference mark; days that cleared it are the darker * 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. * ramp step, which keeps "did I hit the goal" readable without a second colour.
*/ */
import { Box, Tooltip, Typography, useTheme } from "@mui/material"; import { Box, Tooltip, Typography } from "@mui/material";
import { shortDate } from "../../lib/format"; import { shortDate } from "../../lib/format";
import type { VizPalette } from "../../lib/viz"; import type { VizPalette } from "../../lib/viz";
@@ -30,7 +30,6 @@ export default function FocusBars({
palette: VizPalette; palette: VizPalette;
title?: string; title?: string;
}) { }) {
const theme = useTheme();
const max = Math.max(goal, ...days.map((day) => day.pomodoros), 1); const max = Math.max(goal, ...days.map((day) => day.pomodoros), 1);
const goalY = HEIGHT - (goal / max) * HEIGHT; const goalY = HEIGHT - (goal / max) * HEIGHT;
const slotWidth = 100 / Math.max(days.length, 1); const slotWidth = 100 / Math.max(days.length, 1);
@@ -84,7 +83,7 @@ export default function FocusBars({
borderRadius: "4px 4px 0 0", borderRadius: "4px 4px 0 0",
bgcolor: bgcolor:
day.pomodoros === 0 day.pomodoros === 0
? theme.palette.action.disabledBackground ? palette.empty
: day.goal_met : day.goal_met
? palette.sequential[4] ? palette.sequential[4]
: palette.sequential[2], : palette.sequential[2],
+2 -3
View File
@@ -4,7 +4,7 @@
* Sequential encoding, so one hue light→dark and an empty day is the surface * 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. * tint rather than a colour — "nothing happened" should not look like a value.
*/ */
import { Box, Stack, Tooltip, Typography, useTheme } from "@mui/material"; import { Box, Stack, Tooltip, Typography } from "@mui/material";
import { weekdayOf } from "../../lib/format"; import { weekdayOf } from "../../lib/format";
import { rampStep, type VizPalette } from "../../lib/viz"; import { rampStep, type VizPalette } from "../../lib/viz";
@@ -23,7 +23,6 @@ export default function Heatmap({
goal: number; goal: number;
palette: VizPalette; palette: VizPalette;
}) { }) {
const theme = useTheme();
if (!days.length) return null; if (!days.length) return null;
// Pad the first week so every column is a real Monday→Sunday week. // Pad the first week so every column is a real Monday→Sunday week.
@@ -67,7 +66,7 @@ export default function Heatmap({
width: CELL, width: CELL,
height: CELL, height: CELL,
borderRadius: "3px", borderRadius: "3px",
bgcolor: fill ?? theme.palette.action.hover, bgcolor: fill ?? palette.empty,
outline: fill ? "none" : `1px solid ${palette.grid}`, outline: fill ? "none" : `1px solid ${palette.grid}`,
outlineOffset: "-1px", outlineOffset: "-1px",
}} }}
@@ -3,7 +3,7 @@
* hour is the only bar that gets a direct label, which is the number you came * hour is the only bar that gets a direct label, which is the number you came
* for. * for.
*/ */
import { Box, Tooltip, Typography, useTheme } from "@mui/material"; import { Box, Tooltip, Typography } from "@mui/material";
import type { VizPalette } from "../../lib/viz"; import type { VizPalette } from "../../lib/viz";
import ChartFrame from "./ChartFrame"; import ChartFrame from "./ChartFrame";
@@ -19,7 +19,6 @@ export default function HourHistogram({
palette: VizPalette; palette: VizPalette;
peak: number | null; peak: number | null;
}) { }) {
const theme = useTheme();
const max = Math.max(1, ...byHour.map((entry) => entry.count)); const max = Math.max(1, ...byHour.map((entry) => entry.count));
return ( return (
@@ -37,7 +36,7 @@ export default function HourHistogram({
borderRadius: "4px 4px 0 0", borderRadius: "4px 4px 0 0",
bgcolor: bgcolor:
entry.count === 0 entry.count === 0
? theme.palette.action.disabledBackground ? palette.empty
: entry.hour === peak : entry.hour === peak
? palette.sequential[4] ? palette.sequential[4]
: palette.sequential[2], : palette.sequential[2],
@@ -4,7 +4,7 @@
* Three categories (focus / short / long break) so a legend is mandatory, and * Three categories (focus / short / long break) so a legend is mandatory, and
* each block is also labelled on hover — colour never carries identity alone. * each block is also labelled on hover — colour never carries identity alone.
*/ */
import { Box, Stack, Tooltip, Typography, useTheme } from "@mui/material"; import { Box, Stack, Tooltip, Typography } from "@mui/material";
import { KIND_LABEL } from "../../lib/format"; import { KIND_LABEL } from "../../lib/format";
import { kindColor, type VizPalette } from "../../lib/viz"; import { kindColor, type VizPalette } from "../../lib/viz";
@@ -28,12 +28,11 @@ export default function SessionTimeline({
sessions: Session[]; sessions: Session[];
palette: VizPalette; palette: VizPalette;
}) { }) {
const theme = useTheme();
const done = sessions.filter((item) => item.minutes > 0); const done = sessions.filter((item) => item.minutes > 0);
if (!done.length) { if (!done.length) {
return ( return (
<ChartFrame title="Today's timeline" caption="Nothing recorded yet — start a pomodoro."> <ChartFrame title="Today's timeline" caption="Nothing recorded yet — start a pomodoro.">
<Box sx={{ height: LANE, borderRadius: 1, bgcolor: theme.palette.action.hover }} /> <Box sx={{ height: LANE, borderRadius: 1, border: "1px dashed", borderColor: "divider" }} />
</ChartFrame> </ChartFrame>
); );
} }
@@ -58,7 +57,7 @@ export default function SessionTimeline({
{ label: KIND_LABEL.long_break, color: palette.longBreak }, { label: KIND_LABEL.long_break, color: palette.longBreak },
]} ]}
> >
<Box sx={{ position: "relative", height: LANE, borderRadius: 1, bgcolor: theme.palette.action.hover }}> <Box sx={{ position: "relative", height: LANE, borderRadius: 1, bgcolor: palette.empty }}>
{done.map((item) => { {done.map((item) => {
const start = (hourOf(item.started_at) - first) / span; const start = (hourOf(item.started_at) - first) / span;
const width = item.minutes / 60 / span; const width = item.minutes / 60 / span;
+1
View File
@@ -27,6 +27,7 @@ export const KIND_LABEL: Record<string, string> = {
export const BUCKET_LABEL: Record<string, string> = { export const BUCKET_LABEL: Record<string, string> = {
overdue: "Overdue", overdue: "Overdue",
today: "Due today", today: "Due today",
active: "Would be nice to start",
upcoming: "Coming up", upcoming: "Coming up",
someday: "No deadline", someday: "No deadline",
}; };
+18 -5
View File
@@ -29,6 +29,7 @@ export interface TimerState {
finished?: TimerSession | null; finished?: TimerSession | null;
auto_started?: TimerSession | null; auto_started?: TimerSession | null;
celebration?: string | null; celebration?: string | null;
timer_enabled?: boolean;
} }
export interface Task { export interface Task {
@@ -50,7 +51,7 @@ export interface Task {
stale: boolean; stale: boolean;
estimated_pomodoros: number | null; estimated_pomodoros: number | null;
days_overdue: number; days_overdue: number;
bucket: "overdue" | "today" | "upcoming" | "someday"; bucket: "overdue" | "today" | "active" | "upcoming" | "someday";
} }
export interface TaskListResponse { export interface TaskListResponse {
@@ -64,11 +65,21 @@ export interface RefreshResult {
refreshed_at: string; refreshed_at: string;
date: string; date: string;
total: number; total: number;
sources: { source: string; label: string; ok: boolean; error: string | null; count: number }[]; closed: number;
sources: {
source: string;
label: string;
ok: boolean;
error: string | null;
count: number;
/** How many tasks this refresh found closed in that tool. */
closed?: number;
}[];
} }
export interface BlockerPolicy { export interface BlockerPolicy {
active: boolean; active: boolean;
unexpandable?: string[];
reason: string; reason: string;
mode: string; mode: string;
enabled: boolean; enabled: boolean;
@@ -165,12 +176,13 @@ export interface AppSettings {
alarm_sound: boolean; alarm_sound: boolean;
alarm_volume: number; alarm_volume: number;
desktop_notifications: boolean; desktop_notifications: boolean;
timer_enabled: boolean;
keep_on_top_during_work: boolean; keep_on_top_during_work: boolean;
}; };
sources: { sources: {
jira: Record<string, unknown> & { 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 }; jira: Record<string, unknown> & { enabled: boolean; base_url: string; email: string; api_token: string; extra_jql: string; start_date_field: string; include_all_open: boolean; max_issues: number };
google_tasks: { enabled: boolean; client_id: string; client_secret: string; refresh_token: string; lists: string[]; include_undated: boolean; writeback_done: boolean }; google_tasks: { enabled: boolean; client_id: string; client_secret: string; refresh_token: string; lists: string[]; include_undated: boolean };
todoist: { enabled: boolean; api_token: string; projects: string[]; filter_query: string; writeback_done: boolean }; todoist: { enabled: boolean; api_token: string; projects: string[]; filter_query: string };
laundry: { enabled: boolean; base_url: string; session_cookie: string }; laundry: { enabled: boolean; base_url: string; session_cookie: string };
refresh_interval_minutes: number; refresh_interval_minutes: number;
request_timeout_seconds: number; request_timeout_seconds: number;
@@ -181,6 +193,7 @@ export interface AppSettings {
block_during_breaks: boolean; block_during_breaks: boolean;
blocked_domains: string[]; blocked_domains: string[];
allowed_domains: string[]; allowed_domains: string[];
subdomain_expansions: string[];
blocked_apps: string[]; blocked_apps: string[];
kill_blocked_apps: boolean; kill_blocked_apps: boolean;
schedule: { label: string; start: string; end: string; days: number[] }[]; schedule: { label: string; start: string; end: string; days: number[] }[];
+23 -18
View File
@@ -1,10 +1,12 @@
/** /**
* Chart colours. * Chart colours.
* *
* These are the validated data-viz slots, not the Material theme's palette: the * These are the validated data-viz slots, not the brand accent: the UI is warm
* UI's accent can be anything, but a chart has to stay readable for colour-blind * ember on paper, but a chart has to stay readable for colour-blind readers in
* readers in both themes. Work/short/long are categorical slots 1–3 (validated * both themes, so identity colours come from the reference palette. Focus takes
* all-pairs in both modes); magnitude charts use the single-hue blue ramp. * the orange slot (nearest the brand's warmth), breaks take aqua, and anything
* that encodes *magnitude* uses the single-hue blue ramp — this ordering was run
* through the palette validator for all pairs in both modes.
*/ */
export interface VizPalette { export interface VizPalette {
@@ -14,28 +16,28 @@ export interface VizPalette {
sequential: string[]; sequential: string[];
grid: string; grid: string;
axis: string; axis: string;
surface: string; empty: string;
} }
const LIGHT: VizPalette = { const LIGHT: VizPalette = {
work: "#2a78d6", work: "#eb6834",
shortBreak: "#eb6834", shortBreak: "#2a78d6",
longBreak: "#1baf7a", longBreak: "#1baf7a",
// blue 100 → 600, light → dark: "more pomodoros" reads as "darker". // blue 100 → 600: "more pomodoros" reads as "darker".
sequential: ["#cde2fb", "#9ec5f4", "#6da7ec", "#3987e5", "#256abf", "#184f95"], sequential: ["#cde2fb", "#9ec5f4", "#6da7ec", "#3987e5", "#256abf", "#184f95"],
grid: "rgba(0,0,0,0.08)", grid: "rgba(23, 21, 15, 0.12)",
axis: "rgba(0,0,0,0.45)", axis: "rgba(23, 21, 15, 0.45)",
surface: "#fcfcfb", empty: "rgba(23, 21, 15, 0.07)",
}; };
const DARK: VizPalette = { const DARK: VizPalette = {
work: "#3987e5", work: "#d95926",
shortBreak: "#d95926", shortBreak: "#3987e5",
longBreak: "#199e70", longBreak: "#199e70",
sequential: ["#104281", "#184f95", "#256abf", "#3987e5", "#6da7ec", "#9ec5f4"], sequential: ["#104281", "#184f95", "#256abf", "#3987e5", "#6da7ec", "#9ec5f4"],
grid: "rgba(255,255,255,0.10)", grid: "rgba(244, 241, 234, 0.14)",
axis: "rgba(255,255,255,0.55)", axis: "rgba(244, 241, 234, 0.55)",
surface: "#1a1a19", empty: "rgba(244, 241, 234, 0.08)",
}; };
export function vizPalette(dark: boolean): VizPalette { export function vizPalette(dark: boolean): VizPalette {
@@ -48,10 +50,13 @@ export function kindColor(kind: string, palette: VizPalette): string {
return palette.shortBreak; return palette.shortBreak;
} }
/** Pick a ramp step for a count, 0 meaning "empty". */ /** Pick a ramp step for a count; null means "nothing happened". */
export function rampStep(count: number, goal: number, palette: VizPalette): string | null { export function rampStep(count: number, goal: number, palette: VizPalette): string | null {
if (count <= 0) return null; if (count <= 0) return null;
const ratio = goal > 0 ? count / goal : count / 8; 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)))); const index = Math.min(
palette.sequential.length - 1,
Math.max(0, Math.round(ratio * (palette.sequential.length - 1))),
);
return palette.sequential[index]; return palette.sequential[index];
} }
+129 -144
View File
@@ -1,17 +1,15 @@
/** /**
* The blocker's rules and its live state. * The focus guard: what is blocked, when, and what it caught.
* *
* The rules are server-side so every device agrees; the *enforcement* happens in * Lists accept masks. A hosts file has no wildcards of its own, so `*.x.com` is
* the native desktop app, which polls the same policy this page edits. The web * expanded server-side into real hostnames — this page shows that expansion, and
* UI is honest about that: it shows whether anything is currently enforcing. * says plainly when a pattern (`*gram*`) cannot be expressed that way.
*/ */
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { import {
Alert, Alert,
Box, Box,
Button, Button,
Card,
CardContent,
Chip, Chip,
Divider, Divider,
FormControlLabel, FormControlLabel,
@@ -22,21 +20,27 @@ import {
TextField, TextField,
Typography, Typography,
} from "@mui/material"; } from "@mui/material";
import ShieldIcon from "@mui/icons-material/Shield";
import StatTile from "../components/StatTile"; import { PageHeader, Panel, SectionTitle, Stat, StatRow } from "../components/Layout";
import { useResource } from "../lib/hooks"; import { useResource } from "../lib/hooks";
import { useSettings } from "../lib/settings"; import { useSettings } from "../lib/settings";
import type { BlockEvents, BlockerPolicy } from "../lib/types"; import type { BlockEvents, BlockerPolicy } from "../lib/types";
const MASK_HELP = [
["youtube.com", "the site itself, plus www. and m."],
["*.google.com", "every subdomain in the expansion list below"],
["*gram*", "matches apps; a hosts file cannot express it"],
];
export default function BlockerPage({ onToast }: { onToast: (message: string) => void }) { export default function BlockerPage({ onToast }: { onToast: (message: string) => void }) {
const { settings, defaults, save } = useSettings(); const { settings, defaults, save } = useSettings();
const policy = useResource<BlockerPolicy>("/api/blocker/policy", 5000); const policy = useResource<BlockerPolicy & { unexpandable?: string[] }>("/api/blocker/policy", 5000);
const events = useResource<BlockEvents>("/api/blocker/events", 20000); const events = useResource<BlockEvents>("/api/blocker/events", 20000);
const [domains, setDomains] = useState(""); const [domains, setDomains] = useState("");
const [allowed, setAllowed] = useState(""); const [allowed, setAllowed] = useState("");
const [apps, setApps] = useState(""); const [apps, setApps] = useState("");
const [subdomains, setSubdomains] = useState("");
const [dirty, setDirty] = useState(false); const [dirty, setDirty] = useState(false);
useEffect(() => { useEffect(() => {
@@ -44,57 +48,45 @@ export default function BlockerPage({ onToast }: { onToast: (message: string) =>
setDomains(settings.blocker.blocked_domains.join("\n")); setDomains(settings.blocker.blocked_domains.join("\n"));
setAllowed(settings.blocker.allowed_domains.join("\n")); setAllowed(settings.blocker.allowed_domains.join("\n"));
setApps(settings.blocker.blocked_apps.join("\n")); setApps(settings.blocker.blocked_apps.join("\n"));
setSubdomains((settings.blocker.subdomain_expansions ?? []).join(", "));
}, [settings, dirty]); }, [settings, dirty]);
const lines = (value: string) => const lines = (value: string) => value.split("\n").map((item) => item.trim()).filter(Boolean);
value
.split("\n")
.map((item) => item.trim())
.filter(Boolean);
const patch = async (change: Record<string, unknown>) => { const patch = async (change: Record<string, unknown>, message = "Saved") => {
try { try {
await save({ blocker: change }); await save({ blocker: change });
await policy.reload(); await policy.reload();
onToast("Blocker updated"); onToast(message);
} catch (exc) { } catch (exc) {
onToast(exc instanceof Error ? exc.message : String(exc)); onToast(exc instanceof Error ? exc.message : String(exc));
} }
}; };
const saveLists = async () => { const saveLists = async () => {
await patch({ await patch(
{
blocked_domains: lines(domains), blocked_domains: lines(domains),
allowed_domains: lines(allowed), allowed_domains: lines(allowed),
blocked_apps: lines(apps), blocked_apps: lines(apps),
}); subdomain_expansions: subdomains.split(",").map((item) => item.trim()).filter(Boolean),
},
"Lists saved",
);
setDirty(false); 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; const blocker = settings?.blocker;
const unexpandable = policy.data?.unexpandable ?? [];
const top = useMemo(() => events.data?.by_target.slice(0, 8) ?? [], [events.data]);
return ( return (
<Stack spacing={3}> <>
<Card> <PageHeader
<CardContent> eyebrow={policy.data?.reason ?? "—"}
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 2 }}> title={policy.data?.active ? "Blocking is on" : "Blocking is off"}
<ShieldIcon color={policy.data?.active ? "error" : "disabled"} fontSize="large" /> caption="Rules live here; the desktop app enforces them on your machine."
<Box sx={{ flexGrow: 1 }}> actions={
<Typography variant="h6">
{policy.data?.active ? "Blocking is ON" : "Blocking is off"}
</Typography>
<Typography variant="body2" color="text.secondary">
{policy.data?.reason}
</Typography>
</Box>
<FormControlLabel <FormControlLabel
control={ control={
<Switch <Switch
@@ -104,49 +96,28 @@ export default function BlockerPage({ onToast }: { onToast: (message: string) =>
} }
label="Enabled" label="Enabled"
/> />
</Stack>
<Grid container spacing={2}>
<Grid item xs={6} md={3}>
<StatTile label="Domains" value={policy.data?.all_domains.length ?? 0} caption="redirected" />
</Grid>
<Grid item xs={6} md={3}>
<StatTile label="Apps" value={policy.data?.all_apps.length ?? 0} caption="closed on sight" />
</Grid>
<Grid item xs={6} md={3}>
<StatTile label="Attempts today" value={events.data?.total ?? 0} caption="turned away" />
</Grid>
<Grid item xs={6} md={3}>
<StatTile
label="Mode"
value={
{ during_work: "Focus", always: "Always", scheduled: "Schedule" }[
blocker?.mode ?? "during_work"
]
} }
caption="when it applies"
/> />
</Grid>
</Grid>
<Alert severity="info" sx={{ mt: 2 }}> <StatRow>
Enforcement happens in the desktop app — it rewrites <code>/etc/hosts</code> through a <Stat label="Hostnames" value={policy.data?.all_domains.length ?? 0} caption="after expansion" />
privileged helper and closes blocked applications. This page decides <em>what</em> and{" "} <Stat label="Apps" value={policy.data?.all_apps.length ?? 0} caption="closed on sight" />
<em>when</em>. <Stat label="Attempts today" value={events.data?.total ?? 0} caption="turned away" />
</Alert> <Stat
</CardContent> label="Mode"
</Card> value={{ during_work: "Focus", always: "Always", scheduled: "Schedule" }[blocker?.mode ?? "during_work"]}
caption="when it applies"
size="sm"
/>
</StatRow>
<Card> <Box sx={{ mt: 4 }}>
<CardContent> <SectionTitle>When</SectionTitle>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}> <Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems={{ sm: "center" }}>
When to block
</Typography>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="center">
<TextField <TextField
select select
label="Mode" label="Mode"
sx={{ minWidth: 220 }} sx={{ minWidth: 240 }}
value={blocker?.mode ?? "during_work"} value={blocker?.mode ?? "during_work"}
onChange={(event) => void patch({ mode: event.target.value })} onChange={(event) => void patch({ mode: event.target.value })}
> >
@@ -161,7 +132,7 @@ export default function BlockerPage({ onToast }: { onToast: (message: string) =>
onChange={(event) => void patch({ block_during_breaks: event.target.checked })} onChange={(event) => void patch({ block_during_breaks: event.target.checked })}
/> />
} }
label="Keep blocking during breaks" label="Keep blocking through breaks"
/> />
<FormControlLabel <FormControlLabel
control={ control={
@@ -170,131 +141,145 @@ export default function BlockerPage({ onToast }: { onToast: (message: string) =>
onChange={(event) => void patch({ kill_blocked_apps: event.target.checked })} onChange={(event) => void patch({ kill_blocked_apps: event.target.checked })}
/> />
} }
label="Close blocked apps (otherwise just warn)" label="Close blocked apps (otherwise only warn)"
/> />
</Stack> </Stack>
</CardContent> </Box>
</Card>
<Box sx={{ mt: 4 }}>
<SectionTitle>Masks</SectionTitle>
<Panel sx={{ mb: 3 }}>
<Stack divider={<Divider />}>
{MASK_HELP.map(([pattern, meaning]) => (
<Stack key={pattern} direction={{ xs: "column", sm: "row" }} spacing={1} sx={{ py: 1 }}>
<Typography variant="body2" sx={{ fontFamily: "monospace", minWidth: 150 }}>
{pattern}
</Typography>
<Typography variant="body2" color="text.secondary">
{meaning}
</Typography>
</Stack>
))}
</Stack>
</Panel>
{unexpandable.length > 0 && (
<Alert severity="info" variant="outlined" sx={{ mb: 3 }}>
{unexpandable.join(", ")} {unexpandable.length === 1 ? "is a pattern" : "are patterns"} a hosts file
cannot express — {unexpandable.length === 1 ? "it is" : "they are"} still matched against application
names.
</Alert>
)}
<Grid container spacing={3}> <Grid container spacing={3}>
<Grid item xs={12} md={4}> <Grid item xs={12} md={4}>
<Card sx={{ height: "100%" }}> <SectionTitle>Blocked websites</SectionTitle>
<CardContent>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
Blocked websites
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
One hostname per line. <code>www.</code> and <code>m.</code> are added for you.
</Typography>
<TextField <TextField
multiline multiline
fullWidth fullWidth
minRows={8} minRows={9}
value={domains} value={domains}
placeholder={"youtube.com\nreddit.com\ntwitter.com"} placeholder={"youtube.com\n*.reddit.com\ntwitter.com"}
onChange={(event) => { onChange={(event) => {
setDomains(event.target.value); setDomains(event.target.value);
setDirty(true); setDirty(true);
}} }}
sx={{ "& textarea": { fontFamily: "monospace", fontSize: 13 } }} sx={{ "& textarea": { fontFamily: "monospace", fontSize: 13, lineHeight: 1.7 } }}
/> />
<Button size="small" sx={{ mt: 1 }} onClick={addDefaults}> <Button
size="small"
sx={{ mt: 1 }}
onClick={() => {
const current = new Set(lines(domains));
(defaults?.blocked_domains ?? []).forEach((item) => current.add(item));
setDomains([...current].join("\n"));
setDirty(true);
}}
>
Add the usual suspects Add the usual suspects
</Button> </Button>
</CardContent>
</Card>
</Grid> </Grid>
<Grid item xs={12} md={4}> <Grid item xs={12} md={4}>
<Card sx={{ height: "100%" }}> <SectionTitle>Exceptions</SectionTitle>
<CardContent>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
Exceptions
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
Survive a blocked parent domain — e.g. <code>music.youtube.com</code>.
</Typography>
<TextField <TextField
multiline multiline
fullWidth fullWidth
minRows={8} minRows={9}
value={allowed} value={allowed}
placeholder="music.youtube.com" placeholder={"music.youtube.com\n*.docs.google.com"}
onChange={(event) => { onChange={(event) => {
setAllowed(event.target.value); setAllowed(event.target.value);
setDirty(true); setDirty(true);
}} }}
sx={{ "& textarea": { fontFamily: "monospace", fontSize: 13 } }} sx={{ "& textarea": { fontFamily: "monospace", fontSize: 13, lineHeight: 1.7 } }}
/> />
</CardContent> <Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 1 }}>
</Card> Masks allowed here too — an exception wins over a block.
</Typography>
</Grid> </Grid>
<Grid item xs={12} md={4}> <Grid item xs={12} md={4}>
<Card sx={{ height: "100%" }}> <SectionTitle>Blocked applications</SectionTitle>
<CardContent>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
Blocked applications
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
Process names, matched loosely: <code>steam</code> also catches{" "}
<code>steamwebhelper</code>.
</Typography>
<TextField <TextField
multiline multiline
fullWidth fullWidth
minRows={8} minRows={9}
value={apps} value={apps}
placeholder={"steam\ndiscord\ntelegram-desktop"} placeholder={"steam*\n*discord*\ntelegram-desktop"}
onChange={(event) => { onChange={(event) => {
setApps(event.target.value); setApps(event.target.value);
setDirty(true); setDirty(true);
}} }}
sx={{ "& textarea": { fontFamily: "monospace", fontSize: 13 } }} sx={{ "& textarea": { fontFamily: "monospace", fontSize: 13, lineHeight: 1.7 } }}
/> />
</CardContent> <Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 1 }}>
</Card> Matched against the process name and its command line.
</Typography>
</Grid> </Grid>
</Grid> </Grid>
<Stack direction="row" spacing={1}> <Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems={{ sm: "center" }} sx={{ mt: 3 }}>
<TextField
label="Subdomains that *.site expands to"
fullWidth
sx={{ maxWidth: 520 }}
value={subdomains}
onChange={(event) => {
setSubdomains(event.target.value);
setDirty(true);
}}
helperText="Comma separated. Add the subdomains you actually visit."
/>
<Box sx={{ flexGrow: 1 }} />
<Button variant="contained" disabled={!dirty} onClick={() => void saveLists()}> <Button variant="contained" disabled={!dirty} onClick={() => void saveLists()}>
Save lists Save lists
</Button> </Button>
<Button <Button disabled={!dirty} onClick={() => setDirty(false)}>
disabled={!dirty} Discard
onClick={() => {
setDirty(false);
}}
>
Discard changes
</Button> </Button>
</Stack> </Stack>
</Box>
<Card> <Box sx={{ mt: 5 }}>
<CardContent> <SectionTitle>Today's temptations</SectionTitle>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
Today's temptations
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
Every attempt the blocker turned away. This is what the daily report teases you about.
</Typography>
{top.length === 0 ? ( {top.length === 0 ? (
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
Nothing yet today — either the blocker is off, or you are behaving. Nothing yet — either the blocker is off, or you are behaving.
</Typography> </Typography>
) : ( ) : (
<Stack divider={<Divider flexItem />}> <Stack divider={<Divider />}>
{top.map((entry) => ( {top.map((entry) => (
<Stack key={entry.target} direction="row" sx={{ py: 1 }} alignItems="center"> <Stack key={entry.target} direction="row" alignItems="center" sx={{ py: 1.25 }}>
<Typography sx={{ flexGrow: 1 }}>{entry.target}</Typography> <Typography variant="body2" sx={{ flexGrow: 1, fontFamily: "monospace" }}>
<Chip size="small" label={`× ${entry.count}`} /> {entry.target}
</Typography>
<Chip size="small" variant="outlined" label={entry.count} />
</Stack> </Stack>
))} ))}
</Stack> </Stack>
)} )}
</CardContent> </Box>
</Card> </>
</Stack>
); );
} }
+41 -61
View File
@@ -3,26 +3,13 @@
* so the chat and the browser can never disagree. * so the chat and the browser can never disagree.
*/ */
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { import { Box, Button, Chip, Divider, Grid, Stack, Tab, Tabs, Typography } from "@mui/material";
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 FocusBars from "../components/charts/FocusBars";
import Heatmap from "../components/charts/Heatmap"; import Heatmap from "../components/charts/Heatmap";
import HourHistogram from "../components/charts/HourHistogram"; import HourHistogram from "../components/charts/HourHistogram";
import SessionTimeline from "../components/charts/SessionTimeline"; import SessionTimeline from "../components/charts/SessionTimeline";
import StatTile from "../components/StatTile"; import { PageHeader, Panel, SectionTitle, Stat, StatRow } from "../components/Layout";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { humanMinutes } from "../lib/format"; import { humanMinutes } from "../lib/format";
import { useResource } from "../lib/hooks"; import { useResource } from "../lib/hooks";
@@ -62,48 +49,42 @@ export default function ReportPage({
}; };
return ( return (
<Stack spacing={3}> <>
<Stack direction="row" alignItems="center" spacing={2} sx={{ flexWrap: "wrap", rowGap: 1 }}> <PageHeader
<Typography variant="h5" sx={{ flexGrow: 1 }}> eyebrow={daily.data?.sent_at ? `sent ${new Date(daily.data.sent_at).toLocaleTimeString()}` : "not sent yet"}
{report ? `Report for ${report.date}` : "Report"} title={report ? `Report · ${report.date}` : "Report"}
</Typography> caption={report?.verdict}
{daily.data?.sent_at && ( actions={
<Chip size="small" variant="outlined" label={`sent ${new Date(daily.data.sent_at).toLocaleTimeString()}`} /> <Button variant="contained" disabled={sending} onClick={() => void send()}>
)} {sending ? "Sending…" : "Send to Telegram"}
<Button variant="contained" startIcon={<SendIcon />} disabled={sending} onClick={() => void send()}>
Send to Telegram
</Button> </Button>
</Stack> }
/>
<Grid container spacing={2}> <StatRow>
<Grid item xs={6} md={3}> <Stat
<StatTile
label="Pomodoros" label="Pomodoros"
value={metrics ? `${metrics.pomodoros}/${metrics.goal}` : "—"} value={metrics ? `${metrics.pomodoros}/${metrics.goal}` : "—"}
caption={metrics?.goal_met ? "goal met 🎯" : "goal not met yet"} caption={metrics?.goal_met ? "goal met" : "goal not met yet"}
accent={palette.work} accent={palette.work}
size="lg"
/> />
</Grid> <Stat
<Grid item xs={6} md={3}>
<StatTile
label="Focus" label="Focus"
value={humanMinutes(metrics?.focus_minutes ?? 0)} value={humanMinutes(metrics?.focus_minutes ?? 0)}
caption={`${humanMinutes(metrics?.break_minutes ?? 0)} resting`} caption={`${humanMinutes(metrics?.break_minutes ?? 0)} resting`}
size="lg"
/> />
</Grid> <Stat label="Score" value={report ? Math.round(report.score) : "—"} caption="out of 100" size="lg" />
<Grid item xs={6} md={3}> <Stat
<StatTile label="Score" value={report ? Math.round(report.score) : "—"} caption="out of 100" />
</Grid>
<Grid item xs={6} md={3}>
<StatTile
label="Streak" label="Streak"
value={report ? `${report.streak.current}d` : "—"} value={report ? `${report.streak.current}d` : "—"}
caption={report ? `best ${report.streak.best}` : ""} caption={report ? `best ${report.streak.best}` : ""}
size="lg"
/> />
</Grid> </StatRow>
</Grid>
<Grid container spacing={3}> <Grid container spacing={3} sx={{ mt: 0 }}>
<Grid item xs={12} md={8}> <Grid item xs={12} md={8}>
<FocusBars days={stats.data?.range ?? []} goal={metrics?.goal ?? 8} palette={palette} /> <FocusBars days={stats.data?.range ?? []} goal={metrics?.goal ?? 8} palette={palette} />
</Grid> </Grid>
@@ -112,12 +93,16 @@ export default function ReportPage({
</Grid> </Grid>
</Grid> </Grid>
<Box sx={{ mt: 3 }}>
<SessionTimeline sessions={metrics?.sessions ?? []} palette={palette} /> <SessionTimeline sessions={metrics?.sessions ?? []} palette={palette} />
</Box>
<Box sx={{ mt: 3 }}>
<Heatmap days={stats.data?.heatmap ?? []} goal={metrics?.goal ?? 8} palette={palette} /> <Heatmap days={stats.data?.heatmap ?? []} goal={metrics?.goal ?? 8} palette={palette} />
</Box>
<Card> <Box sx={{ mt: 4 }}>
<CardContent> <Panel>
<Tabs value={tab} onChange={(_event, value) => setTab(value)} sx={{ mb: 2 }}> <Tabs value={tab} onChange={(_event, value) => setTab(value)} sx={{ mb: 2 }}>
<Tab label={`Achievements (${report?.achievements.length ?? 0})`} /> <Tab label={`Achievements (${report?.achievements.length ?? 0})`} />
<Tab label="The funny part" /> <Tab label="The funny part" />
@@ -200,23 +185,18 @@ export default function ReportPage({
{(daily.data?.telegram_preview ?? "").replace(/<[^>]+>/g, "")} {(daily.data?.telegram_preview ?? "").replace(/<[^>]+>/g, "")}
</Box> </Box>
)} )}
</CardContent> </Panel>
</Card> </Box>
<Grid container spacing={2}> <Box sx={{ mt: 4 }}>
<Grid item xs={6} md={3}> <SectionTitle>All time</SectionTitle>
<StatTile label="All-time pomodoros" value={stats.data?.lifetime.pomodoros ?? 0} /> <StatRow>
</Grid> <Stat label="Pomodoros" value={stats.data?.lifetime.pomodoros ?? 0} />
<Grid item xs={6} md={3}> <Stat label="Hours focused" value={stats.data?.lifetime.focus_hours ?? 0} />
<StatTile label="Hours focused" value={stats.data?.lifetime.focus_hours ?? 0} /> <Stat label="Tasks closed" value={stats.data?.lifetime.tasks_completed ?? 0} />
</Grid> <Stat label="Blocked" value={stats.data?.lifetime.blocked_attempts ?? 0} />
<Grid item xs={6} md={3}> </StatRow>
<StatTile label="Tasks closed" value={stats.data?.lifetime.tasks_completed ?? 0} /> </Box>
</Grid> </>
<Grid item xs={6} md={3}>
<StatTile label="Blocked attempts" value={stats.data?.lifetime.blocked_attempts ?? 0} />
</Grid>
</Grid>
</Stack>
); );
} }
+17 -38
View File
@@ -6,9 +6,6 @@
*/ */
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import {
Accordion,
AccordionDetails,
AccordionSummary,
Alert, Alert,
Box, Box,
Button, Button,
@@ -22,8 +19,8 @@ import {
TextField, TextField,
Typography, Typography,
} from "@mui/material"; } from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import { PageHeader, Panel, SectionTitle } from "../components/Layout";
import { api, getToken, setToken } from "../lib/api"; import { api, getToken, setToken } from "../lib/api";
import { useSettings } from "../lib/settings"; import { useSettings } from "../lib/settings";
import type { AppSettings } from "../lib/types"; import type { AppSettings } from "../lib/types";
@@ -59,7 +56,13 @@ export default function SettingsPage({ onToast }: { onToast: (message: string) =
}; };
return ( return (
<Stack spacing={2}> <>
<PageHeader
eyebrow="Shared with the desktop app and the Telegram bot"
title="Settings"
caption="Secrets are masked once stored — leave the dots alone to keep the saved value."
/>
<Stack spacing={5}>
<Section title="Timer" subtitle="Shared with the desktop app and the Telegram bot."> <Section title="Timer" subtitle="Shared with the desktop app and the Telegram bot.">
<Grid container spacing={2}> <Grid container spacing={2}>
{( {(
@@ -85,6 +88,7 @@ export default function SettingsPage({ onToast }: { onToast: (message: string) =
<Stack direction="row" sx={{ flexWrap: "wrap", mt: 1 }}> <Stack direction="row" sx={{ flexWrap: "wrap", mt: 1 }}>
{( {(
[ [
["timer_enabled", "Timer enabled (off = view only)"],
["auto_start_breaks", "Auto-start breaks"], ["auto_start_breaks", "Auto-start breaks"],
["auto_start_work", "Auto-start next focus"], ["auto_start_work", "Auto-start next focus"],
["allow_pause", "Allow pausing"], ["allow_pause", "Allow pausing"],
@@ -159,17 +163,6 @@ export default function SettingsPage({ onToast }: { onToast: (message: string) =
value={settings.sources.jira.start_date_field} value={settings.sources.jira.start_date_field}
onCommit={(value) => void patch({ sources: { jira: { start_date_field: value } } })} onCommit={(value) => void patch({ sources: { jira: { start_date_field: value } } })}
/> />
<FormControlLabel
control={
<Switch
checked={settings.sources.jira.writeback_done}
onChange={(event) =>
void patch({ sources: { jira: { writeback_done: event.target.checked } } })
}
/>
}
label="Completing a task here transitions the Jira issue to Done"
/>
</SourceBlock> </SourceBlock>
<SourceBlock <SourceBlock
@@ -443,13 +436,6 @@ export default function SettingsPage({ onToast }: { onToast: (message: string) =
<MenuItem value="light">Light</MenuItem> <MenuItem value="light">Light</MenuItem>
<MenuItem value="dark">Dark</MenuItem> <MenuItem value="dark">Dark</MenuItem>
</TextField> </TextField>
<TextField
label="Accent colour"
type="color"
sx={{ width: 120 }}
value={settings.appearance.accent}
onChange={(event) => void patch({ appearance: { accent: event.target.value } })}
/>
<TextField <TextField
label="API token (this browser)" label="API token (this browser)"
type="password" type="password"
@@ -462,12 +448,13 @@ export default function SettingsPage({ onToast }: { onToast: (message: string) =
}} }}
/> />
</Stack> </Stack>
<Alert severity="info" sx={{ mt: 2 }}> <Alert severity="info" variant="outlined" sx={{ mt: 2 }}>
Website and application blocking is enforced by the native desktop app — install it from{" "} Website and application blocking is enforced by the native desktop app — install it from{" "}
<code>desktop/</code>, then its lists live on the <strong>Blocker</strong> page. <code>desktop/</code>; its lists live on the <strong>Blocker</strong> page.
</Alert> </Alert>
</Section> </Section>
</Stack> </Stack>
</>
); );
} }
@@ -481,23 +468,15 @@ function Section({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<Accordion defaultExpanded disableGutters sx={{ borderRadius: 3, "&::before": { display: "none" } }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box> <Box>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}> <SectionTitle>{title}</SectionTitle>
{title}
</Typography>
{subtitle && ( {subtitle && (
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{subtitle} {subtitle}
</Typography> </Typography>
)} )}
<Stack spacing={2}>{children}</Stack>
</Box> </Box>
</AccordionSummary>
<AccordionDetails>
<Stack spacing={1.5}>{children}</Stack>
</AccordionDetails>
</Accordion>
); );
} }
@@ -518,7 +497,7 @@ function SourceBlock({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<Box sx={{ border: 1, borderColor: "divider", borderRadius: 3, p: 2 }}> <Panel>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: enabled ? 1.5 : 0 }}> <Stack direction="row" alignItems="center" spacing={1} sx={{ mb: enabled ? 1.5 : 0 }}>
<Typography sx={{ fontWeight: 600, flexGrow: 1 }}>{label}</Typography> <Typography sx={{ fontWeight: 600, flexGrow: 1 }}>{label}</Typography>
{enabled && <Chip size="small" color="success" variant="outlined" label="on" />} {enabled && <Chip size="small" color="success" variant="outlined" label="on" />}
@@ -532,7 +511,7 @@ function SourceBlock({
{children} {children}
</Stack> </Stack>
)} )}
</Box> </Panel>
); );
} }
+143 -128
View File
@@ -1,9 +1,9 @@
/** /**
* Everything you owe, from every source, in one list. * Everything you owe, from every source, grouped by deadline.
* *
* Grouped by deadline rather than by source, because "what is late" is the * There is no checkbox. A task is closed in Jira, Todoist or Google Tasks —
* question you actually have; the source is a chip on the row. Ticking a task * wherever it lives — and the next refresh notices and credits it to the day.
* closes it in its own system too, and says so when that write-back fails. * Two places to tick something off is two places to be wrong.
*/ */
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { import {
@@ -11,9 +11,6 @@ import {
AlertTitle, AlertTitle,
Box, Box,
Button, Button,
Card,
CardContent,
Checkbox,
Chip, Chip,
Collapse, Collapse,
Dialog, Dialog,
@@ -31,30 +28,36 @@ import {
Tooltip, Tooltip,
Typography, Typography,
} from "@mui/material"; } from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import OpenInNewIcon from "@mui/icons-material/OpenInNew"; import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import PlayArrowIcon from "@mui/icons-material/PlayArrow"; import PlayArrowOutlinedIcon from "@mui/icons-material/PlayArrowOutlined";
import PushPinIcon from "@mui/icons-material/PushPin"; import StarIcon from "@mui/icons-material/Star";
import PushPinOutlinedIcon from "@mui/icons-material/PushPinOutlined"; import StarBorderIcon from "@mui/icons-material/StarBorder";
import RefreshIcon from "@mui/icons-material/Refresh";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { PageHeader, Panel, SectionTitle } from "../components/Layout";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { BUCKET_LABEL } from "../lib/format"; import { BUCKET_LABEL } from "../lib/format";
import { useResource } from "../lib/hooks"; import { useResource } from "../lib/hooks";
import type { RefreshResult, Task, TaskListResponse } from "../lib/types"; import type { RefreshResult, Task, TaskListResponse } from "../lib/types";
const BUCKETS: Task["bucket"][] = ["overdue", "today", "upcoming", "someday"]; const BUCKETS: Task["bucket"][] = ["overdue", "today", "active", "upcoming", "someday"];
const BUCKET_COLOR: Record<string, string> = {
overdue: "error.main",
today: "warning.main",
active: "success.main",
upcoming: "text.disabled",
someday: "divider",
};
export default function TasksPage({ onToast }: { onToast: (message: string) => void }) { export default function TasksPage({ onToast }: { onToast: (message: string) => void }) {
const navigate = useNavigate(); const navigate = useNavigate();
const tasks = useResource<TaskListResponse>("/api/tasks", 60000); const tasks = useResource<TaskListResponse>("/api/tasks", 60000);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [sourceStatus, setSourceStatus] = useState<RefreshResult["sources"] | null>(null); const [sourceStatus, setSourceStatus] = useState<RefreshResult["sources"] | null>(null);
const [filter, setFilter] = useState<string>("all"); const [filter, setFilter] = useState("all");
const [addOpen, setAddOpen] = useState(false); const [addOpen, setAddOpen] = useState(false);
const [draft, setDraft] = useState({ title: "", due_date: "", estimated_pomodoros: "" }); const [draft, setDraft] = useState({ title: "", due_date: "" });
const refresh = async () => { const refresh = async () => {
setRefreshing(true); setRefreshing(true);
@@ -63,10 +66,11 @@ export default function TasksPage({ onToast }: { onToast: (message: string) => v
setSourceStatus(result.sources); setSourceStatus(result.sources);
await tasks.reload(); await tasks.reload();
const failed = result.sources.filter((item) => !item.ok); const failed = result.sources.filter((item) => !item.ok);
const closed = result.sources.reduce((sum, item) => sum + ((item as { closed?: number }).closed ?? 0), 0);
onToast( onToast(
failed.length failed.length
? `${failed.length} source(s) failed — see the banner` ? `${failed.length} source(s) failed — see above`
: `Pulled ${result.total} tasks from ${result.sources.length} source(s)`, : `${result.total} open · ${closed} closed since last refresh`,
); );
} catch (exc) { } catch (exc) {
onToast(exc instanceof Error ? exc.message : String(exc)); onToast(exc instanceof Error ? exc.message : String(exc));
@@ -75,43 +79,54 @@ export default function TasksPage({ onToast }: { onToast: (message: string) => v
} }
}; };
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 visible = useMemo(() => {
const all = tasks.data?.tasks ?? []; const all = tasks.data?.tasks ?? [];
return filter === "all" ? all : all.filter((task) => task.source === filter); return filter === "all" ? all : all.filter((task) => task.source === filter);
}, [tasks.data, filter]); }, [tasks.data, filter]);
const grouped = useMemo(() => { const grouped = useMemo(() => {
const map: Record<string, Task[]> = { overdue: [], today: [], upcoming: [], someday: [] }; const map: Record<string, Task[]> = { overdue: [], today: [], active: [], upcoming: [], someday: [] };
for (const task of visible) map[task.bucket].push(task); for (const task of visible) {
const bucket = task && task.bucket && map[task.bucket] ? task.bucket : "someday";
map[bucket].push(task);
}
return map; return map;
}, [visible]); }, [visible]);
const failed = sourceStatus?.filter((item) => !item.ok) ?? []; const failed = sourceStatus?.filter((item) => !item.ok) ?? [];
return ( return (
<Stack spacing={3}> <>
<Stack direction="row" spacing={1} alignItems="center" sx={{ flexWrap: "wrap", rowGap: 1 }}> <PageHeader
eyebrow="From Jira, Todoist, Google Tasks and your own list"
title="Tasks"
caption="Close them where they live — the next refresh credits them to today."
actions={
<>
<Button variant="outlined" onClick={() => setAddOpen(true)}>
Add
</Button>
<Button variant="contained" disabled={refreshing} onClick={() => void refresh()}>
{refreshing ? "Refreshing…" : "Refresh sources"}
</Button>
</>
}
/>
{refreshing && <LinearProgress sx={{ mb: 2 }} />}
<Collapse in={failed.length > 0}>
<Alert severity="warning" variant="outlined" sx={{ mb: 3 }} onClose={() => setSourceStatus(null)}>
<AlertTitle sx={{ fontWeight: 600 }}>Some sources could not be read</AlertTitle>
{failed.map((item) => (
<Typography key={item.source} variant="body2">
<strong>{item.label}:</strong> {item.error}
</Typography>
))}
</Alert>
</Collapse>
<Stack direction="row" spacing={1} sx={{ mb: 3, flexWrap: "wrap", rowGap: 1 }}>
<ToggleButtonGroup <ToggleButtonGroup
size="small" size="small"
exclusive exclusive
@@ -126,69 +141,52 @@ export default function TasksPage({ onToast }: { onToast: (message: string) => v
))} ))}
</ToggleButtonGroup> </ToggleButtonGroup>
<Box sx={{ flexGrow: 1 }} /> <Box sx={{ flexGrow: 1 }} />
<Button startIcon={<AddIcon />} onClick={() => setAddOpen(true)}>
Add task
</Button>
<Button variant="contained" startIcon={<RefreshIcon />} disabled={refreshing} onClick={() => void refresh()}>
Refresh sources
</Button>
</Stack>
{refreshing && <LinearProgress />}
<Collapse in={failed.length > 0}>
<Alert severity="warning" onClose={() => setSourceStatus(null)}>
<AlertTitle>Some sources could not be read</AlertTitle>
{failed.map((item) => (
<Typography key={item.source} variant="body2">
<strong>{item.label}:</strong> {item.error}
</Typography>
))}
</Alert>
</Collapse>
{sourceStatus && failed.length === 0 && ( {sourceStatus && failed.length === 0 && (
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap", rowGap: 1 }}> <Stack direction="row" spacing={1} alignItems="center" sx={{ flexWrap: "wrap", rowGap: 1 }}>
{sourceStatus.map((item) => ( {sourceStatus.map((item) => (
<Chip key={item.source} size="small" color="success" variant="outlined" label={`${item.label}: ${item.count}`} /> <Chip key={item.source} size="small" variant="outlined" label={`${item.label} ${item.count}`} />
))} ))}
</Stack> </Stack>
)} )}
</Stack>
{tasks.data && visible.length === 0 && ( {tasks.data && visible.length === 0 && (
<Card> <Panel>
<CardContent> <Typography variant="h6" sx={{ mb: 1 }}>
<Typography variant="h6">Nothing on the list</Typography> Nothing on the list
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
Connect Jira, Todoist or Google Tasks in Settings → Sources, or add something yourself.
</Typography> </Typography>
</CardContent> <Typography variant="body2" color="text.secondary">
</Card> Connect Jira, Todoist or Google Tasks in Settings → Task sources, or add something yourself.
</Typography>
</Panel>
)} )}
{BUCKETS.map((bucket) => {BUCKETS.map((bucket) =>
grouped[bucket].length ? ( grouped[bucket].length ? (
<Card key={bucket}> <Box key={bucket} sx={{ mb: 4 }}>
<CardContent> <SectionTitle
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}> action={
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}> <Typography variant="caption" color="text.secondary">
{BUCKET_LABEL[bucket]} {grouped[bucket].length}
</Typography> </Typography>
<Chip }
size="small" >
label={grouped[bucket].length} {BUCKET_LABEL[bucket]}
color={bucket === "overdue" ? "error" : bucket === "today" ? "warning" : "default"} </SectionTitle>
/> <Stack divider={<Divider />}>
</Stack>
<Divider />
<Stack divider={<Divider flexItem />}>
{grouped[bucket].map((task) => ( {grouped[bucket].map((task) => (
<TaskRow <TaskRow
key={task.id} key={task.id}
task={task} task={task}
onToggle={toggleDone} onPin={async () => {
onPin={togglePin} await api.patch(`/api/tasks/${task.id}`, { pinned: !task.pinned });
onStart={startOn} await tasks.reload();
}}
onStart={async () => {
await api.post("/api/timer/start", { kind: "work", task_id: task.id });
onToast(`Focusing on “${task.title}”`);
navigate("/");
}}
onDelete={async () => { onDelete={async () => {
await api.del(`/api/tasks/${task.id}`); await api.del(`/api/tasks/${task.id}`);
await tasks.reload(); await tasks.reload();
@@ -196,8 +194,7 @@ export default function TasksPage({ onToast }: { onToast: (message: string) => v
/> />
))} ))}
</Stack> </Stack>
</CardContent> </Box>
</Card>
) : null, ) : null,
)} )}
@@ -207,7 +204,7 @@ export default function TasksPage({ onToast }: { onToast: (message: string) => v
<Stack spacing={2} sx={{ mt: 1 }}> <Stack spacing={2} sx={{ mt: 1 }}>
<TextField <TextField
autoFocus autoFocus
label="Title" label="What needs doing?"
value={draft.title} value={draft.title}
onChange={(event) => setDraft({ ...draft, title: event.target.value })} onChange={(event) => setDraft({ ...draft, title: event.target.value })}
/> />
@@ -218,12 +215,9 @@ export default function TasksPage({ onToast }: { onToast: (message: string) => v
value={draft.due_date} value={draft.due_date}
onChange={(event) => setDraft({ ...draft, due_date: event.target.value })} onChange={(event) => setDraft({ ...draft, due_date: event.target.value })}
/> />
<TextField <Typography variant="caption" color="text.secondary">
label="Estimated pomodoros" Local tasks live only here, so they are the one kind you can delete.
type="number" </Typography>
value={draft.estimated_pomodoros}
onChange={(event) => setDraft({ ...draft, estimated_pomodoros: event.target.value })}
/>
</Stack> </Stack>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
@@ -235,9 +229,8 @@ export default function TasksPage({ onToast }: { onToast: (message: string) => v
await api.post("/api/tasks", { await api.post("/api/tasks", {
title: draft.title.trim(), title: draft.title.trim(),
due_date: draft.due_date || null, due_date: draft.due_date || null,
estimated_pomodoros: draft.estimated_pomodoros ? Number(draft.estimated_pomodoros) : null,
}); });
setDraft({ title: "", due_date: "", estimated_pomodoros: "" }); setDraft({ title: "", due_date: "" });
setAddOpen(false); setAddOpen(false);
await tasks.reload(); await tasks.reload();
}} }}
@@ -246,71 +239,93 @@ export default function TasksPage({ onToast }: { onToast: (message: string) => v
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
</Stack> </>
); );
} }
function TaskRow({ function TaskRow({
task, task,
onToggle,
onPin, onPin,
onStart, onStart,
onDelete, onDelete,
}: { }: {
task: Task; task: Task;
onToggle: (task: Task, done: boolean) => Promise<void>; onPin: () => Promise<void>;
onPin: (task: Task) => Promise<void>; onStart: () => Promise<void>;
onStart: (task: Task) => Promise<void>;
onDelete: () => Promise<void>; onDelete: () => Promise<void>;
}) { }) {
return ( return (
<Stack direction="row" spacing={1} alignItems="center" sx={{ py: 1 }}> <Stack
<Checkbox checked={task.is_done} onChange={(event) => void onToggle(task, event.target.checked)} /> direction="row"
<Box sx={{ flexGrow: 1, minWidth: 0 }}> spacing={2}
<Stack direction="row" spacing={1} alignItems="center" sx={{ flexWrap: "wrap", rowGap: 0.5 }}> alignItems="center"
<Typography sx={{ py: 1.5, "&:hover .row-actions": { opacity: 1 } }}
variant="body1"
sx={{ textDecoration: task.is_done ? "line-through" : "none", opacity: task.is_done ? 0.6 : 1 }}
> >
<Box sx={{ width: 2, alignSelf: "stretch", bgcolor: BUCKET_COLOR[task.bucket] ?? BUCKET_COLOR.someday, borderRadius: 1 }} />
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
<Stack direction="row" spacing={1} alignItems="center">
<Typography variant="body1" noWrap sx={{ fontWeight: task.pinned ? 600 : 400 }}>
{task.title} {task.title}
</Typography> </Typography>
{task.url && ( {task.url && (
<Link href={task.url} target="_blank" rel="noreferrer" sx={{ display: "inline-flex" }}> <Link href={task.url} target="_blank" rel="noreferrer" sx={{ display: "inline-flex", color: "text.disabled" }}>
<OpenInNewIcon fontSize="inherit" /> <OpenInNewIcon sx={{ fontSize: 14 }} />
</Link> </Link>
)} )}
</Stack> </Stack>
<Stack direction="row" spacing={1} sx={{ mt: 0.5, flexWrap: "wrap", rowGap: 0.5 }}> <Stack direction="row" spacing={1.5} sx={{ mt: 0.25, flexWrap: "wrap", rowGap: 0.5 }}>
<Chip size="small" variant="outlined" label={task.source_label} /> <Typography variant="caption" color="text.secondary">
{task.project && <Chip size="small" variant="outlined" label={task.project} />} {task.source_label}
{task.priority && <Chip size="small" variant="outlined" label={task.priority} />} </Typography>
{task.status && <Chip size="small" variant="outlined" label={task.status} />} {task.project && (
{task.days_overdue > 0 && ( <Typography variant="caption" color="text.secondary">
<Chip size="small" color="error" label={`${task.days_overdue}d overdue`} /> {task.project}
</Typography>
)} )}
{task.due_date && task.days_overdue === 0 && ( {task.status && (
<Chip size="small" variant="outlined" label={task.due_date} /> <Typography variant="caption" color="text.secondary">
{task.status}
</Typography>
)}
{task.priority && (
<Typography variant="caption" color="text.secondary">
{task.priority}
</Typography>
)}
{task.days_overdue > 0 ? (
<Typography variant="caption" color="error.main" sx={{ fontWeight: 600 }}>
{task.days_overdue}d overdue
</Typography>
) : (
task.due_date && (
<Typography variant="caption" color="text.secondary">
due {task.due_date}
</Typography>
)
)} )}
{task.stale && <Chip size="small" color="warning" variant="outlined" label="gone from source" />}
</Stack> </Stack>
</Box> </Box>
<Stack direction="row" spacing={0.5} className="row-actions" sx={{ opacity: { xs: 1, md: 0.25 }, transition: "opacity .15s" }}>
<Tooltip title={task.pinned ? "Unpin" : "Pin to the top"}> <Tooltip title={task.pinned ? "Unpin" : "Pin to the top"}>
<IconButton size="small" onClick={() => void onPin(task)}> <IconButton size="small" onClick={() => void onPin()}>
{task.pinned ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />} {task.pinned ? <StarIcon sx={{ fontSize: 17 }} /> : <StarBorderIcon sx={{ fontSize: 17 }} />}
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Tooltip title="Start a pomodoro on this"> <Tooltip title="Start a pomodoro on this">
<IconButton size="small" color="primary" onClick={() => void onStart(task)}> <IconButton size="small" onClick={() => void onStart()}>
<PlayArrowIcon fontSize="small" /> <PlayArrowOutlinedIcon sx={{ fontSize: 18 }} />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
{task.source === "local" && ( {task.source === "local" && (
<Tooltip title="Delete"> <Tooltip title="Delete">
<IconButton size="small" onClick={() => void onDelete()}> <IconButton size="small" onClick={() => void onDelete()}>
<DeleteOutlineIcon fontSize="small" /> <DeleteOutlineIcon sx={{ fontSize: 17 }} />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
)} )}
</Stack> </Stack>
</Stack>
); );
} }
+156 -171
View File
@@ -2,9 +2,9 @@
* The timer. * The timer.
* *
* The server owns the clock, so this page polls `/api/timer/state` and ticks * 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 * locally in between: the countdown is smooth, and a reload — or the desktop
* taking over) picks up exactly where the browser left off. When the local * 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 * countdown reaches zero it tells the server to complete the interval; the
* server decides what comes next. * server decides what comes next.
*/ */
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -12,9 +12,6 @@ import {
Alert, Alert,
Box, Box,
Button, Button,
Card,
CardContent,
Chip,
Dialog, Dialog,
DialogActions, DialogActions,
DialogContent, DialogContent,
@@ -22,23 +19,18 @@ import {
Divider, Divider,
Grid, Grid,
IconButton, IconButton,
LinearProgress,
MenuItem, MenuItem,
Stack, Stack,
TextField, TextField,
Tooltip, Tooltip,
Typography, Typography,
} from "@mui/material"; } from "@mui/material";
import AddAlarmIcon from "@mui/icons-material/AddAlarm"; import AddAlarmOutlinedIcon from "@mui/icons-material/AddAlarmOutlined";
import CheckCircleIcon from "@mui/icons-material/CheckCircle"; import NotificationsNoneOutlinedIcon from "@mui/icons-material/NotificationsNoneOutlined";
import CoffeeIcon from "@mui/icons-material/Coffee"; import StopOutlinedIcon from "@mui/icons-material/StopOutlined";
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 SessionTimeline from "../components/charts/SessionTimeline";
import StatTile from "../components/StatTile"; import { PageHeader, Panel, SectionTitle, Stat, StatRow } from "../components/Layout";
import TimerDial from "../components/TimerDial"; import TimerDial from "../components/TimerDial";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { KIND_LABEL, clock, humanMinutes } from "../lib/format"; import { KIND_LABEL, clock, humanMinutes } from "../lib/format";
@@ -55,12 +47,12 @@ export default function TimerPage({
onToast: (message: string) => void; onToast: (message: string) => void;
dark: boolean; dark: boolean;
}) { }) {
const { settings } = useSettings(); const { settings, save } = useSettings();
const palette = useMemo(() => vizPalette(dark), [dark]); const palette = useMemo(() => vizPalette(dark), [dark]);
const timer = useResource<TimerState>("/api/timer/state", 10000); const timer = useResource<TimerState>("/api/timer/state", 10000);
const tasks = useResource<TaskListResponse>("/api/tasks", 60000); const tasks = useResource<TaskListResponse>("/api/tasks", 60000);
const today = useResource<{ sessions: unknown[]; metrics: DayMetrics }>("/api/timer/sessions", 30000); const today = useResource<{ metrics: DayMetrics }>("/api/timer/sessions", 30000);
const [abandonOpen, setAbandonOpen] = useState(false); const [abandonOpen, setAbandonOpen] = useState(false);
const [abandonReason, setAbandonReason] = useState(""); const [abandonReason, setAbandonReason] = useState("");
@@ -70,27 +62,23 @@ export default function TimerPage({
const state = timer.data; const state = timer.data;
const current = state?.current ?? null; const current = state?.current ?? null;
const running = current?.status === "running"; const running = current?.status === "running";
const timerEnabled = settings?.pomodoro.timer_enabled ?? true;
// Local countdown between polls.
const tick = useTick(Boolean(current)); const tick = useTick(Boolean(current));
const anchor = useRef<{ at: number; remaining: number; id: number } | null>(null); const anchor = useRef<{ at: number; remaining: number; id: number } | null>(null);
useEffect(() => { useEffect(() => {
if (current) anchor.current = { at: Date.now(), remaining: current.remaining_seconds, id: current.id }; anchor.current = current
else anchor.current = null; ? { at: Date.now(), remaining: current.remaining_seconds, id: current.id }
: null;
}, [current?.id, current?.remaining_seconds, current?.status]); }, [current?.id, current?.remaining_seconds, current?.status]);
const remaining = useMemo(() => { const remaining = useMemo(() => {
if (!current) return 0; if (!current) return 0;
if (!anchor.current || anchor.current.id !== current.id) return current.remaining_seconds; if (!anchor.current || anchor.current.id !== current.id) return current.remaining_seconds;
if (current.status === "paused") return anchor.current.remaining; 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 - Math.floor((Date.now() - anchor.current.at) / 1000));
return Math.max(0, anchor.current.remaining - drift);
}, [current, tick]); }, [current, tick]);
const refreshAll = useCallback(async () => {
await Promise.all([timer.reload(), today.reload()]);
}, [timer, today]);
const call = useCallback( const call = useCallback(
async (path: string, body?: unknown, successToast?: string) => { async (path: string, body?: unknown, successToast?: string) => {
setBusy(true); setBusy(true);
@@ -110,7 +98,6 @@ export default function TimerPage({
[onToast, timer, today], [onToast, timer, today],
); );
// When the countdown hits zero, hand it to the server and announce it.
const firing = useRef(false); const firing = useRef(false);
useEffect(() => { useEffect(() => {
if (!current || remaining > 0 || firing.current) return; if (!current || remaining > 0 || firing.current) return;
@@ -123,12 +110,9 @@ export default function TimerPage({
wasWork ? sound.workDone(volume) : sound.breakDone(volume); wasWork ? sound.workDone(volume) : sound.breakDone(volume);
} }
if (settings?.pomodoro.desktop_notifications ?? true) { if (settings?.pomodoro.desktop_notifications ?? true) {
notify( notify(wasWork ? "Pomodoro complete" : "Break over", wasWork ? "Time for a break." : "Back to it.");
wasWork ? "Pomodoro complete 🍅" : "Break over ☕",
wasWork ? "Time for a break." : "Back to it.",
);
} }
if (next?.celebration) onToast("Milestone reached — check Telegram 🎉"); if (next?.celebration) onToast("Milestone reached — Telegram got a cheer");
firing.current = false; firing.current = false;
})(); })();
}, [remaining, current, call, settings, onToast]); }, [remaining, current, call, settings, onToast]);
@@ -138,7 +122,7 @@ export default function TimerPage({
await call( await call(
"/api/timer/start", "/api/timer/start",
{ kind, task_id: kind === "work" && selectedTask !== "" ? selectedTask : undefined }, { kind, task_id: kind === "work" && selectedTask !== "" ? selectedTask : undefined },
kind === "work" ? "Focus interval started" : "Break started", kind === "work" ? "Focus started" : "Break started",
); );
}; };
@@ -150,184 +134,184 @@ export default function TimerPage({
const kind = current?.kind ?? state?.next_kind ?? "work"; const kind = current?.kind ?? state?.next_kind ?? "work";
const total = current?.planned_seconds ?? (state ? state.durations[kind] * 60 : 1500); const total = current?.planned_seconds ?? (state ? state.durations[kind] * 60 : 1500);
const accent = kindColor(kind, palette);
return ( return (
<Stack spacing={3}>
<Grid container spacing={3}>
<Grid item xs={12} md={7}>
<Card>
<CardContent sx={{ textAlign: "center", pb: 3 }}>
<TimerDial
remaining={current ? remaining : total}
total={total}
label={current ? KIND_LABEL[current.kind] : `Next: ${KIND_LABEL[kind]}`}
sublabel={current?.task?.title ?? (current ? undefined : "Press start when you are ready")}
color={kindColor(kind, palette)}
paused={current?.status === "paused"}
/>
<Stack direction="row" spacing={1} justifyContent="center" sx={{ mt: 3, flexWrap: "wrap", rowGap: 1 }}>
{!current && (
<> <>
<Button <PageHeader
variant="contained" eyebrow={new Date().toLocaleDateString(undefined, { weekday: "long", day: "numeric", month: "long" })}
size="large" title={!timerEnabled ? "View mode" : (current ? KIND_LABEL[current.kind] : "Ready when you are")}
startIcon={<PlayArrowIcon />} caption={
disabled={busy} !timerEnabled
onClick={() => void start("work")} ? "Timer is turned off · spectator mode"
> : (current?.task
Start focus ? `Tracking “${current.task.title}”`
</Button> : `Cycle ${state?.cycle_position ?? 1} of ${state?.cycles_before_long_break ?? 4} before a long break`)
}
actions={
<Stack direction="row" spacing={1} alignItems="center">
<Button <Button
variant="outlined" variant="outlined"
size="large" size="small"
startIcon={<CoffeeIcon />} onClick={async () => {
disabled={busy} try {
onClick={() => void start(state?.next_kind === "long_break" ? "long_break" : "short_break")} await save({ pomodoro: { timer_enabled: !timerEnabled } });
onToast(!timerEnabled ? "Timer enabled" : "Switched to view-only mode");
} catch (exc) {
onToast(exc instanceof Error ? exc.message : String(exc));
}
}}
> >
{state?.next_kind === "long_break" ? "Long break" : "Short break"} {timerEnabled ? "Turn off timer" : "Turn on timer"}
</Button> </Button>
</> {timerEnabled && (
)} current ? (
{current && (
<> <>
{running ? ( {running ? (
<Button <Button
variant="outlined" variant="outlined"
size="large"
startIcon={<PauseIcon />}
disabled={busy || !(settings?.pomodoro.allow_pause ?? true)} disabled={busy || !(settings?.pomodoro.allow_pause ?? true)}
onClick={() => void call("/api/timer/pause")} onClick={() => void call("/api/timer/pause")}
> >
Pause Pause
</Button> </Button>
) : ( ) : (
<Button <Button variant="outlined" disabled={busy} onClick={() => void call("/api/timer/resume")}>
variant="contained"
size="large"
startIcon={<PlayArrowIcon />}
disabled={busy}
onClick={() => void call("/api/timer/resume")}
>
Resume Resume
</Button> </Button>
)} )}
<Button <Button
variant="contained" variant="contained"
color="success"
size="large"
startIcon={<CheckCircleIcon />}
disabled={busy} disabled={busy}
onClick={() => void call("/api/timer/complete", { auto_next: true }, "Interval completed")} onClick={() => void call("/api/timer/complete", { auto_next: true }, "Interval finished")}
> >
Done Finish
</Button> </Button>
<Tooltip title="Add five minutes"> </>
<IconButton ) : (
<>
<Button variant="contained" disabled={busy} onClick={() => void start("work")}>
Start focus
</Button>
<Button
variant="outlined"
disabled={busy} disabled={busy}
onClick={() => void call("/api/timer/extend", { minutes: 5 })} onClick={() => void start(state?.next_kind === "long_break" ? "long_break" : "short_break")}
> >
<AddAlarmIcon /> {state?.next_kind === "long_break" ? "Long break" : "Short break"}
</Button>
</>
)
)}
</Stack>
}
/>
<Grid container spacing={4}>
<Grid item xs={12} md={7}>
<Panel sx={{ py: { xs: 4, md: 5 } }}>
<TimerDial
remaining={timerEnabled ? (current ? remaining : total) : 0}
total={total}
label={!timerEnabled ? "View mode" : (current ? KIND_LABEL[current.kind] : `Next · ${KIND_LABEL[kind]}`)}
sublabel={!timerEnabled ? "Timer disabled" : (current?.task?.title ?? (current ? undefined : "Pick something below, or just begin"))}
color={timerEnabled ? accent : palette.axis}
paused={current?.status === "paused"}
idle={!timerEnabled || !current}
/>
{current && timerEnabled && (
<Stack direction="row" spacing={1} justifyContent="center" sx={{ mt: 3 }}>
<Tooltip title="Add five minutes">
<IconButton size="small" disabled={busy} onClick={() => void call("/api/timer/extend", { minutes: 5 })}>
<AddAlarmOutlinedIcon fontSize="small" />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Tooltip title="Count an interruption without stopping"> <Tooltip title="Count an interruption without stopping the clock">
<IconButton disabled={busy} onClick={() => void call("/api/timer/interrupt")}> <IconButton size="small" disabled={busy} onClick={() => void call("/api/timer/interrupt")}>
<NotificationsActiveIcon /> <NotificationsNoneOutlinedIcon fontSize="small" />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Tooltip title="Abandon this interval"> <Tooltip title="Abandon this interval">
<IconButton color="error" disabled={busy} onClick={() => setAbandonOpen(true)}> <IconButton size="small" disabled={busy} onClick={() => setAbandonOpen(true)}>
<StopIcon /> <StopOutlinedIcon fontSize="small" />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</>
)}
</Stack> </Stack>
)}
{current && current.interruptions > 0 && ( {current && current.interruptions > 0 && (
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 1.5 }}> <Typography variant="caption" color="text.secondary" align="center" sx={{ display: "block", mt: 1.5 }}>
{current.interruptions} interruption{current.interruptions === 1 ? "" : "s"} this interval {current.interruptions} interruption{current.interruptions === 1 ? "" : "s"} so far
</Typography> </Typography>
)} )}
</CardContent> </Panel>
</Card>
</Grid> </Grid>
<Grid item xs={12} md={5}> <Grid item xs={12} md={5}>
<Stack spacing={3} sx={{ height: "100%" }}> <Stack spacing={3}>
<Card> <Box>
<CardContent> <SectionTitle>Today</SectionTitle>
<Typography variant="overline" color="text.secondary">
Today
</Typography>
<Stack direction="row" alignItems="baseline" spacing={1}> <Stack direction="row" alignItems="baseline" spacing={1}>
<Typography variant="h4" sx={{ fontVariantNumeric: "tabular-nums" }}> <Typography
sx={{
fontFamily: (theme) => theme.typography.h1.fontFamily,
fontSize: 44,
lineHeight: 1,
letterSpacing: "-0.03em",
}}
>
{doneToday} {doneToday}
</Typography> </Typography>
<Typography variant="h6" color="text.secondary"> <Typography variant="body1" color="text.secondary">
/ {goal} pomodoros of {goal} pomodoros
</Typography> </Typography>
</Stack> </Stack>
<LinearProgress
variant="determinate" <Stack direction="row" spacing={0.75} sx={{ flexWrap: "wrap", rowGap: 0.75, mt: 2 }}>
value={Math.min(100, goal ? (doneToday / goal) * 100 : 0)}
sx={{ height: 8, borderRadius: 4, my: 1.5 }}
/>
<Stack direction="row" spacing={0.75} sx={{ flexWrap: "wrap", rowGap: 0.75 }}>
{Array.from({ length: Math.max(goal, doneToday) }, (_unused, index) => ( {Array.from({ length: Math.max(goal, doneToday) }, (_unused, index) => (
<Box <Box
key={index} key={index}
sx={{ sx={{
width: 14, width: 11,
height: 14, height: 11,
borderRadius: "50%", borderRadius: "50%",
bgcolor: index < doneToday ? palette.work : "action.hover", bgcolor: index < doneToday ? palette.work : "transparent",
outline: index < doneToday ? "none" : `1px solid ${palette.grid}`, border: index < doneToday ? "none" : "1px solid",
outlineOffset: "-1px", borderColor: "divider",
}} }}
/> />
))} ))}
</Stack> </Stack>
{metrics && ( {metrics && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 1.5 }}> <Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
{humanMinutes(metrics.focus_minutes)} focused · {humanMinutes(metrics.break_minutes)} rested {humanMinutes(metrics.focus_minutes)} focused · {humanMinutes(metrics.break_minutes)} rested
{metrics.longest_stretch_minutes > 0 && {metrics.longest_stretch_minutes > 0 &&
` · longest stretch ${humanMinutes(metrics.longest_stretch_minutes)}`} ` · longest run ${humanMinutes(metrics.longest_stretch_minutes)}`}
</Typography> </Typography>
)} )}
<Typography variant="caption" color="text.secondary"> </Box>
Cycle {state?.cycle_position ?? 1} of {state?.cycles_before_long_break ?? 4} before a long break
</Typography>
</CardContent>
</Card>
<Card sx={{ flexGrow: 1 }}> <Box>
<CardContent> <SectionTitle>Working on</SectionTitle>
<Typography variant="overline" color="text.secondary">
What are you working on?
</Typography>
<TextField <TextField
select select
fullWidth fullWidth
sx={{ mt: 1 }} SelectProps={{ displayEmpty: true }}
value={selectedTask} value={selectedTask}
onChange={(event) => { onChange={(event) => {
const value = event.target.value === "" ? "" : Number(event.target.value); const value = event.target.value === "" ? "" : Number(event.target.value);
setSelectedTask(value); setSelectedTask(value);
if (current) void call("/api/timer/task", { task_id: value === "" ? null : 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"
}
> >
<MenuItem value=""> <MenuItem value="">
<em>No task</em> <em>Nothing in particular</em>
</MenuItem> </MenuItem>
{openTasks.slice(0, 50).map((task) => ( {openTasks.slice(0, 60).map((task) => (
<MenuItem key={task.id} value={task.id}> <MenuItem key={task.id} value={task.id}>
{task.pinned ? "📌 " : ""} {task.pinned ? "★ " : ""}
{task.title} {task.title}
{task.due_date ? ` · ${task.due_date}` : ""} {task.due_date ? ` · ${task.due_date}` : ""}
</MenuItem> </MenuItem>
@@ -337,56 +321,58 @@ export default function TimerPage({
{suggestion && selectedTask === "" && ( {suggestion && selectedTask === "" && (
<Alert <Alert
severity={suggestion.days_overdue > 0 ? "warning" : "info"} severity={suggestion.days_overdue > 0 ? "warning" : "info"}
sx={{ mt: 2 }} variant="outlined"
sx={{ mt: 1.5 }}
action={ action={
<Button size="small" onClick={() => setSelectedTask(suggestion.id)}> <Button size="small" onClick={() => setSelectedTask(suggestion.id)}>
Use Use
</Button> </Button>
} }
> >
Suggested: <strong>{suggestion.title}</strong> {suggestion.days_overdue > 0 ? "Most overdue: " : "Next up: "}
{suggestion.days_overdue > 0 ? ` — ${suggestion.days_overdue} days overdue` : ""} <strong>{suggestion.title}</strong>
</Alert> </Alert>
)} )}
<Divider sx={{ my: 2 }} /> <Stack direction="row" spacing={2} sx={{ mt: 2 }} divider={<Divider flexItem orientation="vertical" />}>
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap", rowGap: 1 }}> <Typography variant="caption" color="text.secondary">
<Chip size="small" label={`${tasks.data?.counts.overdue ?? 0} overdue`} color="error" variant="outlined" /> {tasks.data?.counts.overdue ?? 0} overdue
<Chip size="small" label={`${tasks.data?.counts.today ?? 0} due today`} color="warning" variant="outlined" /> </Typography>
<Chip size="small" label={`${tasks.data?.counts.upcoming ?? 0} upcoming`} variant="outlined" /> <Typography variant="caption" color="text.secondary">
{tasks.data?.counts.today ?? 0} due today
</Typography>
<Typography variant="caption" color="text.secondary">
{tasks.data?.counts.upcoming ?? 0} upcoming
</Typography>
</Stack> </Stack>
</CardContent> </Box>
</Card>
</Stack> </Stack>
</Grid> </Grid>
</Grid> </Grid>
<Grid container spacing={3}> <Box sx={{ mt: 4 }}>
<Grid item xs={6} md={3}> <StatRow>
<StatTile label="Focus today" value={humanMinutes(metrics?.focus_minutes ?? 0)} caption={`${metrics?.pomodoros ?? 0} pomodoros`} /> <Stat label="Focus today" value={humanMinutes(metrics?.focus_minutes ?? 0)} caption={`${metrics?.pomodoros ?? 0} intervals`} />
</Grid> <Stat
<Grid item xs={6} md={3}>
<StatTile
label="Interruptions" label="Interruptions"
value={metrics?.interruptions ?? 0} value={metrics?.interruptions ?? 0}
caption={metrics?.abandoned ? `${metrics.abandoned} abandoned` : "none dropped"} caption={metrics?.abandoned ? `${metrics.abandoned} abandoned` : "none dropped"}
/> />
</Grid> <Stat label="Closed today" value={metrics?.tasks_completed_count ?? 0} caption="in your own tools" />
<Grid item xs={6} md={3}> <Stat label="Blocked" value={metrics?.blocked_attempts ?? 0} caption="temptations turned away" />
<StatTile label="Tasks closed" value={metrics?.tasks_completed_count ?? 0} caption="today" /> </StatRow>
</Grid> </Box>
<Grid item xs={6} md={3}>
<StatTile label="Blocked attempts" value={metrics?.blocked_attempts ?? 0} caption="temptations turned away" />
</Grid>
</Grid>
<Box sx={{ mt: 4 }}>
<SessionTimeline sessions={metrics?.sessions ?? []} palette={palette} /> <SessionTimeline sessions={metrics?.sessions ?? []} palette={palette} />
</Box>
<Dialog open={abandonOpen} onClose={() => setAbandonOpen(false)} fullWidth maxWidth="xs"> <Dialog open={abandonOpen} onClose={() => setAbandonOpen(false)} fullWidth maxWidth="xs">
<DialogTitle>Abandon this interval?</DialogTitle> <DialogTitle>Abandon this interval?</DialogTitle>
<DialogContent> <DialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{clock(remaining)} left. {settings?.pomodoro.strict_mode ? "Strict mode wants a reason." : "A reason is optional."} {clock(remaining)} left.{" "}
{settings?.pomodoro.strict_mode ? "Strict mode wants a reason." : "A reason is optional."}
</Typography> </Typography>
<TextField <TextField
autoFocus autoFocus
@@ -405,7 +391,6 @@ export default function TimerPage({
if (result) { if (result) {
setAbandonOpen(false); setAbandonOpen(false);
setAbandonReason(""); setAbandonReason("");
void refreshAll();
} }
}} }}
> >
@@ -413,17 +398,17 @@ export default function TimerPage({
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
</Stack> </>
); );
} }
/** Overdue first, then due today, then whatever is pinned — the obvious next thing. */ /** Pinned first, then the most overdue, then what is due today. */
function pickSuggestion(tasks: Task[]): Task | null { function pickSuggestion(tasks: Task[]): Task | null {
const pinned = tasks.find((task) => task.pinned); return (
if (pinned) return pinned; tasks.find((task) => task.pinned) ??
const overdue = tasks.filter((task) => task.bucket === "overdue"); tasks.find((task) => task.bucket === "overdue") ??
if (overdue.length) return overdue[0]; tasks.find((task) => task.bucket === "today") ??
const dueToday = tasks.filter((task) => task.bucket === "today"); tasks[0] ??
if (dueToday.length) return dueToday[0]; null
return tasks[0] ?? null; );
} }
+100 -36
View File
@@ -1,61 +1,125 @@
/** /**
* Material theme. * The MUI theme, built from the tokens.
* *
* The seed colour from the settings drives the palette, so changing the accent * Material components are used for their behaviour, not their chrome: shadows
* repaints the whole app; shape, elevation and typography follow Material 3's * are off, radii are small, surfaces are separated by hairlines, and type is the
* defaults (rounded 16–28px surfaces, tonal containers, no hard shadows). * Fraunces/Inter pairing. The accent is a fixed ember — it is a brand colour,
* not a user setting, because the warm palette is the design.
*/ */
import { alpha, createTheme, type Theme } from "@mui/material/styles"; import { createTheme, type Theme } from "@mui/material/styles";
import { DISPLAY_FONT, SANS_FONT, tokensFor } from "./tokens";
export type ThemeMode = "light" | "dark"; export type ThemeMode = "light" | "dark";
export function buildTheme(mode: ThemeMode, seed: string, compact: boolean): Theme { export function buildTheme(mode: ThemeMode, compact: boolean): Theme {
const dark = mode === "dark"; const t = tokensFor(mode);
return createTheme({ return createTheme({
palette: { palette: {
mode, mode,
primary: { main: seed }, primary: { main: t.ember, contrastText: mode === "dark" ? "#16140F" : "#FFFFFF" },
secondary: { main: dark ? "#CCC2DC" : "#625B71" }, secondary: { main: t.pine },
success: { main: dark ? "#8BD5A8" : "#2E7D52" }, success: { main: t.pine },
warning: { main: dark ? "#E6B566" : "#9A6700" }, warning: { main: t.amber },
error: { main: dark ? "#F2B8B5" : "#B3261E" }, error: { main: t.clay },
background: { text: { primary: t.ink, secondary: t.inkMuted, disabled: t.inkFaint },
default: dark ? "#131316" : "#FBF8FD", divider: t.rule,
paper: dark ? "#1C1B20" : "#FFFFFF", background: { default: t.paper, paper: t.surface },
}, },
}, shape: { borderRadius: 10 },
shape: { borderRadius: 16 }, spacing: compact ? 7 : 8,
spacing: compact ? 6 : 8,
typography: { typography: {
fontFamily: '"Roboto", "Segoe UI", system-ui, sans-serif', fontFamily: SANS_FONT,
h4: { fontWeight: 500, letterSpacing: "-0.01em" }, h1: { fontFamily: DISPLAY_FONT, fontWeight: 500, letterSpacing: "-0.02em" },
h5: { fontWeight: 500 }, h2: { fontFamily: DISPLAY_FONT, fontWeight: 500, letterSpacing: "-0.02em" },
h6: { fontWeight: 500 }, h3: { fontFamily: DISPLAY_FONT, fontWeight: 500, letterSpacing: "-0.015em" },
h4: { fontFamily: DISPLAY_FONT, fontWeight: 500, letterSpacing: "-0.015em" },
h5: { fontFamily: DISPLAY_FONT, fontWeight: 500, letterSpacing: "-0.01em" },
h6: { fontFamily: DISPLAY_FONT, fontWeight: 500, fontSize: "1.1rem" },
subtitle1: { fontWeight: 600, fontSize: "0.95rem" },
subtitle2: { fontWeight: 600, fontSize: "0.82rem" },
body2: { fontSize: "0.875rem", lineHeight: 1.55 },
button: { textTransform: "none", fontWeight: 500, letterSpacing: 0 }, button: { textTransform: "none", fontWeight: 500, letterSpacing: 0 },
overline: { letterSpacing: "0.08em", fontWeight: 500 }, overline: {
fontSize: "0.68rem",
fontWeight: 600,
letterSpacing: "0.12em",
textTransform: "uppercase",
lineHeight: 1.6,
},
caption: { fontSize: "0.76rem", lineHeight: 1.5 },
}, },
components: { components: {
MuiPaper: { MuiCssBaseline: {
styleOverrides: { styleOverrides: {
root: ({ theme }) => ({ body: {
backgroundImage: "none", backgroundColor: t.paper,
border: `1px solid ${alpha(theme.palette.text.primary, 0.08)}`, // Tabular figures everywhere: numbers that change every second must
}), // not make the layout twitch.
fontVariantNumeric: "tabular-nums",
WebkitFontSmoothing: "antialiased",
},
"::selection": { background: t.emberSoft },
}, },
}, },
MuiCard: { MuiPaper: {
defaultProps: { elevation: 0 }, defaultProps: { elevation: 0 },
styleOverrides: { root: { borderRadius: 20 } }, styleOverrides: {
root: { backgroundImage: "none", border: `1px solid ${t.rule}`, borderRadius: 12 },
}, },
},
MuiCard: { defaultProps: { elevation: 0 }, styleOverrides: { root: { borderRadius: 12 } } },
MuiButton: { MuiButton: {
styleOverrides: { root: { borderRadius: 999, paddingInline: 20 } }, defaultProps: { disableElevation: true, disableRipple: true },
styleOverrides: {
root: { borderRadius: 8, paddingInline: 14, minHeight: 36 },
outlined: { borderColor: t.ruleStrong, "&:hover": { borderColor: t.ink, background: "transparent" } },
contained: { "&:hover": { filter: "brightness(1.06)" } },
text: { "&:hover": { background: t.emberSoft } },
}, },
MuiChip: { styleOverrides: { root: { borderRadius: 8 } } }, },
MuiIconButton: { defaultProps: { disableRipple: true }, styleOverrides: { root: { borderRadius: 8 } } },
MuiChip: {
styleOverrides: {
root: { borderRadius: 6, fontWeight: 500, fontSize: "0.74rem", height: 24 },
outlined: { borderColor: t.rule },
},
},
MuiDivider: { styleOverrides: { root: { borderColor: t.rule } } },
MuiTextField: { defaultProps: { size: "small" } }, MuiTextField: { defaultProps: { size: "small" } },
MuiTooltip: { defaultProps: { arrow: true } }, MuiOutlinedInput: {
MuiAppBar: { styleOverrides: {
defaultProps: { elevation: 0, color: "transparent" }, root: { borderRadius: 8, background: mode === "dark" ? t.surfaceSunken : t.surface },
styleOverrides: { root: { backdropFilter: "blur(12px)" } }, notchedOutline: { borderColor: t.rule },
},
},
MuiTooltip: {
defaultProps: { arrow: false },
styleOverrides: {
tooltip: {
background: t.ink,
color: t.paper,
fontSize: "0.75rem",
borderRadius: 6,
padding: "6px 9px",
},
},
},
MuiSwitch: { defaultProps: { size: "small" } },
MuiTab: { styleOverrides: { root: { textTransform: "none", fontWeight: 500, minHeight: 40 } } },
MuiTabs: { styleOverrides: { indicator: { height: 1.5, background: t.ink } } },
MuiLinearProgress: {
styleOverrides: {
root: { height: 3, borderRadius: 0, background: t.rule },
bar: { borderRadius: 0 },
},
},
MuiAlert: { styleOverrides: { root: { borderRadius: 10, border: `1px solid ${t.rule}` } } },
MuiAccordion: {
styleOverrides: {
root: { border: `1px solid ${t.rule}`, borderRadius: 12, "&::before": { display: "none" } },
},
}, },
}, },
}); });
+67
View File
@@ -0,0 +1,67 @@
/**
* The design tokens, in one place.
*
* Kadence is meant to read like a well-set page rather than a dashboard: warm
* paper, ink-black type, hairline rules instead of boxes and shadows, one warm
* accent. The desktop app's QML `Theme` singleton carries the same values, so
* the two clients are the same product.
*
* Chart colours are deliberately *not* the brand accent: they come from the
* validated data palette (see `viz.ts`), because a chart has to stay readable
* for colour-blind readers in both themes.
*/
export interface Tokens {
paper: string;
surface: string;
surfaceSunken: string;
ink: string;
inkMuted: string;
inkFaint: string;
rule: string;
ruleStrong: string;
ember: string;
emberSoft: string;
pine: string;
amber: string;
clay: string;
}
export const light: Tokens = {
paper: "#FAF7F2",
surface: "#FFFFFF",
surfaceSunken: "#F3EFE8",
ink: "#17150F",
inkMuted: "#6E675B",
inkFaint: "#9A9388",
rule: "rgba(23, 21, 15, 0.12)",
ruleStrong: "rgba(23, 21, 15, 0.26)",
ember: "#B4441E",
emberSoft: "rgba(180, 68, 30, 0.10)",
pine: "#2F6B55",
amber: "#8A6200",
clay: "#A33A2E",
};
export const dark: Tokens = {
paper: "#121110",
surface: "#1A1917",
surfaceSunken: "#201E1B",
ink: "#F4F1EA",
inkMuted: "#A79F93",
inkFaint: "#776F64",
rule: "rgba(244, 241, 234, 0.14)",
ruleStrong: "rgba(244, 241, 234, 0.30)",
ember: "#E0724A",
emberSoft: "rgba(224, 114, 74, 0.14)",
pine: "#6FB394",
amber: "#D9A441",
clay: "#E07A6E",
};
export const DISPLAY_FONT = '"Fraunces", "Iowan Old Style", Georgia, serif';
export const SANS_FONT = '"Inter", system-ui, -apple-system, "Segoe UI", sans-serif';
export function tokensFor(mode: "light" | "dark"): Tokens {
return mode === "dark" ? dark : light;
}