taskcheck 0.3.0.post1__tar.gz → 0.3.2__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,12 +1,13 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: taskcheck
3
- Version: 0.3.0.post1
3
+ Version: 0.3.2
4
4
  Summary: Automatic scheduler for taskwarrior
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
7
7
  License-File: LICENSE
8
8
  Requires-Dist: appdirs>=1.4.4
9
9
  Requires-Dist: icalendar>=6.0.1
10
+ Requires-Dist: random-unicode-emoji>=2.8
10
11
  Requires-Dist: requests>=2.32.3
11
12
  Requires-Dist: rich>=13.9.4
12
13
 
@@ -15,6 +16,10 @@ Requires-Dist: rich>=13.9.4
15
16
 
16
17
  This is a taskwarrior extension checks if tasks can be completed on time, considering estimated time and working hours.
17
18
 
19
+ <p align="center">
20
+ <img src="https://github.com/user-attachments/assets/b9082701-339b-4407-b941-b613a717382c"/>
21
+ </p>
22
+
18
23
  ## Features
19
24
 
20
25
  - [x] Use arbitrarily complex time maps
@@ -71,29 +76,9 @@ instance:
71
76
  - `taskcheck -r today` will show the tasks planned for today
72
77
  - `taskcheck -r 1w` will show the tasks planned for the next week,
73
78
 
74
- ## Algorithms
75
-
76
- Taskcheck provides two scheduling algorithms: `parallel` and `sequential`.
77
-
78
- #### Sequential
79
-
80
- The `sequential` algorithm schedules tasks one after the other. It is the simplest algorithm.
81
- It looks at your tasks one by one, computes the time slots available for that task, and fills them
82
- as soon as possible.
83
-
84
- While it sounds logical, it has a problem.
85
- While you're working on a task, its remaining estimated time is decreasing, and if your urgency
86
- depends on the estimated time left, the urgency of the task will decrease as well.
87
- This is especially problematic for long tasks: imagine if you block your whole week for a single
88
- task and all the other small tasks, even if urgent, don't get scheduled. After a few days, the
89
- urgency of the first long task should have decreased.
79
+ ## Algorithm
90
80
 
91
- #### Parallel
92
-
93
- The `parallel` algorithm tries to solve the problem of the `sequential` algorithm.
94
-
95
- Instead of allocating all the available time for a task until the task is finished, the `parallel`
96
- algorithm allocates a fixed amount of time for each task (e.g. 1 hour) and then recomputes the
81
+ The algorithm allocates a fixed amount of time for each task (e.g. 1 hour) and then recomputes the
97
82
  urgency and restart the allocation procedure.
98
83
 
99
84
  If after 1 hour the long task has decreased urgency, that will be noticed and the newer most urgent
@@ -102,8 +87,6 @@ task will get scheduled in its place.
102
87
  The minimum time for a task is by default 2 hours, but you can change it by tuning the `min_block`
103
88
  taskwarrior UDA.
104
89
 
105
- The `parallel` algorithm is the default algorithm.
106
-
107
90
  ## Configuration
108
91
 
109
92
  `taskcheck --install` allows you to create required and recommended configurations for
@@ -127,7 +110,6 @@ saturday = [[9, 12.30], ]
127
110
  sunday = [[9, 12.30], ]
128
111
 
129
112
  [scheduler]
130
- algorithm = "parallel" # or "sequential", see above
131
113
  days_ahead = 1000 # how far go with the schedule (lower values make the computation faster)
132
114
 
133
115
  [calendars]
@@ -141,6 +123,13 @@ timezone = "Europe/Rome" # if set, force timezone for this calendar; timezone va
141
123
  url = "https://www.officeholidays.com/ics-clean/italy/milan"
142
124
  event_all_day_is_blocking = true
143
125
  expiration = 720 # in hours (720 hours = 30 days)
