From f9d9e00057a6a00887b16b18d3831a36b7837f44 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 27 Apr 2026 11:18:16 -0700 Subject: [PATCH] =?UTF-8?q?C0:=20blumeops-tasks=20=E2=80=94=20show=20due?= =?UTF-8?q?=20offset=20+=20recurrence,=20sort=20by=20overdue-ness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../+blumeops-tasks-due-recurrence.feature.md | 1 + mise-tasks/blumeops-tasks | 50 ++++++++++++++++++- 2 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 docs/changelog.d/+blumeops-tasks-due-recurrence.feature.md diff --git a/docs/changelog.d/+blumeops-tasks-due-recurrence.feature.md b/docs/changelog.d/+blumeops-tasks-due-recurrence.feature.md new file mode 100644 index 0000000..3d00e1c --- /dev/null +++ b/docs/changelog.d/+blumeops-tasks-due-recurrence.feature.md @@ -0,0 +1 @@ +`blumeops-tasks` now annotates each task with a signed `due:±N` offset (or `due:today`) and a `↻ ` marker for recurring tasks, and sorts by overdue-ness (most overdue first, no-due-date last) with priority as tiebreaker. diff --git a/mise-tasks/blumeops-tasks b/mise-tasks/blumeops-tasks index 333178e..1c41dea 100755 --- a/mise-tasks/blumeops-tasks +++ b/mise-tasks/blumeops-tasks @@ -101,9 +101,45 @@ def is_due(task: dict) -> bool: return due_date <= date.today() +def days_until_due(task: dict) -> int | None: + """Return signed days offset from today, or None if no due date. + + Negative = days remaining before due (e.g. -2 = due in 2 days). + Positive = days past due (overdue). Zero = due today. + """ + due = task.get("due") + if due is None: + return None + due_date = date.fromisoformat(due["date"][:10]) + return (date.today() - due_date).days + + +def recurrence_string(task: dict) -> str | None: + """Return the Todoist natural-language recurrence string, or None. + + Todoist's REST API doesn't expose RFC 5545 RRULE; the natural-language + `due.string` (e.g. "every monday", "every 2 weeks") is the terse form. + """ + due = task.get("due") + if due is None or not due.get("is_recurring"): + return None + return due.get("string") + + def sort_tasks(tasks: list[dict]) -> list[dict]: - """Sort tasks by custom priority order: p1, p2, p4, p3.""" - return sorted(tasks, key=lambda t: PRIORITY_SORT_ORDER.get(t["priority"], 5)) + """Sort by overdue-ness, then priority. + + Most overdue first (largest +N); tasks with no due date come last. + Within a given day, tiebreaker is the custom priority order p1, p2, p4, p3. + """ + + def key(task: dict) -> tuple[int, int, int]: + days = days_until_due(task) + no_due = 1 if days is None else 0 + days_key = -(days if days is not None else 0) # descending + return (no_due, days_key, PRIORITY_SORT_ORDER.get(task["priority"], 5)) + + return sorted(tasks, key=key) def main() -> int: @@ -149,6 +185,16 @@ def main() -> int: header = Text() header.append(f"[{label}]", style="bold") header.append(f" {content}") + + meta = [] + days = days_until_due(task) + if days is not None: + meta.append(f"due:{days:+d}" if days != 0 else "due:today") + recurrence = recurrence_string(task) + if recurrence: + meta.append(f"↻ {recurrence}") + if meta: + header.append(f" ({', '.join(meta)})", style="dim") console.print(header) # Description indented (escape rich markup to preserve brackets)