taskcheck 1.6.1__tar.gz → 1.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.
Files changed (36) hide show
  1. {taskcheck-1.6.1 → taskcheck-1.7.0}/PKG-INFO +11 -2
  2. taskcheck-1.6.1/taskcheck.egg-info/PKG-INFO → taskcheck-1.7.0/README.md +9 -15
  3. {taskcheck-1.6.1 → taskcheck-1.7.0}/pyproject.toml +2 -2
  4. {taskcheck-1.6.1 → taskcheck-1.7.0}/taskcheck/__main__.py +2 -2
  5. {taskcheck-1.6.1 → taskcheck-1.7.0}/taskcheck/dry_run.py +16 -1
  6. {taskcheck-1.6.1 → taskcheck-1.7.0}/taskcheck/install.py +2 -2
  7. {taskcheck-1.6.1 → taskcheck-1.7.0}/taskcheck/parallel.py +7 -9
  8. {taskcheck-1.6.1 → taskcheck-1.7.0}/taskcheck/report.py +50 -56
  9. taskcheck-1.6.1/README.md → taskcheck-1.7.0/taskcheck.egg-info/PKG-INFO +24 -0
  10. {taskcheck-1.6.1 → taskcheck-1.7.0}/taskcheck.egg-info/SOURCES.txt +2 -0
  11. taskcheck-1.7.0/tests/test_dry_run.py +21 -0
  12. {taskcheck-1.6.1 → taskcheck-1.7.0}/tests/test_parallel.py +19 -0
  13. {taskcheck-1.6.1 → taskcheck-1.7.0}/tests/test_report.py +2 -3
  14. taskcheck-1.7.0/tests/test_report_columns.py +29 -0
  15. {taskcheck-1.6.1 → taskcheck-1.7.0}/LICENSE +0 -0
  16. {taskcheck-1.6.1 → taskcheck-1.7.0}/setup.cfg +0 -0
  17. {taskcheck-1.6.1 → taskcheck-1.7.0}/taskcheck/agent_blocks.py +0 -0
  18. {taskcheck-1.6.1 → taskcheck-1.7.0}/taskcheck/common.py +0 -0
  19. {taskcheck-1.6.1 → taskcheck-1.7.0}/taskcheck/google_calendar.py +0 -0
  20. {taskcheck-1.6.1 → taskcheck-1.7.0}/taskcheck/ical.py +0 -0
  21. {taskcheck-1.6.1 → taskcheck-1.7.0}/taskcheck/theme.py +0 -0
  22. {taskcheck-1.6.1 → taskcheck-1.7.0}/taskcheck/timeline.py +0 -0
  23. {taskcheck-1.6.1 → taskcheck-1.7.0}/taskcheck/undo.py +0 -0
  24. {taskcheck-1.6.1 → taskcheck-1.7.0}/taskcheck.egg-info/dependency_links.txt +0 -0
  25. {taskcheck-1.6.1 → taskcheck-1.7.0}/taskcheck.egg-info/entry_points.txt +0 -0
  26. {taskcheck-1.6.1 → taskcheck-1.7.0}/taskcheck.egg-info/requires.txt +0 -0
  27. {taskcheck-1.6.1 → taskcheck-1.7.0}/taskcheck.egg-info/top_level.txt +0 -0
  28. {taskcheck-1.6.1 → taskcheck-1.7.0}/tests/test_agent_blocks.py +0 -0
  29. {taskcheck-1.6.1 → taskcheck-1.7.0}/tests/test_agent_scheduler_integration.py +0 -0
  30. {taskcheck-1.6.1 → taskcheck-1.7.0}/tests/test_common.py +0 -0
  31. {taskcheck-1.6.1 → taskcheck-1.7.0}/tests/test_google_calendar.py +0 -0
  32. {taskcheck-1.6.1 → taskcheck-1.7.0}/tests/test_ical.py +0 -0
  33. {taskcheck-1.6.1 → taskcheck-1.7.0}/tests/test_install.py +0 -0
  34. {taskcheck-1.6.1 → taskcheck-1.7.0}/tests/test_main.py +0 -0
  35. {taskcheck-1.6.1 → taskcheck-1.7.0}/tests/test_timeline.py +0 -0
  36. {taskcheck-1.6.1 → taskcheck-1.7.0}/tests/test_undo.py +0 -0
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: taskcheck
3
- Version: 1.6.1
3
+ Version: 1.7.0
4
4
  Summary: Automatic scheduler for taskwarrior
