"""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 collections.abc import Iterable from datetime import date from typing import Any 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()