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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: doingapp
3
- Version: 0.5.1
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(email: str, password: str) -> dict:
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={"email": email, "password": password})
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) -> dict:
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) -> dict:
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
- """Create a new Doing account."""
158
- email = typer.prompt("Email")
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(email, password)
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, "email": email})
173
- console.print(f"[bold green]Account created![/bold green] Logged in as [bold]{email}[/bold]")
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
- task_id: int = typer.Argument(..., help="ID of the task to mark done"),
347
+ task_ids: list[int] = typer.Argument(..., help="ID(s) of the task(s) to mark done"),
297
348
  ):
298
- """Mark a task as done."""
299
- try:
300
- task = api.mark_done(task_id)
301
- except Exception as exc:
302
- _handle_api_error(exc)
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(task)
305
- console.print(f"[green]✓[/green] Done: [bold]{task['title']}[/bold]")
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 context."""
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
- _validation_error("No changes specified. Use --title, --notes, --context, or --no-context.")
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.5.1
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.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "doingapp"
7
- version = "0.5.1"
7
+ version = "0.7.0"
8
8
  description = "A fast CLI task tracker — track what you're doing from the terminal."
9
9
  readme = "README.md"
10
10
  license = "MIT"
File without changes
File without changes
File without changes
File without changes