5
- Requires-Python: >=3.11
5
+ Requires-Python: >=3.12
6
6
  Description-Content-Type: text/markdown
7
7
  Requires-Dist: appdirs>=1.4.4
8
8
  Requires-Dist: google-api-python-client>=2.198.0
@@ -114,6 +114,11 @@ Create a preview with `taskcheck --schedule --dry-run`. It does not update Taskw
114
114
  saves the generated schedule to the Taskwarrior data directory. Reuse it with `taskcheck --report
115
115
  today --dry-run` or `taskcheck --timeline 1w --dry-run`.
116
116
 
117
+ Report columns use the labels from `uda.<attribute>.label` in the active Taskwarrior configuration.
118
+ Reports show a robot icon beside the task ID for tasks tagged `agent`.
119
+ Dry-run schedules retain custom task attributes, including `agent_weight`.
120
+ When you load an older preview, Taskcheck fills missing task attributes from Taskwarrior and keeps the saved schedule.
121
+
117
122
  Timeline rows follow dotted project levels such as `work.client`. Without `--zoom`, the most
118
123
  detailed view that fits the terminal width is selected: day, then week, then month. The track uses
119
124
  `▶` for a start, `━` for work, `◀` for an end, and `◆` for a deadline. Thin, heavy, and double rules
@@ -148,6 +153,10 @@ Before writing a schedule, taskcheck backs up the affected tasks' `scheduled`, `
148
153
 
149
154
  If a task would finish after its due date, taskcheck retries with a total-urgency override. Each retry moves the task immediately above the task with the smallest greater urgency by adding `urgency_epsilon` (default: `0.1`). At rank 1, it adds epsilon for up to `max_top_urgency_rounds` retries (default: `10`), then tries once at urgency `1000`. If the task is still late, taskcheck stops raising it and optimizes the other overdue tasks.
150
155
 
156
+ Each task gets at most `N - 1 + max_top_urgency_rounds` priority increases before the final retry at urgency `1000`.
157
+ Here, `N` is the number of tasks in the schedule.
158
+ This limit stops repeated retries when tasks with competing deadlines keep overtaking each other.
159
+
151
160
  ## Tips and Tricks
152
161
 
153
162
  - You can exclude a task from being scheduled by removing the `time_map` or `estimated` attributes.
@@ -1,18 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: taskcheck
3
- Version: 1.6.1
4
- Summary: Automatic scheduler for taskwarrior
5
- Requires-Python: >=3.11
6
- Description-Content-Type: text/markdown
7
- Requires-Dist: appdirs>=1.4.4
8
- Requires-Dist: google-api-python-client>=2.198.0
9
- Requires-Dist: google-auth>=2.56.2
10
- Requires-Dist: google-auth-oauthlib>=1.2.2
11
- Requires-Dist: icalendar>=6.0.1
12
- Requires-Dist: random-unicode-emoji>=2.8
13
- Requires-Dist: requests>=2.32.3
14
- Requires-Dist: rich>=13.9.4
15
-
16
1
  A Taskwarrior scheduler for people who want a realistic plan, not a manual to-do list.
17
2
  It turns tasks, working hours, and calendar blocks into an actionable schedule — then keeps due dates visible.
18
3
 
@@ -114,6 +99,11 @@ Create a preview with `taskcheck --schedule --dry-run`. It does not update Taskw
114
99
  saves the generated schedule to the Taskwarrior data directory. Reuse it with `taskcheck --report
115
100
  today --dry-run` or `taskcheck --timeline 1w --dry-run`.
116
101
 
102
+ Report columns use the labels from `uda.<attribute>.label` in the active Taskwarrior configuration.
103
+ Reports show a robot icon beside the task ID for tasks tagged `agent`.
104
+ Dry-run schedules retain custom task attributes, including `agent_weight`.
105
+ When you load an older preview, Taskcheck fills missing task attributes from Taskwarrior and keeps the saved schedule.
106
+
117
107
  Timeline rows follow dotted project levels such as `work.client`. Without `--zoom`, the most
