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

+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,