doingapp 0.5.1__tar.gz → 0.7.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {doingapp-0.5.1 → doingapp-0.7.0}/PKG-INFO +17 -3
- {doingapp-0.5.1 → doingapp-0.7.0}/README.md +16 -2
- {doingapp-0.5.1 → doingapp-0.7.0}/doing_cli/api.py +85 -7
- {doingapp-0.5.1 → doingapp-0.7.0}/doing_cli/main.py +206 -25
- {doingapp-0.5.1 → doingapp-0.7.0}/doingapp.egg-info/PKG-INFO +17 -3
- {doingapp-0.5.1 → doingapp-0.7.0}/pyproject.toml +1 -1
- {doingapp-0.5.1 → doingapp-0.7.0}/LICENSE +0 -0
- {doingapp-0.5.1 → doingapp-0.7.0}/doing_cli/__init__.py +0 -0
- {doingapp-0.5.1 → doingapp-0.7.0}/doing_cli/auth.py +0 -0
- {doingapp-0.5.1 → doingapp-0.7.0}/doingapp.egg-info/SOURCES.txt +0 -0
- {doingapp-0.5.1 → doingapp-0.7.0}/doingapp.egg-info/dependency_links.txt +0 -0
- {doingapp-0.5.1 → doingapp-0.7.0}/doingapp.egg-info/entry_points.txt +0 -0
- {doingapp-0.5.1 → doingapp-0.7.0}/doingapp.egg-info/requires.txt +0 -0
- {doingapp-0.5.1 → doingapp-0.7.0}/doingapp.egg-info/top_level.txt +0 -0
- {doingapp-0.5.1 → doingapp-0.7.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: doingapp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: A fast CLI task tracker — track what you're doing from the terminal.
|
|
5
5
|
Author-email: Parker Grimes <parker@altellus.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -73,13 +73,14 @@ doing apikey --key doing_xxxxx
|
|
|
73
73
|
| Command | Description |
|
|
74
74
|
|---------|-------------|
|
|
75
75
|
| `doing` | Show active tasks (same as `doing ls`) |
|
|
76
|
-
| `doing add <title>` | Add a task (`-n` notes, `-c` context ID) |
|
|
76
|
+
| `doing add <title>` | Add a task (`-n` notes, `-c` context ID, `-d "tomorrow 3pm"` due, `-r daily` repeat) |
|
|
77
77
|
| `doing get <id>` | Get a single task by ID |
|
|
78
78
|
| `doing done <id>` | Mark a task as done |
|
|
79
79
|
| `doing pause <id>` | Pause a task |
|
|
80
80
|
| `doing resume <id>` | Resume a paused task |
|
|
81
81
|
| `doing reopen <id>` | Reopen a completed task |
|
|
82
|
-
| `doing edit <id>` | Edit a task (`-t` title, `-n` notes, `-c` context, `--no-context`) |
|
|
82
|
+
| `doing edit <id>` | Edit a task (`-t` title, `-n` notes, `-c` context, `--no-context`, `-d` due, `--no-due`, `-r` repeat, `--no-repeat`) |
|
|
83
|
+
| `doing snooze <id> [10m\|1h\|3h\|1d\|morning]` | Push a due task back (default 10m) |
|
|
83
84
|
| `doing rm <id>` | Delete a task |
|
|
84
85
|
| `doing ls` | List active tasks (`-c` context ID) |
|
|
85
86
|
| `doing log` | Show tasks done today (`-d YYYY-MM-DD`, `-c` context ID) |
|
|
@@ -93,6 +94,19 @@ doing apikey --key doing_xxxxx
|
|
|
93
94
|
| `doing logout` | Log out |
|
|
94
95
|
| `doing apikey` | Save an API key (`-k` key) |
|
|
95
96
|
|
|
97
|
+
## Due dates and reminders
|
|
98
|
+
|
|
99
|
+
Give a task a due date and the Doing iOS app nags you every few minutes once it's overdue, until you mark it done or snooze it.
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
doing add "Call mom" -d "tomorrow 3pm"
|
|
103
|
+
doing add "Water plants" -d friday -r 3d # repeats 3 days after each completion
|
|
104
|
+
doing add "Standup notes" -d "9am" -r weekdays
|
|
105
|
+
doing snooze 12 1h
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`-d` accepts natural language: `in 20m`, `tonight`, `fri 9am`, `oct 30`. Dates without a time use your morning time (9:00 by default). `-r` accepts `daily`, `weekdays`, days like `mon,thu`, or `Nd` (N days after done).
|
|
109
|
+
|
|
96
110
|
## JSON output
|
|
97
111
|
|
|
98
112
|
Every command supports `--json` (or `-j`) for machine-readable output. This is useful for scripting and AI coding agents like Claude Code or Codex CLI.
|
|
@@ -48,13 +48,14 @@ doing apikey --key doing_xxxxx
|
|
|
48
48
|
| Command | Description |
|
|
49
49
|
|---------|-------------|
|
|
50
50
|
| `doing` | Show active tasks (same as `doing ls`) |
|
|
51
|
-
| `doing add <title>` | Add a task (`-n` notes, `-c` context ID) |
|
|
51
|
+
| `doing add <title>` | Add a task (`-n` notes, `-c` context ID, `-d "tomorrow 3pm"` due, `-r daily` repeat) |
|
|
52
52
|
| `doing get <id>` | Get a single task by ID |
|
|
53
53
|
| `doing done <id>` | Mark a task as done |
|
|
54
54
|
| `doing pause <id>` | Pause a task |
|
|
55
55
|
| `doing resume <id>` | Resume a paused task |
|
|
56
56
|
| `doing reopen <id>` | Reopen a completed task |
|
|
57
|
-
| `doing edit <id>` | Edit a task (`-t` title, `-n` notes, `-c` context, `--no-context`) |
|
|
57
|
+
| `doing edit <id>` | Edit a task (`-t` title, `-n` notes, `-c` context, `--no-context`, `-d` due, `--no-due`, `-r` repeat, `--no-repeat`) |
|
|
58
|
+
| `doing snooze <id> [10m\|1h\|3h\|1d\|morning]` | Push a due task back (default 10m) |
|
|
58
59
|
| `doing rm <id>` | Delete a task |
|
|
59
60
|
| `doing ls` | List active tasks (`-c` context ID) |
|
|
60
61
|
| `doing log` | Show tasks done today (`-d YYYY-MM-DD`, `-c` context ID) |
|
|
@@ -68,6 +69,19 @@ doing apikey --key doing_xxxxx
|
|
|
68
69
|
| `doing logout` | Log out |
|
|
69
70
|
| `doing apikey` | Save an API key (`-k` key) |
|
|
70
71
|
|
|
72
|
+
## Due dates and reminders
|
|
73
|
+
|
|
74
|
+
Give a task a due date and the Doing iOS app nags you every few minutes once it's overdue, until you mark it done or snooze it.
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
doing add "Call mom" -d "tomorrow 3pm"
|
|
78
|
+
doing add "Water plants" -d friday -r 3d # repeats 3 days after each completion
|
|
79
|
+
doing add "Standup notes" -d "9am" -r weekdays
|
|
80
|
+
doing snooze 12 1h
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`-d` accepts natural language: `in 20m`, `tonight`, `fri 9am`, `oct 30`. Dates without a time use your morning time (9:00 by default). `-r` accepts `daily`, `weekdays`, days like `mon,thu`, or `Nd` (N days after done).
|
|
84
|
+
|
|
71
85
|
## JSON output
|
|
72
86
|
|
|
73
87
|
Every command supports `--json` (or `-j`) for machine-readable output. This is useful for scripting and AI coding agents like Claude Code or Codex CLI.
|
|
@@ -28,9 +28,9 @@ def _client() -> httpx.Client:
|
|
|
28
28
|
return httpx.Client(base_url=BASE_URL, timeout=10.0, headers=headers)
|
|
29
29
|
|
|
30
30
|
|
|
31
|
-
def register(
|
|
31
|
+
def register(token: str, password: str) -> dict:
|
|
32
32
|
with httpx.Client(base_url=BASE_URL, timeout=10.0) as c:
|
|
33
|
-
r = c.post("/auth/register", json={"
|
|
33
|
+
r = c.post("/auth/register", json={"token": token, "password": password})
|
|
34
34
|
r.raise_for_status()
|
|
35
35
|
return r.json()
|
|
36
36
|
|
|
@@ -49,14 +49,19 @@ def get_me() -> dict:
|
|
|
49
49
|
return r.json()
|
|
50
50
|
|
|
51
51
|
|
|
52
|
-
def create_task(title: str, notes: str | None = None, context_id: int | None = None
|
|
52
|
+
def create_task(title: str, notes: str | None = None, context_id: int | None = None,
|
|
53
|
+
due: str | None = None, recurrence: str | None = None) -> dict:
|
|
53
54
|
payload: dict = {"title": title}
|
|
54
55
|
if notes is not None:
|
|
55
56
|
payload["notes"] = notes
|
|
56
57
|
if context_id is not None:
|
|
57
58
|
payload["context_id"] = context_id
|
|
59
|
+
if due is not None:
|
|
60
|
+
payload["due"] = due
|
|
61
|
+
if recurrence is not None:
|
|
62
|
+
payload["recurrence"] = recurrence
|
|
58
63
|
with _client() as c:
|
|
59
|
-
r = c.post("/tasks", json=payload)
|
|
64
|
+
r = c.post("/tasks", json=payload, params={"tz": _local_tz()})
|
|
60
65
|
r.raise_for_status()
|
|
61
66
|
return r.json()
|
|
62
67
|
|
|
@@ -85,7 +90,7 @@ def list_tasks(status: str | None = None, date: str | None = None,
|
|
|
85
90
|
|
|
86
91
|
def mark_done(task_id: int) -> dict:
|
|
87
92
|
with _client() as c:
|
|
88
|
-
r = c.post(f"/tasks/{task_id}/done")
|
|
93
|
+
r = c.post(f"/tasks/{task_id}/done", params={"tz": _local_tz()})
|
|
89
94
|
r.raise_for_status()
|
|
90
95
|
return r.json()
|
|
91
96
|
|
|
@@ -112,7 +117,9 @@ def resume_task(task_id: int) -> dict:
|
|
|
112
117
|
|
|
113
118
|
|
|
114
119
|
def update_task(task_id: int, title: str | None = None, notes: str | None = None,
|
|
115
|
-
context_id: int | None = None, clear_context: bool = False
|
|
120
|
+
context_id: int | None = None, clear_context: bool = False,
|
|
121
|
+
due: str | None = None, recurrence: str | None = None,
|
|
122
|
+
clear_due: bool = False, clear_recurrence: bool = False) -> dict:
|
|
116
123
|
payload: dict = {}
|
|
117
124
|
if title is not None:
|
|
118
125
|
payload["title"] = title
|
|
@@ -122,8 +129,24 @@ def update_task(task_id: int, title: str | None = None, notes: str | None = None
|
|
|
122
129
|
payload["clear_context"] = True
|
|
123
130
|
elif context_id is not None:
|
|
124
131
|
payload["context_id"] = context_id
|
|
132
|
+
if clear_due:
|
|
133
|
+
payload["clear_due"] = True
|
|
134
|
+
elif due is not None:
|
|
135
|
+
payload["due"] = due
|
|
136
|
+
if clear_recurrence:
|
|
137
|
+
payload["clear_recurrence"] = True
|
|
138
|
+
elif recurrence is not None:
|
|
139
|
+
payload["recurrence"] = recurrence
|
|
125
140
|
with _client() as c:
|
|
126
|
-
r = c.patch(f"/tasks/{task_id}", json=payload)
|
|
141
|
+
r = c.patch(f"/tasks/{task_id}", json=payload, params={"tz": _local_tz()})
|
|
142
|
+
r.raise_for_status()
|
|
143
|
+
return r.json()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def snooze_task(task_id: int, minutes: int | None = None, preset: str | None = None) -> dict:
|
|
147
|
+
payload: dict = {"minutes": minutes} if minutes is not None else {"preset": preset}
|
|
148
|
+
with _client() as c:
|
|
149
|
+
r = c.post(f"/tasks/{task_id}/snooze", json=payload, params={"tz": _local_tz()})
|
|
127
150
|
r.raise_for_status()
|
|
128
151
|
return r.json()
|
|
129
152
|
|
|
@@ -134,6 +157,29 @@ def delete_task(task_id: int) -> None:
|
|
|
134
157
|
r.raise_for_status()
|
|
135
158
|
|
|
136
159
|
|
|
160
|
+
def get_stats(context_id: int | None = None) -> dict:
|
|
161
|
+
params: dict = {"tz": _local_tz()}
|
|
162
|
+
if context_id is not None:
|
|
163
|
+
params["context_id"] = context_id
|
|
164
|
+
with _client() as c:
|
|
165
|
+
r = c.get("/stats", params=params)
|
|
166
|
+
r.raise_for_status()
|
|
167
|
+
return r.json()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def get_done_history(limit: int = 7, before: str | None = None,
|
|
171
|
+
context_id: int | None = None) -> dict:
|
|
172
|
+
params: dict = {"tz": _local_tz(), "limit": limit}
|
|
173
|
+
if before is not None:
|
|
174
|
+
params["before"] = before
|
|
175
|
+
if context_id is not None:
|
|
176
|
+
params["context_id"] = context_id
|
|
177
|
+
with _client() as c:
|
|
178
|
+
r = c.get("/done", params=params)
|
|
179
|
+
r.raise_for_status()
|
|
180
|
+
return r.json()
|
|
181
|
+
|
|
182
|
+
|
|
137
183
|
def get_today(context_id: int | None = None) -> dict:
|
|
138
184
|
params: dict = {"tz": _local_tz()}
|
|
139
185
|
if context_id is not None:
|
|
@@ -161,6 +207,38 @@ def create_context(name: str) -> dict:
|
|
|
161
207
|
return r.json()
|
|
162
208
|
|
|
163
209
|
|
|
210
|
+
def get_context(context_id: int) -> dict:
|
|
211
|
+
with _client() as c:
|
|
212
|
+
r = c.get(f"/contexts/{context_id}")
|
|
213
|
+
r.raise_for_status()
|
|
214
|
+
return r.json()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def update_context(context_id: int, name: str, icon: str | None = None) -> dict:
|
|
218
|
+
payload: dict = {"name": name}
|
|
219
|
+
if icon is not None:
|
|
220
|
+
payload["icon"] = icon
|
|
221
|
+
with _client() as c:
|
|
222
|
+
r = c.put(f"/contexts/{context_id}", json=payload)
|
|
223
|
+
r.raise_for_status()
|
|
224
|
+
return r.json()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def snooze_context(context_id: int) -> dict:
|
|
228
|
+
params: dict = {"tz": _local_tz()}
|
|
229
|
+
with _client() as c:
|
|
230
|
+
r = c.post(f"/contexts/{context_id}/snooze", params=params)
|
|
231
|
+
r.raise_for_status()
|
|
232
|
+
return r.json()
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def unsnooze_context(context_id: int) -> dict:
|
|
236
|
+
with _client() as c:
|
|
237
|
+
r = c.post(f"/contexts/{context_id}/unsnooze")
|
|
238
|
+
r.raise_for_status()
|
|
239
|
+
return r.json()
|
|
240
|
+
|
|
241
|
+
|
|
164
242
|
def delete_context(context_id: int) -> None:
|
|
165
243
|
with _client() as c:
|
|
166
244
|
r = c.delete(f"/contexts/{context_id}")
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import json
|
|
2
|
+
import re
|
|
2
3
|
import sys
|
|
4
|
+
from datetime import datetime, timezone
|
|
3
5
|
|
|
4
6
|
import httpx
|
|
5
7
|
import typer
|
|
@@ -99,6 +101,51 @@ def _validate_name(name: str) -> str:
|
|
|
99
101
|
return cleaned
|
|
100
102
|
|
|
101
103
|
|
|
104
|
+
WEEKDAYS = ("mon", "tue", "wed", "thu", "fri", "sat", "sun")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _normalize_repeat(value: str) -> str:
|
|
108
|
+
"""Accept friendly repeat specs: daily, weekdays, mon,thu, 3d, or the raw API form."""
|
|
109
|
+
v = value.strip().lower().replace(" ", "")
|
|
110
|
+
if v in ("daily", "weekdays") or v.startswith(("weekly:", "after:")):
|
|
111
|
+
return v
|
|
112
|
+
if all(day in WEEKDAYS for day in v.split(",")):
|
|
113
|
+
return f"weekly:{v}"
|
|
114
|
+
if m := re.fullmatch(r"(\d+)d(ays?)?", v):
|
|
115
|
+
return f"after:{m.group(1)}"
|
|
116
|
+
_validation_error(f"Invalid --repeat '{value}'. Use daily, weekdays, mon,thu, or 3d (days after done).")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _describe_repeat(rule: str) -> str:
|
|
120
|
+
if rule.startswith("weekly:"):
|
|
121
|
+
return "every " + ", ".join(d.title() for d in rule.split(":", 1)[1].split(","))
|
|
122
|
+
if rule.startswith("after:"):
|
|
123
|
+
n = rule.split(":", 1)[1]
|
|
124
|
+
return f"{n} day{'s' if n != '1' else ''} after done"
|
|
125
|
+
return rule
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _format_due(task: dict) -> str:
|
|
129
|
+
"""Rich-formatted due label, e.g. '[red]overdue · Mon 3:00 PM[/red]'."""
|
|
130
|
+
if not task.get("due_at"):
|
|
131
|
+
return ""
|
|
132
|
+
due = datetime.fromisoformat(task["due_at"])
|
|
133
|
+
if due.tzinfo is None:
|
|
134
|
+
due = due.replace(tzinfo=timezone.utc)
|
|
135
|
+
due = due.astimezone()
|
|
136
|
+
now = datetime.now().astimezone()
|
|
137
|
+
if task.get("due_all_day"):
|
|
138
|
+
when = due.strftime("%a %b %-d")
|
|
139
|
+
elif due.date() == now.date():
|
|
140
|
+
when = due.strftime("%-I:%M %p")
|
|
141
|
+
else:
|
|
142
|
+
when = due.strftime("%a %b %-d %-I:%M %p")
|
|
143
|
+
repeat = f" ↻ {_describe_repeat(task['recurrence'])}" if task.get("recurrence") else ""
|
|
144
|
+
if task.get("status") == "active" and due <= now:
|
|
145
|
+
return f" [bold red]⏰ overdue · {when}{repeat}[/bold red]"
|
|
146
|
+
return f" [cyan]⏰ {when}{repeat}[/cyan]"
|
|
147
|
+
|
|
148
|
+
|
|
102
149
|
def _print_notes(task: dict) -> None:
|
|
103
150
|
if task.get("notes"):
|
|
104
151
|
console.print(f" [dim]{task['notes']}[/dim]")
|
|
@@ -106,13 +153,13 @@ def _print_notes(task: dict) -> None:
|
|
|
106
153
|
|
|
107
154
|
def _print_active_task(task: dict) -> None:
|
|
108
155
|
ctx = f" [dim][{task['context_name']}][/dim]" if task.get("context_name") else ""
|
|
109
|
-
console.print(f" [green bold]●[/green bold] [dim]#{task['id']}[/dim] {task['title']}{ctx}")
|
|
156
|
+
console.print(f" [green bold]●[/green bold] [dim]#{task['id']}[/dim] {task['title']}{ctx}{_format_due(task)}")
|
|
110
157
|
_print_notes(task)
|
|
111
158
|
|
|
112
159
|
|
|
113
160
|
def _print_paused_task(task: dict) -> None:
|
|
114
161
|
ctx = f" [dim][{task['context_name']}][/dim]" if task.get("context_name") else ""
|
|
115
|
-
console.print(f" [yellow]⏸[/yellow] [dim]#{task['id']}[/dim] {task['title']}{ctx}")
|
|
162
|
+
console.print(f" [yellow]⏸[/yellow] [dim]#{task['id']}[/dim] {task['title']}{ctx}{_format_due(task)}")
|
|
116
163
|
_print_notes(task)
|
|
117
164
|
|
|
118
165
|
|
|
@@ -153,9 +200,10 @@ def login():
|
|
|
153
200
|
|
|
154
201
|
|
|
155
202
|
@app.command()
|
|
156
|
-
def register(
|
|
157
|
-
"
|
|
158
|
-
|
|
203
|
+
def register(
|
|
204
|
+
token: str = typer.Argument(..., help="Invite token from your invitation link"),
|
|
205
|
+
):
|
|
206
|
+
"""Create a new Doing account using an invite token."""
|
|
159
207
|
password = typer.prompt("Password", hide_input=True)
|
|
160
208
|
password_confirm = typer.prompt("Confirm password", hide_input=True)
|
|
161
209
|
if password != password_confirm:
|
|
@@ -164,13 +212,13 @@ def register():
|
|
|
164
212
|
console.print("[bold red]Passwords do not match.[/bold red]")
|
|
165
213
|
raise typer.Exit(code=1)
|
|
166
214
|
try:
|
|
167
|
-
data = api.register(
|
|
215
|
+
data = api.register(token, password)
|
|
168
216
|
except Exception as exc:
|
|
169
217
|
_handle_api_error(exc)
|
|
170
218
|
save_token(data["access_token"])
|
|
171
219
|
if _json_mode:
|
|
172
|
-
_output_json({"ok": True
|
|
173
|
-
console.print(
|
|
220
|
+
_output_json({"ok": True})
|
|
221
|
+
console.print("[bold green]Account created![/bold green] You're now logged in.")
|
|
174
222
|
|
|
175
223
|
|
|
176
224
|
@app.command()
|
|
@@ -219,16 +267,19 @@ def add(
|
|
|
219
267
|
title: str = typer.Argument(..., help="What are you working on?"),
|
|
220
268
|
notes: str = typer.Option(None, "--notes", "-n", help="Optional notes"),
|
|
221
269
|
context: int = typer.Option(None, "--context", "-c", help="Context ID"),
|
|
270
|
+
due: str = typer.Option(None, "--due", "-d", help="When it's due, e.g. 'tomorrow 3pm', 'in 20m', 'friday'"),
|
|
271
|
+
repeat: str = typer.Option(None, "--repeat", "-r", help="daily, weekdays, mon,thu, or 3d (3 days after done)"),
|
|
222
272
|
):
|
|
223
273
|
"""Add something to your plate."""
|
|
224
274
|
title = _validate_title(title)
|
|
275
|
+
recurrence = _normalize_repeat(repeat) if repeat else None
|
|
225
276
|
try:
|
|
226
|
-
task = api.create_task(title, notes, context)
|
|
277
|
+
task = api.create_task(title, notes, context, due=due, recurrence=recurrence)
|
|
227
278
|
except Exception as exc:
|
|
228
279
|
_handle_api_error(exc)
|
|
229
280
|
if _json_mode:
|
|
230
281
|
_output_json(task)
|
|
231
|
-
console.print(f"[green bold]●[/green bold] Added: [bold]{task['title']}[/bold] [dim](#{task['id']})[/dim]")
|
|
282
|
+
console.print(f"[green bold]●[/green bold] Added: [bold]{task['title']}[/bold] [dim](#{task['id']})[/dim]{_format_due(task)}")
|
|
232
283
|
|
|
233
284
|
|
|
234
285
|
@app.command()
|
|
@@ -245,7 +296,7 @@ def get(
|
|
|
245
296
|
status_icon = {"active": "[green bold]●[/green bold]", "paused": "[yellow]⏸[/yellow]", "done": "[green]✓[/green]"}
|
|
246
297
|
icon = status_icon.get(task["status"], "?")
|
|
247
298
|
ctx = f" [dim][{task['context_name']}][/dim]" if task.get("context_name") else ""
|
|
248
|
-
console.print(f" {icon} [dim]#{task['id']}[/dim] {task['title']}{ctx}")
|
|
299
|
+
console.print(f" {icon} [dim]#{task['id']}[/dim] {task['title']}{ctx}{_format_due(task)}")
|
|
249
300
|
if task.get("notes"):
|
|
250
301
|
console.print(f" [dim]{task['notes']}[/dim]")
|
|
251
302
|
console.print(f" [dim]Status: {task['status']} Created: {task['created_at']}[/dim]")
|
|
@@ -293,16 +344,29 @@ def log(
|
|
|
293
344
|
|
|
294
345
|
@app.command()
|
|
295
346
|
def done(
|
|
296
|
-
|
|
347
|
+
task_ids: list[int] = typer.Argument(..., help="ID(s) of the task(s) to mark done"),
|
|
297
348
|
):
|
|
298
|
-
"""Mark
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
349
|
+
"""Mark one or more tasks as done."""
|
|
350
|
+
results = []
|
|
351
|
+
errors = []
|
|
352
|
+
for task_id in task_ids:
|
|
353
|
+
try:
|
|
354
|
+
task = api.mark_done(task_id)
|
|
355
|
+
except Exception as exc:
|
|
356
|
+
if _json_mode and len(task_ids) == 1:
|
|
357
|
+
_handle_api_error(exc)
|
|
358
|
+
if not _json_mode:
|
|
359
|
+
console.print(f"[red]Failed to complete task #{task_id}[/red]")
|
|
360
|
+
errors.append(task_id)
|
|
361
|
+
continue
|
|
362
|
+
results.append(task)
|
|
363
|
+
if not _json_mode:
|
|
364
|
+
next_due = f" [dim]next:[/dim]{_format_due(task)}" if task.get("recurrence") else ""
|
|
365
|
+
console.print(f"[green]✓[/green] Done: [bold]{task['title']}[/bold]{next_due}")
|
|
303
366
|
if _json_mode:
|
|
304
|
-
_output_json(
|
|
305
|
-
|
|
367
|
+
_output_json(results[0] if len(task_ids) == 1 and results else results)
|
|
368
|
+
if errors:
|
|
369
|
+
raise typer.Exit(code=1)
|
|
306
370
|
|
|
307
371
|
|
|
308
372
|
@app.command()
|
|
@@ -354,26 +418,60 @@ def edit(
|
|
|
354
418
|
notes: str = typer.Option(None, "--notes", "-n", help="New notes"),
|
|
355
419
|
context: int = typer.Option(None, "--context", "-c", help="Move to context ID"),
|
|
356
420
|
no_context: bool = typer.Option(False, "--no-context", help="Remove context"),
|
|
421
|
+
due: str = typer.Option(None, "--due", "-d", help="When it's due, e.g. 'tomorrow 3pm'"),
|
|
422
|
+
no_due: bool = typer.Option(False, "--no-due", help="Remove due date (and repeat)"),
|
|
423
|
+
repeat: str = typer.Option(None, "--repeat", "-r", help="daily, weekdays, mon,thu, or 3d"),
|
|
424
|
+
no_repeat: bool = typer.Option(False, "--no-repeat", help="Stop repeating"),
|
|
357
425
|
):
|
|
358
|
-
"""Edit a task's title, notes, or
|
|
426
|
+
"""Edit a task's title, notes, context, due date, or repeat."""
|
|
359
427
|
if no_context and context is not None:
|
|
360
428
|
_validation_error("--no-context and --context cannot be used together")
|
|
429
|
+
if no_due and due is not None:
|
|
430
|
+
_validation_error("--no-due and --due cannot be used together")
|
|
431
|
+
if no_repeat and repeat is not None:
|
|
432
|
+
_validation_error("--no-repeat and --repeat cannot be used together")
|
|
361
433
|
|
|
362
|
-
if title is None and notes is None and context is None and not no_context
|
|
363
|
-
|
|
434
|
+
if (title is None and notes is None and context is None and not no_context
|
|
435
|
+
and due is None and not no_due and repeat is None and not no_repeat):
|
|
436
|
+
_validation_error("No changes specified. Use --title, --notes, --context, --due, or --repeat.")
|
|
364
437
|
|
|
365
|
-
# BUG-8: Validate title if provided
|
|
366
438
|
if title is not None:
|
|
367
439
|
title = _validate_title(title)
|
|
440
|
+
recurrence = _normalize_repeat(repeat) if repeat else None
|
|
368
441
|
|
|
369
442
|
try:
|
|
370
443
|
task = api.update_task(task_id, title=title, notes=notes,
|
|
371
|
-
context_id=context, clear_context=no_context
|
|
444
|
+
context_id=context, clear_context=no_context,
|
|
445
|
+
due=due, clear_due=no_due,
|
|
446
|
+
recurrence=recurrence, clear_recurrence=no_repeat)
|
|
372
447
|
except Exception as exc:
|
|
373
448
|
_handle_api_error(exc)
|
|
374
449
|
if _json_mode:
|
|
375
450
|
_output_json(task)
|
|
376
|
-
console.print(f"[green bold]Updated:[/green bold] {task['title']} [dim](#{task['id']})[/dim]")
|
|
451
|
+
console.print(f"[green bold]Updated:[/green bold] {task['title']} [dim](#{task['id']})[/dim]{_format_due(task)}")
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
@app.command()
|
|
455
|
+
def snooze(
|
|
456
|
+
task_id: int = typer.Argument(..., help="ID of the task to snooze"),
|
|
457
|
+
duration: str = typer.Argument("10m", help="10m, 1h, 3h, 1d, or 'morning' (tomorrow morning)"),
|
|
458
|
+
):
|
|
459
|
+
"""Push a task's due time back (stops the nagging until then)."""
|
|
460
|
+
d = duration.strip().lower()
|
|
461
|
+
minutes = preset = None
|
|
462
|
+
if d in ("morning", "tomorrow"):
|
|
463
|
+
preset = "tomorrow_morning"
|
|
464
|
+
elif m := re.fullmatch(r"(\d+)\s*(m|min|h|hr|d)", d):
|
|
465
|
+
minutes = int(m.group(1)) * {"m": 1, "min": 1, "h": 60, "hr": 60, "d": 1440}[m.group(2)]
|
|
466
|
+
else:
|
|
467
|
+
_validation_error(f"Invalid duration '{duration}'. Use 10m, 1h, 3h, 1d, or morning.")
|
|
468
|
+
try:
|
|
469
|
+
task = api.snooze_task(task_id, minutes=minutes, preset=preset)
|
|
470
|
+
except Exception as exc:
|
|
471
|
+
_handle_api_error(exc)
|
|
472
|
+
if _json_mode:
|
|
473
|
+
_output_json(task)
|
|
474
|
+
console.print(f"[cyan]💤 Snoozed:[/cyan] [bold]{task['title']}[/bold]{_format_due(task)}")
|
|
377
475
|
|
|
378
476
|
|
|
379
477
|
@app.command()
|
|
@@ -492,6 +590,64 @@ def context_add(
|
|
|
492
590
|
console.print(f"[green bold]Created context:[/green bold] {ctx['name']} [dim](#{ctx['id']})[/dim]")
|
|
493
591
|
|
|
494
592
|
|
|
593
|
+
@context_app.command("edit")
|
|
594
|
+
def context_edit(
|
|
595
|
+
context_id: int = typer.Argument(..., help="Context ID to edit"),
|
|
596
|
+
name: str = typer.Option(None, "--name", "-n", help="New name"),
|
|
597
|
+
icon: str = typer.Option(None, "--icon", "-i", help="New icon (emoji)"),
|
|
598
|
+
):
|
|
599
|
+
"""Rename a context or change its icon."""
|
|
600
|
+
if name is None and icon is None:
|
|
601
|
+
_validation_error("No changes specified. Use --name or --icon.")
|
|
602
|
+
|
|
603
|
+
if name is not None:
|
|
604
|
+
name = _validate_name(name)
|
|
605
|
+
|
|
606
|
+
# Need current name if only changing icon (API requires name)
|
|
607
|
+
if name is None:
|
|
608
|
+
try:
|
|
609
|
+
current = api.get_context(context_id)
|
|
610
|
+
except Exception as exc:
|
|
611
|
+
_handle_api_error(exc)
|
|
612
|
+
name = current["name"]
|
|
613
|
+
|
|
614
|
+
try:
|
|
615
|
+
ctx = api.update_context(context_id, name=name, icon=icon)
|
|
616
|
+
except Exception as exc:
|
|
617
|
+
_handle_api_error(exc)
|
|
618
|
+
if _json_mode:
|
|
619
|
+
_output_json(ctx)
|
|
620
|
+
console.print(f"[green bold]Updated context:[/green bold] {ctx['name']} [dim](#{ctx['id']})[/dim]")
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
@context_app.command("snooze")
|
|
624
|
+
def context_snooze(
|
|
625
|
+
context_id: int = typer.Argument(..., help="Context ID to snooze"),
|
|
626
|
+
):
|
|
627
|
+
"""Snooze a context until end of day (hides its tasks)."""
|
|
628
|
+
try:
|
|
629
|
+
ctx = api.snooze_context(context_id)
|
|
630
|
+
except Exception as exc:
|
|
631
|
+
_handle_api_error(exc)
|
|
632
|
+
if _json_mode:
|
|
633
|
+
_output_json(ctx)
|
|
634
|
+
console.print(f"[yellow]😴 Snoozed:[/yellow] {ctx['name']} until end of day")
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
@context_app.command("unsnooze")
|
|
638
|
+
def context_unsnooze(
|
|
639
|
+
context_id: int = typer.Argument(..., help="Context ID to unsnooze"),
|
|
640
|
+
):
|
|
641
|
+
"""Unsnooze a context (show its tasks again)."""
|
|
642
|
+
try:
|
|
643
|
+
ctx = api.unsnooze_context(context_id)
|
|
644
|
+
except Exception as exc:
|
|
645
|
+
_handle_api_error(exc)
|
|
646
|
+
if _json_mode:
|
|
647
|
+
_output_json(ctx)
|
|
648
|
+
console.print(f"[green bold]Unsnoozed:[/green bold] {ctx['name']}")
|
|
649
|
+
|
|
650
|
+
|
|
495
651
|
@context_app.command("rm")
|
|
496
652
|
def context_rm(
|
|
497
653
|
context_id: int = typer.Argument(..., help="Context ID to delete"),
|
|
@@ -506,5 +662,30 @@ def context_rm(
|
|
|
506
662
|
console.print(f"[dim]Deleted context #{context_id}.[/dim]")
|
|
507
663
|
|
|
508
664
|
|
|
665
|
+
@app.command()
|
|
666
|
+
def stats(
|
|
667
|
+
context: int = typer.Option(None, "--context", "-c", help="Filter by context ID"),
|
|
668
|
+
):
|
|
669
|
+
"""Show how many tasks you've completed today and this week."""
|
|
670
|
+
try:
|
|
671
|
+
data = api.get_stats(context_id=context)
|
|
672
|
+
except Exception as exc:
|
|
673
|
+
_handle_api_error(exc)
|
|
674
|
+
|
|
675
|
+
if _json_mode:
|
|
676
|
+
_output_json(data)
|
|
677
|
+
|
|
678
|
+
today_count = data["today"]
|
|
679
|
+
week_count = data["this_week"]
|
|
680
|
+
total_count = data["total"]
|
|
681
|
+
|
|
682
|
+
console.print(f"\n [bold]Today:[/bold] {today_count} task{'s' if today_count != 1 else ''} done")
|
|
683
|
+
console.print(f" [bold]This week:[/bold] {week_count} task{'s' if week_count != 1 else ''} done")
|
|
684
|
+
|
|
685
|
+
if total_count > week_count:
|
|
686
|
+
console.print(f" [dim]All time: {total_count} total[/dim]")
|
|
687
|
+
console.print()
|
|
688
|
+
|
|
689
|
+
|
|
509
690
|
if __name__ == "__main__":
|
|
510
691
|
app()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: doingapp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: A fast CLI task tracker — track what you're doing from the terminal.
|
|
5
5
|
Author-email: Parker Grimes <parker@altellus.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -73,13 +73,14 @@ doing apikey --key doing_xxxxx
|
|
|
73
73
|
| Command | Description |
|
|
74
74
|
|---------|-------------|
|
|
75
75
|
| `doing` | Show active tasks (same as `doing ls`) |
|
|
76
|
-
| `doing add <title>` | Add a task (`-n` notes, `-c` context ID) |
|
|
76
|
+
| `doing add <title>` | Add a task (`-n` notes, `-c` context ID, `-d "tomorrow 3pm"` due, `-r daily` repeat) |
|
|
77
77
|
| `doing get <id>` | Get a single task by ID |
|
|
78
78
|
| `doing done <id>` | Mark a task as done |
|
|
79
79
|
| `doing pause <id>` | Pause a task |
|
|
80
80
|
| `doing resume <id>` | Resume a paused task |
|
|
81
81
|
| `doing reopen <id>` | Reopen a completed task |
|
|
82
|
-
| `doing edit <id>` | Edit a task (`-t` title, `-n` notes, `-c` context, `--no-context`) |
|
|
82
|
+
| `doing edit <id>` | Edit a task (`-t` title, `-n` notes, `-c` context, `--no-context`, `-d` due, `--no-due`, `-r` repeat, `--no-repeat`) |
|
|
83
|
+
| `doing snooze <id> [10m\|1h\|3h\|1d\|morning]` | Push a due task back (default 10m) |
|
|
83
84
|
| `doing rm <id>` | Delete a task |
|
|
84
85
|
| `doing ls` | List active tasks (`-c` context ID) |
|
|
85
86
|
| `doing log` | Show tasks done today (`-d YYYY-MM-DD`, `-c` context ID) |
|
|
@@ -93,6 +94,19 @@ doing apikey --key doing_xxxxx
|
|
|
93
94
|
| `doing logout` | Log out |
|
|
94
95
|
| `doing apikey` | Save an API key (`-k` key) |
|
|
95
96
|
|
|
97
|
+
## Due dates and reminders
|
|
98
|
+
|
|
99
|
+
Give a task a due date and the Doing iOS app nags you every few minutes once it's overdue, until you mark it done or snooze it.
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
doing add "Call mom" -d "tomorrow 3pm"
|
|
103
|
+
doing add "Water plants" -d friday -r 3d # repeats 3 days after each completion
|
|
104
|
+
doing add "Standup notes" -d "9am" -r weekdays
|
|
105
|
+
doing snooze 12 1h
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`-d` accepts natural language: `in 20m`, `tonight`, `fri 9am`, `oct 30`. Dates without a time use your morning time (9:00 by default). `-r` accepts `daily`, `weekdays`, days like `mon,thu`, or `Nd` (N days after done).
|
|
109
|
+
|
|
96
110
|
## JSON output
|
|
97
111
|
|
|
98
112
|
Every command supports `--json` (or `-j`) for machine-readable output. This is useful for scripting and AI coding agents like Claude Code or Codex CLI.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|