Files
kadence/backend/app/services/tasks_service.py
T
timka123 77b26757bf 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.
2026-10-18 23:57:36 +03:00

255 lines
8.4 KiB
Python

"""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 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
import logging
from datetime import date
from typing import Any, Iterable
from uuid import uuid4
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import TaskRecord, utcnow
from app.settings_schema import AppSettings
from app.sources.base import SourceResult, TodoItem
from app.sources.registry import SOURCE_LABELS, fetch_all
from app.timeutil import local_date
log = logging.getLogger(__name__)
LOCAL_SOURCE = "local"
# --------------------------------------------------------------------------
# Refresh
# --------------------------------------------------------------------------
# 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,
TaskRecord.external_id == item.external_id,
)
)
if record is None:
record = TaskRecord(source=item.source, external_id=item.external_id)
session.add(record)
record.title = item.title
record.description = item.description
record.url = item.url
record.project = item.project
record.status = item.status
record.priority = item.priority
record.due_date = item.due_date
record.start_date = item.start_date
record.labels = item.labels
record.raw = item.raw
record.last_seen_at = utcnow()
record.stale = False
if item.is_done and not record.is_done:
_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.
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)
summary: list[dict[str, Any]] = []
for result in results:
if not result.ok:
summary.append(
{
"source": result.source,
"label": SOURCE_LABELS.get(result.source, result.source),
"ok": False,
"error": result.error,
"count": 0,
}
)
continue
seen_ids: set[str] = set()
for item in result.items:
_upsert(session, item, today)
seen_ids.add(item.external_id)
existing = session.scalars(
select(TaskRecord).where(
TaskRecord.source == result.source, TaskRecord.is_done.is_(False)
)
).all()
closed = 0
for record in existing:
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(
{
"source": result.source,
"label": SOURCE_LABELS.get(result.source, result.source),
"ok": True,
"error": None,
"count": len(result.items),
"closed": closed,
}
)
session.commit()
return {
"refreshed_at": utcnow().isoformat(),
"date": today.isoformat(),
"sources": summary,
"total": sum(entry["count"] for entry in summary),
"closed": sum(entry.get("closed", 0) for entry in summary),
}
# --------------------------------------------------------------------------
# Queries
# --------------------------------------------------------------------------
def list_tasks(
session: Session,
*,
include_done: bool = False,
include_stale: bool = False,
sources: Iterable[str] | None = None,
) -> list[TaskRecord]:
query = select(TaskRecord)
if not include_done:
query = query.where(TaskRecord.is_done.is_(False))
if not include_stale:
query = query.where(TaskRecord.stale.is_(False))
if sources:
query = query.where(TaskRecord.source.in_(list(sources)))
records = list(session.scalars(query))
records.sort(key=_sort_key)
return records
def _sort_key(record: TaskRecord) -> tuple:
"""Pinned first, then by deadline (undated last), then by source and title."""
return (
not record.pinned,
record.due_date is None,
record.due_date or date.max,
record.source,
(record.title or "").lower(),
)
def bucket_tasks(records: Iterable[TaskRecord], today: date) -> dict[str, list[TaskRecord]]:
"""Group a task list the way the UI and the report both want it."""
buckets: dict[str, list[TaskRecord]] = {
"overdue": [],
"today": [],
"active": [],
"upcoming": [],
"someday": [],
}
for record in records:
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) -> 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)))
# --------------------------------------------------------------------------
# Mutations
# --------------------------------------------------------------------------
def create_local_task(
session: Session,
*,
title: str,
description: str | None = None,
due_date: date | None = None,
priority: str | None = None,
project: str | None = None,
estimated_pomodoros: int | None = None,
) -> TaskRecord:
"""A task that lives only here — the quick "and this too" you type at 9am."""
# A random id, not a timestamp: two tasks added in the same millisecond
# (a double-clicked Add button) would otherwise collide on the unique key.
external_id = f"local-{uuid4().hex}"
record = TaskRecord(
source=LOCAL_SOURCE,
external_id=external_id,
title=title.strip(),
description=description,
due_date=due_date,
priority=priority,
project=project or "My list",
estimated_pomodoros=estimated_pomodoros,
)
session.add(record)
session.commit()
return record
def update_task(session: Session, task_id: int, **changes: Any) -> TaskRecord:
record = session.get(TaskRecord, task_id)
if record is None:
raise LookupError(f"no task {task_id}")
for key, value in changes.items():
if value is not None and hasattr(record, key):
setattr(record, key, value)
session.commit()
return record
def delete_task(session: Session, task_id: int) -> None:
record = session.get(TaskRecord, task_id)
if record is None:
return
session.delete(record)
session.commit()