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:
84 files changed
+4501
-2682
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 <number> — 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 <номер> — закрыть задачу 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,
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in new issue
Block a user