118
108
  detailed view that fits the terminal width is selected: day, then week, then month. The track uses
119
109
  `▶` for a start, `━` for work, `◀` for an end, and `◆` for a deadline. Thin, heavy, and double rules
@@ -148,6 +138,10 @@ Before writing a schedule, taskcheck backs up the affected tasks' `scheduled`, `
148
138
 
149
139
  If a task would finish after its due date, taskcheck retries with a total-urgency override. Each retry moves the task immediately above the task with the smallest greater urgency by adding `urgency_epsilon` (default: `0.1`). At rank 1, it adds epsilon for up to `max_top_urgency_rounds` retries (default: `10`), then tries once at urgency `1000`. If the task is still late, taskcheck stops raising it and optimizes the other overdue tasks.
150
140
 
141
+ Each task gets at most `N - 1 + max_top_urgency_rounds` priority increases before the final retry at urgency `1000`.
142
+ Here, `N` is the number of tasks in the schedule.
143
+ This limit stops repeated retries when tasks with competing deadlines keep overtaking each other.
144
+
151
145
  ## Tips and Tricks
152
146
 
153
147
  - You can exclude a task from being scheduled by removing the `time_map` or `estimated` attributes.
@@ -1,9 +1,9 @@
1
1
  [project]
2
2
  name = "taskcheck"
3
- version = "1.6.1"
3
+ version = "1.7.0"
4
4
  description = "Automatic scheduler for taskwarrior"
5
5
  readme = "README.md"
6
- requires-python = ">=3.11"
6
+ requires-python = ">=3.12"
7
7
  dependencies = [
8
8
  "appdirs>=1.4.4",
9
9
  "google-api-python-client>=2.198.0",
@@ -102,8 +102,8 @@ def load_config():
102
102
  },
103
103
  "report": {
104
104
  "include_unplanned": true,
105
- "additional_attributes": [],
106
- "additional_attributes_unplanned": [],
105
+ "attributes": ["id", "project", "description", "scheduling_hours"],
106
+ "attributes_unplanned": ["id", "project", "description"],
107
107
  "emoji_keywords": {},
108
108
  ...
109
109
  }
@@ -1,7 +1,9 @@
1
1
  import json
2
+ import subprocess
2
3
  from pathlib import Path
3
4
 
4
5
  from taskcheck.undo import get_task_data_dir
6
+ from taskcheck.common import get_task_env
5
7
 
6
8
 
7
9
  def get_dry_run_path(taskrc=None):
@@ -19,4 +21,17 @@ def load_dry_run_results(taskrc=None):
19
21
  path = get_dry_run_path(taskrc)
20
22
  if not path.exists():
21
23
  return None
22
- return json.loads(path.read_text())
24
+ results = json.loads(path.read_text())
25
+ if not results:
26
+ return results
27
+ response = subprocess.run(
28
+ ["task", "export"], capture_output=True, text=True,
29
+ env=get_task_env(taskrc),
30
+ )
31
+ current_tasks = {
32
+ task["uuid"]: task for task in json.loads(response.stdout)
33
+ }
34
+ return [
35
+ {**current_tasks.get(task.get("uuid"), {}), **task}
36
+ for task in results
37
+ ]
@@ -20,8 +20,8 @@ n_undos = 1
20
20
 
21
21
  [report]
22
22
  include_unplanned = true
23
- additional_attributes = []
24
- additional_attributes_unplanned = []
23
+ attributes = ["id", "project", "description", "scheduling_hours"]
24
+ attributes_unplanned = ["id", "project", "description"]
25
25
  # unplanned_max_due = "30d"
26
26
  emoji_keywords = {}
27
27
 
