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
+4501 -2682

No files matched your search

+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 fastapi import APIRouter, HTTPException, Query
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.services import tasks_service
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:
if record.due_date is None:
return "someday"
if record.due_date < today:
if record.due_date is not None and record.due_date < today:
return "overdue"
if record.due_date == 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"
@@ -99,15 +106,6 @@ def update(task_id: int, body: TaskUpdateRequest, session: DbSession, settings:
return task_dict(record, local_date(settings.timezone))
@router.post("/{task_id}/done")
def mark_done(task_id: int, body: TaskDoneRequest, session: DbSession, settings: Settings) -> dict:
try:
record, warning = tasks_service.complete_task(session, settings, task_id, done=body.done)
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return {"task": task_dict(record, local_date(settings.timezone)), "warning": warning}
@router.delete("/{task_id}")
def delete(task_id: int, session: DbSession) -> dict:
tasks_service.delete_task(session, task_id)
-4
View File
@@ -50,10 +50,6 @@ class TaskUpdateRequest(BaseModel):
pinned: bool | None = None
class TaskDoneRequest(BaseModel):
done: bool = True
class BlockEventRequest(BaseModel):
kind: Literal["domain", "app"] = "domain"
target: str = Field(min_length=1, max_length=255)
+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
import os
from functools import lru_cache
from pydantic import Field
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):
model_config = SettingsConfigDict(
env_file=(".env", "../.env"),
env_file=_DOTENV_FILES,
env_file_encoding="utf-8",
extra="ignore",
)
+12 -5
View File
@@ -90,9 +90,12 @@ class PomodoroSession(Base):
class TaskRecord(Base):
"""A todo item, wherever it came from.
External tasks are cached here on every refresh so the UI is instant and the
daily report still works when Jira is down. Locally created tasks live here
too, with `source = "local"` — they are the only ones we own outright.
External tasks are mirrored here on every refresh so the UI is instant and
the daily report still works when Jira is down. Locally created tasks live
here too, with `source = "local"`.
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"
@@ -119,8 +122,12 @@ class TaskRecord(Base):
is_done: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
completed_date: Mapped[date | None] = mapped_column(Date, index=True)
completed_here: Mapped[bool] = mapped_column(Boolean, default=False)
"""True when *this app* closed it — what the daily report counts."""
"""The day the task was seen to close, in your timezone.
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)
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 datetime import date, datetime
from fnmatch import fnmatch
from typing import Any
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
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]:
"""Every hostname the desktop app should point at the redirect IP.
A rule like `youtube.com` is meant as "that site", so `www.` and the common
subdomains come along; anything in `allowed_domains` is removed again, which
is how `youtube.com` blocked + `music.youtube.com` allowed works.
`youtube.com` means "that site", so `www.` and `m.` come along; masks are
expanded as described above; and anything the allow list covers — itself
maskable — is taken back out, which is how `*.google.com` blocked plus
`*.docs.google.com` allowed works.
"""
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] = []
for raw in blocker.blocked_domains:
domain = raw.strip().lower().lstrip(".")
if not domain or domain in allowed:
continue
for candidate in (domain, f"www.{domain}", f"m.{domain}"):
if candidate not in allowed and candidate not in hosts:
hosts.append(candidate)
expanded, _ = expand_entry(raw, subdomains)
for host in expanded:
if host not in hosts and not is_allowed(host, allow_masks):
hosts.append(host)
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:
now_time = local_time(settings.timezone, moment)
weekday = (moment or datetime.now()).weekday()
@@ -86,6 +133,7 @@ def policy(session: Session, settings: AppSettings) -> dict[str, Any]:
"redirect_ip": blocker.redirect_ip,
"domains": expand_domains(settings) if active else [],
"apps": [app.strip() for app in blocker.blocked_apps if app.strip()] if active else [],
"unexpandable": unexpandable_masks(settings),
"kill_apps": blocker.kill_blocked_apps,
"notify_on_block": blocker.notify_on_block,
"poll_seconds": blocker.app_poll_seconds,
@@ -148,10 +196,10 @@ def events_for_day(session: Session, settings: AppSettings, day: date | None = N
DEFAULT_DOMAINS = [
"youtube.com",
"*.youtube.com",
"twitter.com",
"x.com",
"instagram.com",
"*.instagram.com",
"tiktok.com",
"reddit.com",
"facebook.com",
@@ -162,4 +210,4 @@ DEFAULT_DOMAINS = [
"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,
note: str | None = None,
) -> 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)
if running is not None:
_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,
},
"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": {
"overdue": len(buckets["overdue"]),
"today": len(buckets["today"]),
"active": len(buckets["active"]),
"upcoming": len(buckets["upcoming"]),
"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:
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]:
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 "Due today", buckets["today"], "🟡")
section("Было бы неплохо начать" if ru else "Would be nice to start", buckets["active"], "🟢")
section("Скоро" if ru else "Coming up", buckets["upcoming"][:5], "🔵")
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)
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(
session.scalars(
select(TaskRecord).where(
TaskRecord.completed_date == day, TaskRecord.completed_here.is_(True)
)
)
session.scalars(select(TaskRecord).where(TaskRecord.completed_date == day))
)
blocks = list(
@@ -272,7 +270,7 @@ def lifetime(session: Session, settings: AppSettings) -> dict[str, Any]:
)
).one()
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)))
return {
+48 -69
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
buys three things a live call cannot: the UI stays instant, the daily report
still works when a source is down, and "I finished this" is recorded locally
even if the write-back to Jira fails.
buys two things a live call cannot: the UI stays instant, and the daily report
still works when a source is down.
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
@@ -19,7 +24,7 @@ from sqlalchemy.orm import Session
from app.models import TaskRecord, utcnow
from app.settings_schema import AppSettings
from app.sources.base import SourceResult, TodoItem
from app.sources.registry import SOURCE_LABELS, complete_remote, fetch_all
from app.sources.registry import SOURCE_LABELS, fetch_all
from app.timeutil import local_date
log = logging.getLogger(__name__)
@@ -30,7 +35,12 @@ LOCAL_SOURCE = "local"
# --------------------------------------------------------------------------
# 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(
select(TaskRecord).where(
TaskRecord.source == item.source,
@@ -54,18 +64,24 @@ def _upsert(session: Session, item: TodoItem) -> TaskRecord:
record.last_seen_at = utcnow()
record.stale = False
if item.is_done and not record.is_done:
# Closed in the source, not here: it counts as done but not as "mine".
record.is_done = True
record.completed_at = record.completed_at or utcnow()
_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.completed_at = record.completed_at or utcnow()
record.completed_date = record.completed_date or today
def refresh_tasks(session: Session, settings: AppSettings) -> dict[str, Any]:
"""Pull every enabled source and reconcile the mirror.
Tasks a source stops returning are flagged `stale` rather than deleted: the
history behind "you finished 6 things today" has to survive a source that
quietly drops an item.
A task a *healthy* source stops returning has left your plate — Jira drops
it from "open issues" the moment you move it to Done — so it is recorded as
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)
today = local_date(settings.timezone)
@@ -86,7 +102,7 @@ def refresh_tasks(session: Session, settings: AppSettings) -> dict[str, Any]:
seen_ids: set[str] = set()
for item in result.items:
_upsert(session, item)
_upsert(session, item, today)
seen_ids.add(item.external_id)
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)
)
).all()
closed = 0
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
continue
_mark_closed(record, today)
closed += 1
summary.append(
{
@@ -105,6 +127,7 @@ def refresh_tasks(session: Session, settings: AppSettings) -> dict[str, Any]:
"ok": True,
"error": None,
"count": len(result.items),
"closed": closed,
}
)
@@ -114,6 +137,7 @@ def refresh_tasks(session: Session, settings: AppSettings) -> dict[str, Any]:
"date": today.isoformat(),
"sources": 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]] = {
"overdue": [],
"today": [],
"active": [],
"upcoming": [],
"someday": [],
}
for record in records:
if record.due_date is None:
buckets["someday"].append(record)
elif record.due_date < today:
if record.due_date is not None and record.due_date < today:
buckets["overdue"].append(record)
elif record.due_date == today:
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:
buckets["upcoming"].append(record)
return buckets
def completed_on(session: Session, day: date, *, only_here: bool = True) -> list[TaskRecord]:
query = select(TaskRecord).where(TaskRecord.completed_date == day)
if only_here:
query = query.where(TaskRecord.completed_here.is_(True))
return list(session.scalars(query))
def completed_on(session: Session, day: date) -> list[TaskRecord]:
"""Tasks seen to close on `day`, whichever tool they were closed in."""
return list(session.scalars(select(TaskRecord).where(TaskRecord.completed_date == day)))
# --------------------------------------------------------------------------
@@ -209,53 +235,6 @@ def create_local_task(
return record
def complete_task(
session: Session, settings: AppSettings, task_id: int, *, done: bool = True
) -> tuple[TaskRecord, str | None]:
"""Mark a task done (or undone) here, and try to mirror it to its source.
Returns the record plus a warning when the write-back failed — the local
state still changes, because the alternative is losing the fact that you
finished something because Jira hiccupped.
"""
record = session.get(TaskRecord, task_id)
if record is None:
raise LookupError(f"no task {task_id}")
warning: str | None = None
if done:
record.is_done = True
record.completed_here = True
record.completed_at = utcnow()
record.completed_date = local_date(settings.timezone)
if record.source != LOCAL_SOURCE:
item = TodoItem(
source=record.source,
external_id=record.external_id,
title=record.title,
raw=record.raw or {},
)
wrote, error = complete_remote(settings, item)
if error:
warning = (
f"Marked done here, but {SOURCE_LABELS.get(record.source, record.source)}"
f" did not accept it: {error}"
)
elif not wrote:
warning = (
f"Marked done here only — write-back is off for "
f"{SOURCE_LABELS.get(record.source, record.source)}."
)
else:
record.is_done = False
record.completed_here = False
record.completed_at = None
record.completed_date = None
session.commit()
return record, warning
def update_task(session: Session, task_id: int, **changes: Any) -> TaskRecord:
record = session.get(TaskRecord, task_id)
if record is None:
+1 -26
View File
@@ -41,7 +41,6 @@ HELP_EN = """<b>Kadence</b>
/pause · /resume — hold the clock
/status — what is running right now
/today — your todo list, grouped
/done &lt;number&gt; — close task N from /today
/report — today's report, now
/week — the weekly review
/streak — streak and all-time totals
@@ -56,7 +55,6 @@ HELP_RU = """<b>Kadence</b>
/pause · /resume — пауза и продолжение
/status — что идёт сейчас
/today — список задач по группам
/done &lt;номер&gt; — закрыть задачу N из /today
/report — отчёт за сегодня
/week — итоги недели
/streak — серия и общая статистика
@@ -171,7 +169,7 @@ def _cmd_resume(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"
today = local_date(settings.timezone)
tasks = tasks_service.list_tasks(session)
@@ -180,7 +178,6 @@ def _cmd_today(session, settings: AppSettings, _args: str) -> str:
if not ordered:
return "✨ Задач нет." if ru else "✨ Nothing on the list."
_INDEX[settings.notifications.telegram.chat_id] = [task.id for task in ordered]
limit = settings.notifications.report.max_tasks_listed
lines = [f"<b>{'📋 Задачи' if ru else '📋 Your tasks'}</b>"]
marks = {"overdue": "🔴", "today": "🟡", "upcoming": "🔵", "someday": "⚪"}
@@ -196,30 +193,9 @@ def _cmd_today(session, settings: AppSettings, _args: str) -> str:
)
if position > limit:
lines.append(("… и ещё " if ru else "… and ") + f"{position - limit}")
lines.append("")
lines.append("<i>/done N</i>")
return "\n".join(lines)
_INDEX: dict[str, list[int]] = {}
def _cmd_done(session, settings: AppSettings, args: str) -> str:
ru = settings.notifications.report.language == "ru"
chat_id = settings.notifications.telegram.chat_id
index = _INDEX.get(chat_id)
if not args.strip().isdigit():
return "Укажи номер: /done 3" if ru else "Give me a number: /done 3"
if not index:
return "Сначала /today." if ru else "Run /today first."
position = int(args.strip())
if not 1 <= position <= len(index):
return "Нет такого номера." if ru else "No task with that number."
record, warning = tasks_service.complete_task(session, settings, index[position - 1])
text = f"✅ {report._esc(record.title)}"
return text + (f"\n⚠️ {report._esc(warning)}" if warning else "")
def _cmd_report(session, settings: AppSettings, _args: str) -> str:
today = local_date(settings.timezone)
_, payload = report.get_or_build_daily(session, settings, today, rebuild=True)
@@ -283,7 +259,6 @@ COMMANDS: dict[str, Callable] = {
"resume": _cmd_resume,
"today": _cmd_today,
"tasks": _cmd_today,
"done": _cmd_done,
"report": _cmd_report,
"week": _cmd_week,
"streak": _cmd_streak,
+16 -13
View File
@@ -49,6 +49,9 @@ class PomodoroSettings(BaseModel):
alarm_sound: bool = True
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
keep_on_top_during_work: bool = False
"""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)
writeback_done: bool = True
"""Completing a Jira task here transitions the issue to Done in Jira."""
done_transition_name: str = "Done"
"""Preferred transition name when writing back; falls back to any 'done' one."""
class GoogleTasksSourceSettings(BaseModel):
"""Google Tasks, via an OAuth refresh token (see jira-helper's README)."""
@@ -96,8 +93,6 @@ class GoogleTasksSourceSettings(BaseModel):
include_undated: bool = True
"""Keep tasks with no due date (jira-helper drops them; here they are todos)."""
writeback_done: bool = True
class TodoistSourceSettings(BaseModel):
"""Todoist, via a personal API token."""
@@ -110,8 +105,6 @@ class TodoistSourceSettings(BaseModel):
filter_query: str = ""
"""Override the server-side filter. Empty = all open tasks."""
writeback_done: bool = True
class LaundrySourceSettings(BaseModel):
"""Campus laundry bookings — not a todo, but a thing that owns your time."""
@@ -157,13 +150,23 @@ class BlockerSettings(BaseModel):
block_during_breaks: bool = False
blocked_domains: list[str] = Field(default_factory=list)
"""Bare hostnames, e.g. `youtube.com`. `www.` and subdomains are added for you."""
"""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)
"""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)
"""Process names, e.g. `steam`, `discord`, `Telegram`."""
"""Process names or masks: `steam`, `*discord*`, `telegram-*`."""
kill_blocked_apps: bool = True
"""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]:
"""Every open todo this source knows about."""
def complete(self, item: TodoItem) -> bool:
"""Close the task in the external system. False = not supported."""
def check(self) -> str:
"""Verify credentials; return a short human description of the account."""
-19
View File
@@ -142,25 +142,6 @@ class GoogleTasksSource:
def check(self) -> str:
return f"{len(self._task_lists())} Google task list(s) visible"
def complete(self, item: TodoItem) -> bool:
"""PATCH the task's status — Google Tasks has no dedicated close verb."""
if not self._config.writeback_done:
return False
list_id, _, task_id = item.external_id.partition(":")
if not (list_id and task_id):
raise SourceError(f"Unusable Google task id: {item.external_id!r}")
try:
resp = self._client.patch(
f"{_API_BASE}/lists/{list_id}/tasks/{task_id}",
json={"status": "completed"},
headers={"Authorization": f"Bearer {self._token()}"},
)
except httpx.HTTPError as exc:
raise SourceError(f"Google is unreachable: {exc}") from exc
if not resp.is_success:
raise SourceError(f"Google could not complete the task: {resp.status_code}")
return True
def close(self) -> None:
self._client.close()
+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
something to start a pomodoro on even when nothing is due.
Writing back is new here: finishing a Jira-backed pomodoro task transitions the
issue to Done, so the app is not a second place to keep your task list in sync.
Nothing is written back: you close an issue in Jira, and the next refresh
notices it has left the "open issues" query. One owner per task.
"""
from __future__ import annotations
@@ -110,45 +110,6 @@ class JiraSource:
me = self._request("GET", "/rest/api/3/myself") or {}
return f"{me.get('displayName', 'unknown')} on {self._base_url}"
def complete(self, item: TodoItem) -> bool:
"""Transition the issue into a Done-category status.
Jira has no generic "close" verb: every project defines its own workflow,
so we ask the issue which transitions it offers and pick the one named in
the settings, else any transition landing in the Done category.
"""
if not self._config.writeback_done:
return False
key = item.external_id
data = self._request("GET", f"/rest/api/3/issue/{key}/transitions") or {}
transitions = data.get("transitions", [])
wanted = self._config.done_transition_name.strip().lower()
chosen = next(
(t for t in transitions if (t.get("name") or "").lower() == wanted),
None,
) or next(
(
t
for t in transitions
if ((t.get("to") or {}).get("statusCategory") or {}).get("key") == "done"
),
None,
)
if chosen is None:
raise SourceError(
f"{key} has no transition to a Done status"
+ (f" named {self._config.done_transition_name!r}" if wanted else "")
+ ". Available: "
+ ", ".join(t.get("name", "?") for t in transitions)
)
self._request(
"POST",
f"/rest/api/3/issue/{key}/transitions",
json={"transition": {"id": chosen["id"]}},
)
return True
def close(self) -> None:
self._client.close()
-3
View File
@@ -133,9 +133,6 @@ class LaundrySource:
self._check_session()
return f"signed in at {self._base_url}"
def complete(self, item: TodoItem) -> bool:
return False # the washing machine finishes on its own
def close(self) -> None:
self._client.close()
-15
View File
@@ -93,21 +93,6 @@ def fetch_all(settings: AppSettings) -> list[SourceResult]:
_close(source)
def complete_remote(settings: AppSettings, item: TodoItem) -> tuple[bool, str | None]:
"""Close a task in its own system. Returns `(did_write, error)`."""
sources = build_sources(settings)
source = sources.get(item.source)
if source is None:
return False, None
try:
return bool(source.complete(item)), None
except SourceError as exc:
return False, str(exc)
finally:
for built in sources.values():
_close(built)
def check_source(settings: AppSettings, name: str) -> tuple[bool, str]:
"""Verify one source's credentials — what the settings page's Test button calls."""
sources = build_sources(settings)
-12
View File
@@ -117,18 +117,6 @@ class TodoistSource:
projects = self._projects()
return f"{len(projects)} Todoist project(s) visible"
def complete(self, item: TodoItem) -> bool:
"""`POST /tasks/{id}/close` — the same verb the apps' checkbox uses."""
if not self._config.writeback_done:
return False
try:
resp = self._client.post(f"/tasks/{item.external_id}/close")
except httpx.HTTPError as exc:
raise SourceError(f"Todoist is unreachable: {exc}") from exc
if not resp.is_success:
raise SourceError(f"Todoist could not close the task: {resp.status_code}")
return True
def close(self) -> None:
self._client.close()