"""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, TaskUpdateRequest from app.models import TaskRecord from app.services import tasks_service from app.sources.registry import SOURCE_LABELS, check_source from app.timeutil import ensure_utc, local_date router = APIRouter(prefix="/api/tasks", tags=["tasks"]) def task_dict(record: TaskRecord, today) -> dict: return { "id": record.id, "source": record.source, "source_label": SOURCE_LABELS.get(record.source, record.source), "external_id": record.external_id, "title": record.title, "description": record.description, "url": record.url, "project": record.project, "status": record.status, "priority": record.priority, "due_date": record.due_date.isoformat() if record.due_date else None, "start_date": record.start_date.isoformat() if record.start_date else None, "labels": record.labels or [], "is_done": record.is_done, "completed_at": ensure_utc(record.completed_at).isoformat() if record.completed_at else None, "pinned": record.pinned, "stale": record.stale, "estimated_pomodoros": record.estimated_pomodoros, "days_overdue": (today - record.due_date).days if record.due_date and record.due_date < today else 0, "bucket": _bucket_of(record, today), } def _bucket_of(record: TaskRecord, today) -> str: 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" @router.get("") def list_tasks( session: DbSession, settings: Settings, include_done: bool = Query(default=False), include_stale: bool = Query(default=False), source: list[str] | None = Query(default=None), ) -> dict: today = local_date(settings.timezone) records = tasks_service.list_tasks( session, include_done=include_done, include_stale=include_stale, sources=source ) buckets = tasks_service.bucket_tasks(records, today) return { "date": today.isoformat(), "tasks": [task_dict(record, today) for record in records], "counts": {name: len(items) for name, items in buckets.items()}, "sources": sorted({record.source for record in records}), } @router.post("/refresh") def refresh(session: DbSession, settings: Settings) -> dict: """Pull every enabled source now. Failures come back per source, not as a 500.""" return tasks_service.refresh_tasks(session, settings) @router.post("") def create(body: TaskCreateRequest, session: DbSession, settings: Settings) -> dict: record = tasks_service.create_local_task( session, title=body.title, description=body.description, due_date=body.due_date, priority=body.priority, project=body.project, estimated_pomodoros=body.estimated_pomodoros, ) return task_dict(record, local_date(settings.timezone)) @router.patch("/{task_id}") def update(task_id: int, body: TaskUpdateRequest, session: DbSession, settings: Settings) -> dict: try: record = tasks_service.update_task(session, task_id, **body.model_dump(exclude_unset=True)) except LookupError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc return task_dict(record, local_date(settings.timezone)) @router.delete("/{task_id}") def delete(task_id: int, session: DbSession) -> dict: tasks_service.delete_task(session, task_id) return {"deleted": task_id} @router.post("/sources/{name}/test") def test_source(name: str, settings: Settings) -> dict: """What the settings page's "Test connection" button calls.""" ok, detail = check_source(settings, name) return {"source": name, "ok": ok, "detail": detail}