126
+
127
+ [report]
128
+ additional_attributes = ["estimated", "due", "urgency"] # additional attributes to show in the report
129
+ # when these words are matched in the task description, the corresponding emoji is used
130
+ emoji_keywords = {"meet"=":busts_in_silhouette:", "review"=":mag_right:"}
131
+ include_unplanned = true # include unplanned tasks in the report in an ad-hoc section
132
+ additional_attributes_unplanned = ["due", "urgency"] # additional attributes to show in the report for unplanned tasks
144
133
  ```
145
134
 
146
135
  ## Tips and Tricks
@@ -3,6 +3,10 @@
3
3
 
4
4
  This is a taskwarrior extension checks if tasks can be completed on time, considering estimated time and working hours.
5
5
 
6
+ <p align="center">
7
+ <img src="https://github.com/user-attachments/assets/b9082701-339b-4407-b941-b613a717382c"/>
8
+ </p>
9
+
6
10
  ## Features
7
11
 
8
12
  - [x] Use arbitrarily complex time maps
@@ -59,29 +63,9 @@ instance:
59
63
  - `taskcheck -r today` will show the tasks planned for today
60
64
  - `taskcheck -r 1w` will show the tasks planned for the next week,
61
65
 
62
- ## Algorithms
63
-
64
- Taskcheck provides two scheduling algorithms: `parallel` and `sequential`.
65
-
66
- #### Sequential
67
-
68
- The `sequential` algorithm schedules tasks one after the other. It is the simplest algorithm.
69
- It looks at your tasks one by one, computes the time slots available for that task, and fills them
70
- as soon as possible.
71
-
72
- While it sounds logical, it has a problem.
73
- While you're working on a task, its remaining estimated time is decreasing, and if your urgency
74
- depends on the estimated time left, the urgency of the task will decrease as well.
75
- This is especially problematic for long tasks: imagine if you block your whole week for a single
76
- task and all the other small tasks, even if urgent, don't get scheduled. After a few days, the
77
- urgency of the first long task should have decreased.
66
+ ## Algorithm
78
67
 
79
- #### Parallel
80
-
81
- The `parallel` algorithm tries to solve the problem of the `sequential` algorithm.
82
-
83
- Instead of allocating all the available time for a task until the task is finished, the `parallel`
84
- algorithm allocates a fixed amount of time for each task (e.g. 1 hour) and then recomputes the
68
+ The algorithm allocates a fixed amount of time for each task (e.g. 1 hour) and then recomputes the
85
69
  urgency and restart the allocation procedure.
86
70
 
87
71
  If after 1 hour the long task has decreased urgency, that will be noticed and the newer most urgent
@@ -90,8 +74,6 @@ task will get scheduled in its place.
90
74
  The minimum time for a task is by default 2 hours, but you can change it by tuning the `min_block`
91
75
  taskwarrior UDA.
92
76
 
93
- The `parallel` algorithm is the default algorithm.
94
-
95
77
  ## Configuration
96
78
 
97
79
  `taskcheck --install` allows you to create required and recommended configurations for
@@ -115,7 +97,6 @@ saturday = [[9, 12.30], ]
115
97
  sunday = [[9, 12.30], ]
116
98
 
117
99
  [scheduler]
118
- algorithm = "parallel" # or "sequential", see above
119
100
  days_ahead = 1000 # how far go with the schedule (lower values make the computation faster)
120
101
 
121
102
  [calendars]
@@ -129,6 +110,13 @@ timezone = "Europe/Rome" # if set, force timezone for this calendar; timezone va
129
110
  url = "https://www.officeholidays.com/ics-clean/italy/milan"
130
111
  event_all_day_is_blocking = true
131
112
  expiration = 720 # in hours (720 hours = 30 days)
113
+
114
+ [report]
115
+ additional_attributes = ["estimated", "due", "urgency"] # additional attributes to show in the report
116
+ # when these words are matched in the task description, the corresponding emoji is used
117
+ emoji_keywords = {"meet"=":busts_in_silhouette:", "review"=":mag_right:"}
118
+ include_unplanned = true # include unplanned tasks in the report in an ad-hoc section
119
+ additional_attributes_unplanned = ["due", "urgency"] # additional attributes to show in the report for unplanned tasks
132
120
  ```
133
121
 
134
122
  ## Tips and Tricks
@@ -1,12 +1,13 @@
1
1
  [project]
2
2
  name = "taskcheck"
3
- version = "0.3.0-1"
3
+ version = "0.3.2"
4
4
  description = "Automatic scheduler for taskwarrior"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
7
7
  dependencies = [
8
8
  "appdirs>=1.4.4",
9
9
  "icalendar>=6.0.1",
10
+ "random-unicode-emoji>=2.8",
10
11
  "requests>=2.32.3",
11
12
  "rich>=13.9.4",
12
13
  ]
@@ -48,13 +48,7 @@ def main():
48
48
  return
49
49
 
50
50
  config = load_config()
51
- algo = config["scheduler"].get("algorithm", "parallel")
52
- if algo == "parallel":
53
- check_tasks_parallel(config, verbose=args.verbose)
54
- elif algo == "sequential":
55
- check_tasks_sequentially(config, verbose=args.verbose)
56
- else:
57
- raise ValueError(f"Unknown algorithm: {algo}")
51
+ check_tasks_parallel(config, verbose=args.verbose)
58
52
 
59
53
 
60
54
  if __name__ == "__main__":
@@ -101,7 +101,10 @@ def pdth_to_hours(duration_str):
101
101
  if "H" in duration_str:
102
102
  hours = int(duration_str.split("T")[1].split("H")[0])
103
103
  if "M" in duration_str:
104
- minutes = int(duration_str.split("H")[1].split("M")[0])
104
+ if "H" in duration_str:
105
+ minutes = int(duration_str.split("H")[1].split("M")[0])
106
+ else:
107
+ minutes = int(duration_str.split("T")[1].split("M")[0])
105
108
  return days * 24 + hours + minutes / 60
106
109
 
107
110
 