@@ -58,7 +58,7 @@ def advance_urgency_override(
58
58
  for task_uuid in overdue_uuids:
59
59
  state = adjustment_states.setdefault(
60
60
  task_uuid,
61
- {"top_rounds": 0, "fallback_used": False, "exhausted": False},
61
+ {"top_rounds": 0, "rounds": 0, "fallback_used": False, "exhausted": False},
62
62
  )
63
63
  if state["exhausted"]:
64
64
  continue
@@ -72,7 +72,10 @@ def advance_urgency_override(
72
72
  for uuid in task_info
73
73
  if uuid != task_uuid and effective_urgency(uuid) > current_urgency
74
74
  ]
75
- if greater_urgencies:
75
+ if state["rounds"] >= len(task_info) - 1 + max_top_rounds:
76
+ urgency_overrides[task_uuid] = 1000.0
77
+ state["fallback_used"] = True
78
+ elif greater_urgencies:
76
79
  urgency_overrides[task_uuid] = min(greater_urgencies) + epsilon
77
80
  elif state["top_rounds"] < max_top_rounds:
78
81
  urgency_overrides[task_uuid] = current_urgency + epsilon
@@ -80,6 +83,7 @@ def advance_urgency_override(
80
83
  else:
81
84
  urgency_overrides[task_uuid] = 1000.0
82
85
  state["fallback_used"] = True
86
+ state["rounds"] += 1
83
87
  return task_uuid
84
88
  return None
85
89
 
@@ -289,13 +293,7 @@ def check_tasks_parallel(
289
293
  scheduling_note += f"{date_str} - {hours_to_pdth(hours)}\n"
290
294
 
291
295
  task_result = {
292
- "id": task["id"],
293
- "uuid": task["uuid"],
294
- "description": task["description"],
295
- "project": task.get("project", ""),
296
- "urgency": task.get("urgency", 0),
297
- "estimated": task["estimated"],
298
- "due": task.get("due", ""),
296
+ **task,
299
297
  "scheduled": start_date,
300
298
  "completion_date": end_date,
301
299
  "scheduling": scheduling_note.strip(),
@@ -198,16 +198,10 @@ def get_tasks(config, tasks, year, month, day):
198
198
  due = due - datetime.today()
199
199
  valid_tasks.append(
200
200
  {
201
- "id": task["id"],
202
- "project": task.get("project", ""),
203
- "description": task["description"],
204
- "urgency": task["urgency"],
201
+ **task,
205
202
  "scheduling_day": f"{year}-{month}-{day}",
206
203
  "scheduling_hours": m.group(1),
207
- **{
208
- attr: task.get(attr, "")
209
- for attr in config.get("additional_attributes", [])
210
- },
204
+ "tags": task.get("tags", []),
211
205
  }
212
206
  )
213
207
  valid_tasks = sorted(valid_tasks, key=lambda x: x["urgency"], reverse=True)
@@ -287,7 +281,22 @@ def get_unplanned_tasks(config, tasks, taskrc=None):
287
281
 
288
282
 
289
283
  def generate_report(config, constraint, verbose=False, force_update=False, taskrc=None, scheduling_results=None):
290
- report_config = config["report"]
284
+ from taskcheck.common import get_task_env
285
+
286
+ report_config = dict(config["report"])
287
+ result = subprocess.run(
288
+ ["task", "_show"],
289
+ capture_output=True,
290
+ text=True,
291
+ env=get_task_env(taskrc),
292
+ )
293
+ report_config["attribute_labels"] = {
294
+ key[4:-6]: value
295
+ for line in result.stdout.splitlines()
296
+ if "=" in line
297
+ for key, value in [line.split("=", 1)]
298
+ if key.startswith("uda.") and key.endswith(".label") and value
299
+ }
291
300
  theme = detect_theme(config=config.get("timeline", {}))
292
301
  console = Console()
293
302
 
@@ -347,32 +356,38 @@ def display_tasks_table(console, config, tasks, theme="dark"):
347
356
 
348
357
  def build_tasks_table(config, tasks, theme="dark"):
349
358
  """Build a Rich table for displaying tasks."""
359
+ return _build_report_table(
360
+ config, tasks, config.get("attributes", ["id", "project", "description", "scheduling_hours"]), theme
361
+ )
362
+
363
+
364
+ def _build_report_table(config, tasks, attributes, theme):
350
365
  header_style = "bold cyan" if theme == "dark" else "bold blue"
351
366
  table = Table(show_header=True, header_style=header_style)
352
- table.add_column("Task", style="dim", width=12)
353
- table.add_column("Project", style="dim", width=12)
354
- table.add_column("Description")
355
- table.add_column("Time", justify="right")
356
- for attr in config.get("additional_attributes", []):
357
- table.add_column(attr.capitalize(), justify="right")
358
-
359
- for task in tasks:
360
- task_id = f"[bold green]#{task['id']}[/bold green]"
361
- project = task.get("project", "")
362
- description = Text(task["description"], style="italic")
363
- hours = f"[yellow]{task['scheduling_hours']}[/yellow]"
364
- emoji = get_task_emoji(config, task)
365
-
366
- table.add_row(
367
- f"{emoji} {task_id}",
368
- project,
369
- description,
370
- hours,
371
- *[
372
- tostring(task.get(attr, ""))
373
- for attr in config.get("additional_attributes", [])
374
- ],
367
+ labels = {"id": "Task", "scheduling_hours": "Time"}
368
+ for attr in attributes:
369
+ label = config.get("attribute_labels", {}).get(
370
+ attr, labels.get(attr, attr.capitalize())
375
371
  )
372
+ if attr in ("id", "project"):
373
+ table.add_column(label, style="dim", width=12)
374
+ else:
375
+ table.add_column(label, justify="left" if attr == "description" else "right")
376
+ if not attributes:
377
+ return table
378
+ for task in tasks:
379
+ cells = []
380
+ for attr in attributes:
381
+ if attr == "id":
382
+ icon = " 🤖" if "agent" in task.get("tags", []) else ""
383
+ cells.append(f"{get_task_emoji(config, task)} [bold green]#{task['id']}[/bold green]{icon}")
384
+ elif attr == "description":
385
+ cells.append(Text(task.get(attr, ""), style="italic"))
386
+ elif attr == "scheduling_hours":
387
+ cells.append(f"[yellow]{task.get(attr, '')}[/yellow]")
388
+ else:
389
+ cells.append(tostring(task.get(attr, "")))
390
+ table.add_row(*cells)
376
391
  return table
377
392
 
378
393
 
@@ -417,27 +432,6 @@ def display_unplanned_tasks(console, config, tasks, theme="dark"):
417
432
 
418
433
  def build_unplanned_tasks_table(config, tasks, theme="dark"):
419
434
  """Build a Rich table for displaying unplanned tasks."""
420
- header_style = "bold cyan" if theme == "dark" else "bold blue"
421
- table = Table(show_header=True, header_style=header_style)
422
- table.add_column("Task", style="dim", width=12)
423
- table.add_column("Project", style="dim", width=12)
424
- table.add_column("Description")
425
- for attr in config.get("additional_attributes_unplanned", []):
426
- table.add_column(attr.capitalize(), justify="right")
427
-
428
- for task in tasks:
429
- task_id = f"[bold green]#{task['id']}[/bold green]"
430
- project = task.get("project", "")
431
- description = Text(task["description"], style="italic")
432
- emoji = get_task_emoji(config, task)
433
-
434
- table.add_row(
435
- f"{emoji} {task_id}",
436
- project,
437
- description,
438
- *[
439
- tostring(task.get(attr, ""))
440
- for attr in config.get("additional_attributes_unplanned", [])
441
- ],
442
- )
443
- return table
435
+ return _build_report_table(
436
+ config, tasks, config.get("attributes_unplanned", ["id", "project", "description"]), theme
437
+ )
@@ -1,3 +1,18 @@
1
+ Metadata-Version: 2.4
2
+ Name: taskcheck
3
+ Version: 1.7.0
4
+ Summary: Automatic scheduler for taskwarrior
5
+ Requires-Python: >=3.12
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: appdirs>=1.4.4
8
+ Requires-Dist: google-api-python-client>=2.198.0
9
+ Requires-Dist: google-auth>=2.56.2
10
+ Requires-Dist: google-auth-oauthlib>=1.2.2
11
+ Requires-Dist: icalendar>=6.0.1
12
+ Requires-Dist: random-unicode-emoji>=2.8
13
+ Requires-Dist: requests>=2.32.3
14
+ Requires-Dist: rich>=13.9.4
15
+
1
16
  A Taskwarrior scheduler for people who want a realistic plan, not a manual to-do list.
2
17
  It turns tasks, working hours, and calendar blocks into an actionable schedule — then keeps due dates visible.
3
18
 
@@ -99,6 +114,11 @@ Create a preview with `taskcheck --schedule --dry-run`. It does not update Taskw
99
114
  saves the generated schedule to the Taskwarrior data directory. Reuse it with `taskcheck --report
100
115
  today --dry-run` or `taskcheck --timeline 1w --dry-run`.
101
116
 
117
+ Report columns use the labels from `uda.<attribute>.label` in the active Taskwarrior configuration.
118
+ Reports show a robot icon beside the task ID for tasks tagged `agent`.
119
+ Dry-run schedules retain custom task attributes, including `agent_weight`.
120
+ When you load an older preview, Taskcheck fills missing task attributes from Taskwarrior and keeps the saved schedule.
121
+
102
122
  Timeline rows follow dotted project levels such as `work.client`. Without `--zoom`, the most
103
123
  detailed view that fits the terminal width is selected: day, then week, then month. The track uses
104
124
  `▶` for a start, `━` for work, `◀` for an end, and `◆` for a deadline. Thin, heavy, and double rules
@@ -133,6 +153,10 @@ Before writing a schedule, taskcheck backs up the affected tasks' `scheduled`, `
133
153
 
134
154
  If a task would finish after its due date, taskcheck retries with a total-urgency override. Each retry moves the task immediately above the task with the smallest greater urgency by adding `urgency_epsilon` (default: `0.1`). At rank 1, it adds epsilon for up to `max_top_urgency_rounds` retries (default: `10`), then tries once at urgency `1000`. If the task is still late, taskcheck stops raising it and optimizes the other overdue tasks.
135
155
 
156
+ Each task gets at most `N - 1 + max_top_urgency_rounds` priority increases before the final retry at urgency `1000`.
157
+ Here, `N` is the number of tasks in the schedule.
158
+ This limit stops repeated retries when tasks with competing deadlines keep overtaking each other.
159
+
136
160
  ## Tips and Tricks
137
161
 
138
162
  - You can exclude a task from being scheduled by removing the `time_map` or `estimated` attributes.
@@ -22,11 +22,13 @@ taskcheck.egg-info/top_level.txt
22
22
  tests/test_agent_blocks.py
23
23
  tests/test_agent_scheduler_integration.py
24
24
  tests/test_common.py
25
+ tests/test_dry_run.py
25
26
  tests/test_google_calendar.py
26
27
  tests/test_ical.py
27
28
  tests/test_install.py
28
29
  tests/test_main.py
29
30
  tests/test_parallel.py
30
31
  tests/test_report.py
32
+ tests/test_report_columns.py
31
33
  tests/test_timeline.py
32
34
  tests/test_undo.py
@@ -0,0 +1,21 @@
1
+ import json
2
+ from unittest.mock import patch
3
+
4
+ from taskcheck.dry_run import load_dry_run_results
5
+
6
+
7
+ def test_old_preview_recovers_missing_attributes_without_changing_schedule(tmp_path):
8
+ path = tmp_path / "dry-run.json"
9
+ cached = [{"uuid": "agent", "scheduling": "2026-11-10 - PT2H"}]
10
+ path.write_text(json.dumps(cached))
11
+ current = [{"uuid": "agent", "agent_weight": 25,
12
+ "scheduling": "2026-11-11 - PT1H"}]
13
+ with patch("taskcheck.dry_run.get_dry_run_path", return_value=path), patch(
14
+ "taskcheck.dry_run.subprocess.run"
15
+ ) as export:
16
+ export.return_value.stdout = json.dumps(current)
17
+ result = load_dry_run_results()
18
+ assert result is not None
19
+ assert result[0]["agent_weight"] == 25
20
+ assert result[0]["scheduling"] == "2026-11-10 - PT2H"
21
+ assert json.loads(path.read_text()) == cached
@@ -388,6 +388,25 @@ class TestMainSchedulingFunction:
388
388
 
389
389
 
390
390
  class TestAutoAdjustUrgency:
391
+ def test_competing_overdue_tasks_exhaust_retries(self):
392
+ task_info = {
393
+ uuid: {"task": {"uuid": uuid, "urgency": urgency}, "urgency": urgency}
394
+ for uuid, urgency in (("a", 1.0), ("b", 2.0))
395
+ }
396
+ overrides = {}
397
+ states = {}
398
+ # Resolving one deadline can make the other task late on the next pass.
399
+ for index in range(12):
400
+ overdue = [task_info["a" if index % 2 == 0 else "b"]["task"]]
401
+ advance_urgency_override(
402
+ overdue, task_info, overrides, states, epsilon=0.1, max_top_rounds=2
403
+ )
404
+ assert advance_urgency_override(
405
+ [info["task"] for info in task_info.values()],
406
+ task_info, overrides, states, epsilon=0.1, max_top_rounds=2,
407
+ ) is None
408
+ assert overrides == {"a": 1000.0, "b": 1000.0}
409
+
391
410
  def test_urgency_override_moves_up_one_rank_then_uses_bounded_top_rounds(self):
392
411
  task_info = {
393
412
  "t1": {"task": {"uuid": "t1"}, "urgency": 1.0},
@@ -246,7 +246,7 @@ class TestReportGeneration:
246
246
  @patch('taskcheck.report.fetch_tasks')
247
247
  @patch('taskcheck.report.get_days_in_constraint')
248
248
  @patch('taskcheck.report.get_unplanned_tasks')
249
- def test_generate_report_with_unplanned(self, mock_unplanned, mock_days, mock_fetch, sample_config, test_taskrc):
249
+ def test_generate_report_with_unplanned(self, mock_unplanned, mock_days, mock_fetch, sample_config, test_taskrc, capsys):
250
250
  mock_fetch.return_value = []
251
251
  mock_days.return_value = [(2023, 12, 5)]
252
252
  mock_unplanned.return_value = [
@@ -260,7 +260,7 @@ class TestReportGeneration:
260
260
 
261
261
  generate_report(sample_config, "today", verbose=True, taskrc=test_taskrc)
262
262
 
263
- mock_unplanned.assert_called_once_with(sample_config["report"], [], taskrc=test_taskrc)
263
+ assert "Unplanned task" in capsys.readouterr().out
264
264
 
265
265
  @patch('taskcheck.report.display_unplanned_tasks')
266
266
  @patch('taskcheck.report.get_unplanned_tasks')
@@ -278,5 +278,4 @@ class TestReportGeneration:
278
278
  generate_report(sample_config, "today", verbose=False, taskrc=test_taskrc, scheduling_results=scheduling_results)
279
279
 
280
280
  mock_fetch.assert_called_once_with(test_taskrc)
281
- mock_unplanned.assert_called_once_with(sample_config["report"], mock_fetch.return_value, taskrc=test_taskrc)
282
281
  mock_display.assert_called_once()
@@ -0,0 +1,29 @@
1
+ from io import StringIO
2
+
3
+ from rich.console import Console
4
+
5
+ from taskcheck.report import build_tasks_table, build_unplanned_tasks_table, get_tasks
6
+
7
+
8
+ def test_explicit_columns_replace_defaults_in_order():
9
+ task = {"id": 2, "description": "Agent work", "urgency": 1,
10
+ "tags": ["agent"], "agent_weight": 0.34,
11
+ "scheduling": "2026-11-10 - PT2H"}
12
+ config = {"attributes": ["agent_weight", "description"],
13
+ "attribute_labels": {"agent_weight": "Attention"}}
14
+ rows = get_tasks(config, [task], 2026, 11, 10)
15
+ table = build_tasks_table(config, rows)
16
+ assert [column.header for column in table.columns] == ["Attention", "Description"]
17
+ stream = StringIO()
18
+ Console(file=stream, width=100).print(table)
19
+ assert "0.34" in stream.getvalue()
20
+ assert "Agent work" in stream.getvalue()
21
+ assert "#2" not in stream.getvalue()
22
+
23
+
24
+ def test_unplanned_columns_are_independent_and_empty_means_none():
25
+ config = {"attributes": [], "attributes_unplanned": ["description"]}
26
+ tasks = [{"id": 1, "description": "Unplanned work"}]
27
+ assert build_tasks_table(config, tasks).columns == []
28
+ table = build_unplanned_tasks_table(config, tasks)
29
+ assert [column.header for column in table.columns] == ["Description"]
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