"""The task list: refreshing it from every source, and acting on 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. """ 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, complete_remote, fetch_all from app.timeutil import local_date log = logging.getLogger(__name__) LOCAL_SOURCE = "local" # -------------------------------------------------------------------------- # Refresh # -------------------------------------------------------------------------- def _upsert(session: Session, item: TodoItem) -> 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: # 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() return record 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. """ 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) seen_ids.add(item.external_id) existing = session.scalars( select(TaskRecord).where( TaskRecord.source == result.source, TaskRecord.is_done.is_(False) ) ).all() for record in existing: if record.external_id not in seen_ids: record.stale = True summary.append( { "source": result.source, "label": SOURCE_LABELS.get(result.source, result.source), "ok": True, "error": None, "count": len(result.items), } ) session.commit() return { "refreshed_at": utcnow().isoformat(), "date": today.isoformat(), "sources": summary, "total": sum(entry["count"] 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": [], "upcoming": [], "someday": [], } for record in records: if record.due_date is None: buckets["someday"].append(record) elif record.due_date < today: buckets["overdue"].append(record) elif record.due_date == today: buckets["today"].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)) # -------------------------------------------------------------------------- # 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 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: 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()