@@ -155,6 +158,8 @@ def get_long_range_time_map(
155
158
  time_map = time_maps[time_map_name].get(today_weekday)
156
159
  if time_map:
157
160
  for schedule_start, schedule_end in time_map:
161
+ schedule_start = hours_to_decimal(schedule_start)
162
+ schedule_end = hours_to_decimal(schedule_end)
158
163
  if schedule_start <= today_time <= schedule_end:
159
164
  today_used_hours += today_time - schedule_start
160
165
  break
@@ -12,7 +12,6 @@ thursday = [[9, 12.30], [14, 17]]
12
12
  friday = [[9, 12.30], [14, 17]]
13
13
 
14
14
  [scheduler]
15
- algorithm = "parallel"
16
15
  days_ahead = 365
17
16
  """
18
17
 
@@ -0,0 +1,397 @@
1
+ import random
2
+ import sys
3
+ from datetime import timedelta
4
+ from datetime import datetime
5
+ import json
6
+ import re
7
+ import subprocess
8
+
9
+
10
+ from rich import print
11
+ from rich.console import Console
12
+ from rich.table import Table
13
+ from rich.text import Text
14
+ from rich.panel import Panel
15
+
16
+ from random_unicode_emoji import random_emoji
17
+
18
+
19
+ DEFAULT_EMOJI_KEYWORDS = {
20
+ "meet": ":busts_in_silhouette:",
21
+ "incontr": ":busts_in_silhouette:",
22
+ "rencontr": ":busts_in_silhouette:",
23
+ "reun": ":busts_in_silhouette:",
24
+ "treff": ":busts_in_silhouette:",
25
+ "review": ":mag_right:",
26
+ "revis": ":mag_right:",
27
+ "révis": ":mag_right:",
28
+ "überprüf": ":mag_right:",
29
+ "write": ":pencil2:",
30
+ "scriv": ":pencil2:",
31
+ "écri": ":pencil2:",
32
+ "escrib": ":pencil2:",
33
+ "schreib": ":pencil2:",
34
+ "read": ":books:",
35
+ "legg": ":books:",
36
+ "lis": ":books:",
37
+ "le": ":books:",
38
+ "les": ":books:",
39
+ "posta": ":email:",
40
+ "courri": ":email:",
41
+ "corre": ":email:",
42
+ "mail": ":email:",
43
+ "call": ":telephone_receiver:",
44
+ "chiam": ":telephone_receiver:",
45
+ "appel": ":telephone_receiver:",
46
+ "llam": ":telephone_receiver:",
47
+ "ruf": ":telephone_receiver:",
48
+ "présent": ":chart_with_upwards_trend:",
49
+ "present": ":chart_with_upwards_trend:",
50
+ "präsent": ":chart_with_upwards_trend:",
51
+ "learn": ":mortar_board:",
52
+ "impar": ":mortar_board:",
53
+ "appren": ":mortar_board:",
54
+ "aprend": ":mortar_board:",
55
+ "lern": ":mortar_board:",
56
+ "search": ":mag:",
57
+ "cerc": ":mag:",
58
+ "cherch": ":mag:",
59
+ "busc": ":mag:",
60
+ "such": ":mag:",
61
+ "idea": ":bulb:",
62
+ "idé": ":bulb:",
63
+ "ide": ":bulb:",
64
+ "break": ":coffee:",
65
+ "paus": ":coffee:",
66
+ "descans": ":coffee:",
67
+ "lunch": ":fork_and_knife:",
68
+ "pranz": ":fork_and_knife:",
69
+ "déjeun": ":fork_and_knife:",
70
+ "almorz": ":fork_and_knife:",
71
+ "mittag": ":fork_and_knife:",
72
+ "test": ":test_tube:",
73
+ "develop": ":hammer_and_wrench:",
74
+ "svilupp": ":hammer_and_wrench:",
75
+ "développ": ":hammer_and_wrench:",
76
+ "desarroll": ":hammer_and_wrench:",
77
+ "entwickl": ":hammer_and_wrench:",
78
+ "disegn": ":art:",
79
+ "diseñ": ":art:",
80
+ "design": ":art:",
81
+ "dafar": ":memo:",
82
+ "tarea": ":memo:",
83
+ "todo": ":memo:",
84
+ "bug": ":beetle:",
85
+ "käfer": ":beetle:",
86
+ "fix": ":wrench:",
87
+ "corregg": ":wrench:",
88
+ "répar": ":wrench:",
89
+ "beheb": ":wrench:",
90
+ "urgent": ":exclamation:",
91
+ "dringend": ":exclamation:",
92
+ "deadlin": ":hourglass_flowing_sand:",
93
+ "scadenz": ":hourglass_flowing_sand:",
94
+ "délais": ":hourglass_flowing_sand:",
95
+ "fechalim": ":hourglass_flowing_sand:",
96
+ "frist": ":hourglass_flowing_sand:",
97
+ "updat": ":arrows_counterclockwise:",
98
+ "aggiorn": ":arrows_counterclockwise:",
99
+ "mett": ":arrows_counterclockwise:",
100
+ "actualiz": ":arrows_counterclockwise:",
101
+ "aktualisier": ":arrows_counterclockwise:",
102
+ "clean": ":broom:",
103
+ "pul": ":broom:",
104
+ "nettoy": ":broom:",
105
+ "limpi": ":broom:",
106
+ "reinig": ":broom:",
107
+ "deploy": ":rocket:",
108
+ "rilasc": ":rocket:",
109
+ "déploy": ":rocket:",
110
+ "despleg": ":rocket:",
111
+ "bereitstell": ":rocket:",
112
+ "festegg": ":tada:",
113
+ "célébr": ":tada:",
114
+ "celebr": ":tada:",
115
+ "feier": ":tada:",
116
+ "research": ":microscope:",
117
+ "ricerc": ":microscope:",
118
+ "recherch": ":microscope:",
119
+ "investig": ":microscope:",
120
+ "forsch": ":microscope:",
121
+ "shop": ":shopping_cart:",
122
+ "achet": ":shopping_cart:",
123
+ "compr": ":shopping_cart:",
124
+ "einkauf": ":shopping_cart:",
125
+ "social": ":handshake:",
126
+ "sozial": ":handshake:",
127
+ "exercis": ":running_shoe:",
128
+ "eserciz": ":running_shoe:",
129
+ "exercic": ":running_shoe:",
130
+ "ejerc": ":running_shoe:",
131
+ "übun": ":running_shoe:",
132
+ "évènement": ":calendar:",
133
+ "event": ":calendar:",
134
+ "movie": ":clapper:",
135
+ "pelicul": ":clapper:",
136
+ "film": ":clapper:",
137
+ "music": ":musical_note:",
138
+ "musik": ":musical_note:",
139
+ "travel": ":airplane:",
140
+ "viagg": ":airplane:",
141
+ "voyag": ":airplane:",
142
+ "viaj": ":airplane:",
143
+ "reis": ":airplane:",
144
+ "home": ":house:",
145
+ "mais": ":house:",
146
+ "cas": ":house:",
147
+ "haus": ":house:",
148
+ "docu": ":notebook:",
149
+ "dokum": ":notebook:",
150
+ "backup": ":floppy_disk:",
151
+ "salvat": ":floppy_disk:",
152
+ "sauveg": ":floppy_disk:",
153
+ "copiaseg": ":floppy_disk:",
154
+ "sicher": ":floppy_disk:",
155
+ "debug": ":bug:",
156
+ "débug": ":bug:",
157
+ "note": ":spiral_notepad:",
158
+ "not": ":spiral_notepad:",
159
+ "focus": ":dart:",
160
+ "concentr": ":dart:",
161
+ "konzentrier": ":dart:",
162
+ "codic": ":computer:",
163
+ "cod": ":computer:",
164
+ "code": ":computer:",
165
+ "serveur": ":cloud:",
166
+ "servidor": ":cloud:",
167
+ "server": ":cloud:",
168
+ "client": ":briefcase:",
169
+ "priorid": ":bangbang:",
170
+ "priorit": ":bangbang:",
171
+ "star": ":star:",
172
+ "stell": ":star:",
173
+ "étoil": ":star:",
174
+ "estrell": ":star:",
175
+ "stern": ":star:",
176
+ "critic": ":fire:",
177
+ "kritisch": ":fire:",
178
+ }
179
+
180
+
181
+ def get_tasks(config, tasks, year, month, day):
182
+ regex = re.compile(rf"{year}-{month}-{day} - ([PDTHM0-9]+)")
183
+ valid_tasks = []
184
+ for task in tasks:
185
+ for line in task["scheduling"].split("\n"):
186
+ m = regex.match(line)
187
+ if m:
188
+ due = task.get("due", "")
189
+ if due:
190
+ due = datetime.strptime(due, "%Y%m%dT%H%M%SZ")
191
+ due = due - datetime.today()
192
+ valid_tasks.append(
193
+ {
194
+ "id": task["id"],
195
+ "project": task.get("project", ""),
196
+ "description": task["description"],
197
+ "scheduling_day": f"{year}-{month}-{day}",
198
+ "scheduling_hours": m.group(1),
199
+ **{
200
+ attr: task.get(attr, "")
201
+ for attr in config.get("additional_attributes", [])
202
+ },
203
+ }
204
+ )
205
+ valid_tasks = sorted(valid_tasks, key=lambda x: x["urgency"], reverse=True)
206
+ return valid_tasks
207
+
208
+
209
+ def get_taskwarrior_date(date, _retry=True):
210
+ date = subprocess.run(
211
+ ["task", "calc", date],
212
+ capture_output=True,
213
+ text=True,
214
+ )
215
+ date = date.stdout.strip()
216
+ try:
217
+ date = datetime.strptime(date, "%Y-%m-%dT%H:%M:%S")
218
+ except Exception as _:
219
+ if _retry:
220
+ return get_taskwarrior_date("today+" + date, False)
221
+ else:
222
+ print(
223
+ "Please provide a valid date. Check with `task calc` that your date is in the format YYYY-MM-DDTHH:MM:SS",
224
+ file=sys.stderr,
225
+ )
226
+ exit(2)
227
+ return date
228
+
229
+
230
+ def get_days_in_constraint(constraint):
231
+ current_date = datetime.today().replace(hour=0, minute=0, second=0, microsecond=0)
232
+ constraint = get_taskwarrior_date(constraint)
233
+ while current_date <= constraint:
234
+ yield current_date.year, current_date.month, current_date.day
235
+ current_date += timedelta(days=1)
236
+
237
+
238
+ def tostring(value):
239
+ if isinstance(value, bool):
240
+ return "Yes" if value else "No"
241
+ elif isinstance(value, datetime):
242
+ return value.strftime("%Y-%m-%d %H:%M")
243
+ elif isinstance(value, str):
244
+ try:
245
+ date = datetime.strptime(value, "%Y%m%dT%H%M%SZ")
246
+ return date.strftime("%Y-%m-%d %H:%M")
247
+ except ValueError:
248
+ return value
249
+ else:
250
+ return str(value)
251
+
252
+
253
+ def get_unplanned_tasks(config, tasks):
254
+ tasks = subprocess.run(
255
+ ["task", "scheduling:", "status:pending", "export"],
256
+ capture_output=True,
257
+ text=True,
258
+ )
259
+ tasks = json.loads(tasks.stdout)
260
+ return tasks
261
+
262
+
263
+ def generate_report(config, constraint, verbose=False):
264
+ config = config["report"]
265
+ console = Console()
266
+ tasks = fetch_tasks()
267
+
268
+ for year, month, day in get_days_in_constraint(constraint):
269
+ this_day_tasks = get_tasks(config, tasks, year, month, day)
270
+
271
+ display_date_header(console, year, month, day)
272
+
273
+ display_tasks_table(console, config, this_day_tasks)
274
+
275
+ if config.get("include_unplanned"):
276
+ unplanned_tasks = get_unplanned_tasks(config, tasks)
277
+ display_unplanned_tasks(console, config, unplanned_tasks)
278
+
279
+
280
+ def fetch_tasks():
281
+ """Fetch tasks from the task manager and return them as a JSON object."""
282
+ tasks = subprocess.run(
283
+ ["task", "scheduling~.", "(", "+PENDING", "or", "+WAITING", ")", "export"],
284
+ capture_output=True,
285
+ text=True,
286
+ )
287
+ return json.loads(tasks.stdout)
288
+
289
+
290
+ def display_date_header(console, year, month, day):
291
+ """Display a date header with a calendar emoji."""
292
+ date_str = f":calendar: [bold cyan]{year}-{month}-{day}[/bold cyan]"
293
+ console.print(Panel(date_str, style="bold blue", expand=False))
294
+
295
+
296
+ def display_tasks_table(console, config, tasks):
297
+ """Display a table of tasks for a specific day."""
298
+ if tasks:
299
+ table = build_tasks_table(config, tasks)
300
+ console.print(table)
301
+ else:
302
+ console.print("[italic dim]No tasks scheduled for this day.[/italic dim]")
303
+
304
+
305
+ def build_tasks_table(config, tasks):
306
+ """Build a Rich table for displaying tasks."""
307
+ table = Table(show_header=True, header_style="bold blue")
308
+ table.add_column("Task", style="dim", width=12)
309
+ table.add_column("Project", style="dim", width=12)
310
+ table.add_column("Description")
311
+ table.add_column("Time", justify="right")
312
+ for attr in config.get("additional_attributes", []):
313
+ table.add_column(attr.capitalize(), justify="right")
314
+
315
+ for task in tasks:
316
+ task_id = f"[bold green]#{task['id']}[/bold green]"
317
+ project = task.get("project", "")
318
+ description = Text(task["description"], style="white")
319
+ hours = f"[yellow]{task['scheduling_hours']}[/yellow]"
320
+ emoji = get_task_emoji(config, task)
321
+
322
+ table.add_row(
323
+ f"{emoji} {task_id}",
324
+ project,
325
+ description,
326
+ hours,
327
+ *[
328
+ tostring(task.get(attr, ""))
329
+ for attr in config.get("additional_attributes", [])
330
+ ],
331
+ )
332
+ return table
333
+
334
+
335
+ def get_task_emoji(config, task):
336
+ """Get an emoji based on keywords in the task description."""
337
+ for keyword in config.get("emoji_keywords", []):
338
+ if keyword in task["description"].lower():
339
+ return config["emoji_keywords"][keyword]
340
+ for keyword, emoji in DEFAULT_EMOJI_KEYWORDS.items():
341
+ if keyword in task["description"].lower():
342
+ return emoji
343
+ # sum the unicode numbers of each letter in the description and use it as seed to get a random unicode
344
+ # emoji
345
+ seed = sum(ord(char) for char in task["description"])
346
+ emoji = ""
347
+ # seed the next call to random.choice
348
+ random.seed(seed)
349
+ emoji = random_emoji()[0]
350
+ # some emoji coutn for more than one character and misbehave the tables of rich
351
+ if len(emoji) > 1:
352
+ while len(emoji) != 1:
353
+ emoji = random_emoji()[0]
354
+ return emoji
355
+
356
+
357
+ def display_unplanned_tasks(console, config, tasks):
358
+ """Display unplanned tasks if any are found."""
359
+ if tasks:
360
+ table = build_unplanned_tasks_table(config, tasks)
361
+ console.print(Panel("Unplanned Tasks", style="bold blue", expand=False))
362
+ console.print(table)
363
+ else:
364
+ console.print(
365
+ Panel(
366
+ "[italic dim]No unplanned tasks found.[/italic dim]",
367
+ style="bold blue",
368
+ expand=False,
369
+ )
370
+ )
371
+
372
+
373
+ def build_unplanned_tasks_table(config, tasks):
374
+ """Build a Rich table for displaying unplanned tasks."""
375
+ table = Table(show_header=True, header_style="bold blue")
376
+ table.add_column("Task", style="dim", width=12)
377
+ table.add_column("Project", style="dim", width=12)
378
+ table.add_column("Description")
379
+ for attr in config.get("additional_attributes_unplanned", []):
380
+ table.add_column(attr.capitalize(), justify="right")
381
+
382
+ for task in tasks:
383
+ task_id = f"[bold green]#{task['id']}[/bold green]"
384
+ project = task.get("project", "")
385
+ description = Text(task["description"], style="white")
386
+ emoji = get_task_emoji(config, task)
387
+
388
+ table.add_row(
389
+ f"{emoji} {task_id}",
390
+ project,
391
+ description,
392
+ *[
393
+ tostring(task.get(attr, ""))
394
+ for attr in config.get("additional_attributes_unplanned", [])
395
+ ],
396
+ )
397
+ return table
@@ -1,12 +1,13 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: taskcheck
3
- Version: 0.3.0.post1
3
+ Version: 0.3.2
4
4
  Summary: Automatic scheduler for taskwarrior
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
7
7
  License-File: LICENSE
8
8
  Requires-Dist: appdirs>=1.4.4
9
9
  Requires-Dist: icalendar>=6.0.1
10
+ Requires-Dist: random-unicode-emoji>=2.8
10
11
  Requires-Dist: requests>=2.32.3
11
12
  Requires-Dist: rich>=13.9.4
12
13
 
@@ -15,6 +16,10 @@ Requires-Dist: rich>=13.9.4
15
16
 
16
17
  This is a taskwarrior extension checks if tasks can be completed on time, considering estimated time and working hours.
17
18
 
19
+ <p align="center">
20
+ <img src="https://github.com/user-attachments/assets/b9082701-339b-4407-b941-b613a717382c"/>
21
+ </p>
22
+
18
23
  ## Features
19
24
 
20
25
  - [x] Use arbitrarily complex time maps
@@ -71,29 +76,9 @@ instance:
71
76
  - `taskcheck -r today` will show the tasks planned for today
72
77
  - `taskcheck -r 1w` will show the tasks planned for the next week,
73
78
 
74
- ## Algorithms
75
-
76
- Taskcheck provides two scheduling algorithms: `parallel` and `sequential`.
77
-
78
- #### Sequential
79
-
80
- The `sequential` algorithm schedules tasks one after the other. It is the simplest algorithm.
81
- It looks at your tasks one by one, computes the time slots available for that task, and fills them
82
- as soon as possible.
83
-
84
- While it sounds logical, it has a problem.
85
- While you're working on a task, its remaining estimated time is decreasing, and if your urgency
86
- depends on the estimated time left, the urgency of the task will decrease as well.
87
- This is especially problematic for long tasks: imagine if you block your whole week for a single
88
- task and all the other small tasks, even if urgent, don't get scheduled. After a few days, the
89
- urgency of the first long task should have decreased.
79
+ ## Algorithm
90
80
 
91
- #### Parallel
92
-
93
- The `parallel` algorithm tries to solve the problem of the `sequential` algorithm.
94
-
95
- Instead of allocating all the available time for a task until the task is finished, the `parallel`
96
- algorithm allocates a fixed amount of time for each task (e.g. 1 hour) and then recomputes the
81
+ The algorithm allocates a fixed amount of time for each task (e.g. 1 hour) and then recomputes the
97
82
  urgency and restart the allocation procedure.
98
83
 
99
84
  If after 1 hour the long task has decreased urgency, that will be noticed and the newer most urgent
@@ -102,8 +87,6 @@ task will get scheduled in its place.
102
87
  The minimum time for a task is by default 2 hours, but you can change it by tuning the `min_block`
103
88
  taskwarrior UDA.
104
89
 
105
- The `parallel` algorithm is the default algorithm.
106
-
107
90
  ## Configuration
108
91
 
109
92
  `taskcheck --install` allows you to create required and recommended configurations for
@@ -127,7 +110,6 @@ saturday = [[9, 12.30], ]
127
110
  sunday = [[9, 12.30], ]
128
111
 
129
112
  [scheduler]
130
- algorithm = "parallel" # or "sequential", see above
131
113
  days_ahead = 1000 # how far go with the schedule (lower values make the computation faster)
132
114
 
133
115
  [calendars]
@@ -141,6 +123,13 @@ timezone = "Europe/Rome" # if set, force timezone for this calendar; timezone va
141
123
  url = "https://www.officeholidays.com/ics-clean/italy/milan"
142
124
  event_all_day_is_blocking = true
143
125
  expiration = 720 # in hours (720 hours = 30 days)
126
+
127
+ [report]
128
+ additional_attributes = ["estimated", "due", "urgency"] # additional attributes to show in the report
129
+ # when these words are matched in the task description, the corresponding emoji is used
130
+ emoji_keywords = {"meet"=":busts_in_silhouette:", "review"=":mag_right:"}
131
+ include_unplanned = true # include unplanned tasks in the report in an ad-hoc section
132
+ additional_attributes_unplanned = ["due", "urgency"] # additional attributes to show in the report for unplanned tasks
144
133
  ```
145
134
 
146
135
  ## Tips and Tricks
@@ -7,7 +7,6 @@ taskcheck/ical.py
7
7
  taskcheck/install.py
8
8
  taskcheck/parallel.py
9
9
  taskcheck/report.py
10
- taskcheck/sequential.py
11
10
  taskcheck.egg-info/PKG-INFO
12
11
  taskcheck.egg-info/SOURCES.txt
13
12
  taskcheck.egg-info/dependency_links.txt
@@ -1,4 +1,5 @@
1
1
  appdirs>=1.4.4
2
2
  icalendar>=6.0.1
3
+ random-unicode-emoji>=2.8
3
4
  requests>=2.32.3
4
5
  rich>=13.9.4
@@ -1,122 +0,0 @@
1
- import sys
2
- from datetime import timedelta
3
- from datetime import datetime
4
- import json
5
- import re
6
- import subprocess
7
-
8
-
9
- from rich import print
10
- from rich.console import Console
11
- from rich.table import Table
12
- from rich.text import Text
13
- from rich.panel import Panel
14
-
15
-
16
- def get_tasks(tasks, year, month, day):
17
- regex = re.compile(rf"{year}-{month}-{day} - ([PDTHM0-9]+)")
18
- valid_tasks = []
19
- for task in tasks:
20
- for line in task["scheduling"].split("\n"):
21
- m = regex.match(line)
22
- if m:
23
- due = task.get("due", "")
24
- if due:
25
- due = datetime.strptime(due, "%Y%m%dT%H%M%SZ")
26
- due = due - datetime.today()
27
- valid_tasks.append(
28
- {
29
- "id": task["id"],
30
- "project": task.get("project", ""),
31
- "description": task["description"],
32
- "scheduling_day": f"{year}-{month}-{day}",
33
- "scheduling_hours": m.group(1),
34
- "urgency": task["urgency"],
35
- "due": due,
36
- }
37
- )
38
- valid_tasks = sorted(valid_tasks, key=lambda x: x["urgency"], reverse=True)
39
- return valid_tasks
40
-
41
-
42
- def get_taskwarrior_date(date, _retry=True):
43
- date = subprocess.run(
44
- ["task", "calc", date],
45
- capture_output=True,
46
- text=True,
47
- )
48
- date = date.stdout.strip()
49
- try:
50
- date = datetime.strptime(date, "%Y-%m-%dT%H:%M:%S")
51
- except Exception as _:
52
- if _retry:
53
- return get_taskwarrior_date("today+" + date, False)
54
- else:
55
- print(
56
- "Please provide a valid date. Check with `task calc` that your date is in the format YYYY-MM-DDTHH:MM:SS",
57
- file=sys.stderr,
58
- )
59
- exit(2)
60
- return date
61
-
62
-
63
- def get_days_in_constraint(constraint):
64
- current_date = datetime.today().replace(hour=0, minute=0, second=0, microsecond=0)
65
- constraint = get_taskwarrior_date(constraint)
66
- while current_date <= constraint:
67
- yield current_date.year, current_date.month, current_date.day
68
- current_date += timedelta(days=1)
69
-
70
-
71
- def generate_report(config, constraint, verbose=False):
72
- console = Console()
73
- tasks = subprocess.run(
74
- ["task", "scheduling~.", "export"], capture_output=True, text=True
75
- )
76
- tasks = json.loads(tasks.stdout)
77
-
78
- for year, month, day in get_days_in_constraint(constraint):
79
- this_day_tasks = get_tasks(tasks, year, month, day)
80
-
81
- # Create a colorful date header with emojis and a panel
82
- date_str = f":calendar: [bold cyan]{year}-{month}-{day}[/bold cyan]"
83
- console.print(Panel(date_str, style="bold magenta", expand=False))
84
-
85
- if this_day_tasks:
86
- table = Table(show_header=True, header_style="bold blue")
87
- table.add_column("Task", style="dim", width=12)
88
- table.add_column("Project", style="dim", width=12)
89
- table.add_column("Description")
90
- table.add_column("Time", justify="right")
91
- table.add_column("Urgency")
92
- table.add_column("Due date")
93
-
94
- for task in this_day_tasks:
95
- task_id = f"[bold green]#{task['id']}[/bold green]"
96
- project = task["project"]
97
- description = Text(task["description"], style="white")
98
- hours = f"[yellow]{task['scheduling_hours']}h[/yellow]"
99
- urgency = f"[bold]{task['urgency']}[/bold]"
100
- due = task["due"]
101
- if due != "" and due < timedelta(days=5):
102
- due = f"[red]{due.days}d[/red]"
103
- elif due != "":
104
- due = f"[green]{due.days}d[/green]"
105
-
106
- # Add an emoji based on a keyword in the description
107
- if "meeting" in task["description"].lower():
108
- emoji = ":busts_in_silhouette:"
109
- elif "review" in task["description"].lower():
110
- emoji = ":mag_right:"
111
- elif "write" in task["description"].lower():
112
- emoji = ":pencil:"
113
- else:
114
- emoji = ":pushpin:"
115
-
116
- table.add_row(
117
- f"{emoji} {task_id}", project, description, hours, urgency, due
118
- )
119
-
120
- console.print(table)
121
- else:
122
- console.print("[italic dim]No tasks scheduled for this day.[/italic dim]")
@@ -1,150 +0,0 @@
1
- from datetime import datetime, timedelta
2
- from taskcheck.common import (
3
- get_calendars,
4
- get_long_range_time_map,
5
- get_tasks,
6
- mark_end_date,
7
- pdth_to_hours,
8
- hours_to_pdth,
9
- AVOID_STATUS,
10
- )
11
-
12
-
13
- def schedule_task_on_day(
14
- is_starting,
15
- day_offset,
16
- start_date,
17
- end_date,
18
- task_remaining_hours,
19
- task_time_map,
20
- today,
21
- used_hours,
22
- wait,
23
- scheduling_note,
24
- verbose=False,
25
- ):
26
- # we can schedule task on this day
27
- employable_hours = task_time_map[day_offset] - used_hours[day_offset]
28
- # avoid using too small values
29
- employable_hours = 0.01 if 0.01 > employable_hours > 0 else employable_hours
30
- current_date = today + timedelta(days=day_offset)
31
- if wait and current_date <= wait:
32
- if verbose:
33
- print(f"Skipping date {current_date} because of wait date {wait}")
34
- return start_date, end_date, task_remaining_hours, is_starting, scheduling_note
35
-
36
- if is_starting:
37
- if verbose:
38
- print(f"Starting task on {current_date}")
39
- is_starting = False
40
- start_date = current_date
41
-
42
- # minimum value we admit is 0.01 (1 minute)
43
- if task_remaining_hours <= employable_hours + 0.01:
44
- # consume all the remaining task's hours
45
- if scheduling_note != "":
46
- scheduling_note += "\n"
47
- scheduling_note += f"{current_date} - {hours_to_pdth(task_remaining_hours)}\n"
48
- used_hours[day_offset] += task_remaining_hours
49
- task_remaining_hours = 0
50
- end_date = current_date
51
- if verbose:
52
- print(f"Task can be completed on {current_date}")
53
- print(f"Used hours on {current_date}: {used_hours[day_offset]}")
54
- else:
55
- # consume all the available hours on this task
56
- if scheduling_note != "":
57
- scheduling_note += "\n"
58
- scheduling_note += f"{current_date} - {hours_to_pdth(employable_hours)}\n"
59
- task_remaining_hours -= employable_hours
60
- used_hours[day_offset] += employable_hours
61
- if verbose:
62
- print(f"Working for {employable_hours} hours on task on {current_date}")
63
- return start_date, end_date, task_remaining_hours, is_starting, scheduling_note
64
-
65
-
66
- # Check if tasks can be completed on time sequentially
67
- def check_tasks_sequentially(config, verbose=False):
68
- tasks = get_tasks()
69
- time_maps = config["time_maps"]
70
- today = datetime.today().date()
71
- todo = [True if t["status"] not in AVOID_STATUS else False for t in tasks]
72
- used_hours = [0] * (config["scheduler"]["days_ahead"] + 1)
73
- calendars = get_calendars(config)
74
-
75
- while any(todo):
76
- for i, task in enumerate(tasks):
77
- if not todo[i]:
78
- # skipping tasks already completed
79
- continue
80
-
81
- due_date = (
82
- datetime.strptime(task["due"], "%Y%m%dT%H%M%SZ").date()
83
- if "due" in task
84
- else None
85
- )
86
- wait_date = (
87
- datetime.strptime(task["wait"], "%Y%m%dT%H%M%SZ").date()
88
- if "wait" in task
89
- else None
90
- )
91
- estimated_hours = (
92
- pdth_to_hours(task["estimated"]) if "estimated" in task else None
93
- ) # Remove trailing "PT" and "H"
94
- time_map_names = (
95
- task.get("time_map").split(",") if "time_map" in task else None
96
- )
97
- if estimated_hours is None or time_map_names is None:
98
- todo[i] = False
99
- if verbose:
100
- print(
101
- f"Task {task['id']} ('{task['description']}') has no estimated time or time map: {estimated_hours}, {time_map_names}"
102
- )
103
- continue
104
- if verbose:
105
- print(
106
- f"Checking task {task['id']} ('{task['description']}') with estimated hours: {estimated_hours} and wait date: {wait_date}"
107
- )
108
-
109
- task_remaining_hours = estimated_hours
110
- task_time_map, today_used_hours = get_long_range_time_map(
111
- time_maps, time_map_names, config["scheduler"]["days_ahead"], calendars
112
- )
113
- used_hours[0] += today_used_hours
114
-
115
- # Simulate work day-by-day until task is complete or past due
116
- is_starting = True
117
- scheduling_note = ""
118
- start_date = end_date = None
119
- for offset in range(len(task_time_map)):
120
- if task_time_map[offset] > used_hours[offset]:
121
- (
122
- start_date,
123
- end_date,
124
- task_remaining_hours,
125
- is_starting,
126
- scheduling_note,
127
- ) = schedule_task_on_day(
128
- is_starting,
129
- offset,
130
- start_date,
131
- end_date,
132
- task_remaining_hours,
133
- task_time_map,
134
- today,
135
- used_hours,
136
- wait_date,
137
- scheduling_note,
138
- )
139
-
140
- if end_date is not None:
141
- todo[i] = False
142
- mark_end_date(
143
- due_date,
144
- end_date,
145
- start_date,
146
- scheduling_note,
147
- task["id"],
148
- task["description"],
149
- )
150
- break
File without changes
File without changes