taskcheck 1.1.2__tar.gz → 1.3.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 (28) hide show
  1. taskcheck-1.3.0/PKG-INFO +114 -0
  2. taskcheck-1.3.0/README.md +99 -0
  3. {taskcheck-1.1.2 → taskcheck-1.3.0}/pyproject.toml +4 -1
  4. {taskcheck-1.1.2 → taskcheck-1.3.0}/taskcheck/__main__.py +12 -1
  5. {taskcheck-1.1.2 → taskcheck-1.3.0}/taskcheck/common.py +25 -10
  6. taskcheck-1.3.0/taskcheck/google_calendar.py +172 -0
  7. {taskcheck-1.1.2 → taskcheck-1.3.0}/taskcheck/parallel.py +22 -7
  8. {taskcheck-1.1.2 → taskcheck-1.3.0}/taskcheck/report.py +13 -4
  9. taskcheck-1.3.0/taskcheck.egg-info/PKG-INFO +114 -0
  10. {taskcheck-1.1.2 → taskcheck-1.3.0}/taskcheck.egg-info/SOURCES.txt +2 -0
  11. {taskcheck-1.1.2 → taskcheck-1.3.0}/taskcheck.egg-info/requires.txt +3 -0
  12. {taskcheck-1.1.2 → taskcheck-1.3.0}/tests/test_common.py +25 -0
  13. taskcheck-1.3.0/tests/test_google_calendar.py +81 -0
  14. {taskcheck-1.1.2 → taskcheck-1.3.0}/tests/test_parallel.py +27 -1
  15. {taskcheck-1.1.2 → taskcheck-1.3.0}/tests/test_report.py +14 -4
  16. taskcheck-1.1.2/PKG-INFO +0 -229
  17. taskcheck-1.1.2/README.md +0 -217
  18. taskcheck-1.1.2/taskcheck.egg-info/PKG-INFO +0 -229
  19. {taskcheck-1.1.2 → taskcheck-1.3.0}/LICENSE +0 -0
  20. {taskcheck-1.1.2 → taskcheck-1.3.0}/setup.cfg +0 -0
  21. {taskcheck-1.1.2 → taskcheck-1.3.0}/taskcheck/ical.py +0 -0
  22. {taskcheck-1.1.2 → taskcheck-1.3.0}/taskcheck/install.py +0 -0
  23. {taskcheck-1.1.2 → taskcheck-1.3.0}/taskcheck.egg-info/dependency_links.txt +0 -0
  24. {taskcheck-1.1.2 → taskcheck-1.3.0}/taskcheck.egg-info/entry_points.txt +0 -0
  25. {taskcheck-1.1.2 → taskcheck-1.3.0}/taskcheck.egg-info/top_level.txt +0 -0
  26. {taskcheck-1.1.2 → taskcheck-1.3.0}/tests/test_ical.py +0 -0
  27. {taskcheck-1.1.2 → taskcheck-1.3.0}/tests/test_install.py +0 -0
  28. {taskcheck-1.1.2 → taskcheck-1.3.0}/tests/test_main.py +0 -0
@@ -0,0 +1,114 @@
1
+ Metadata-Version: 2.4
2
+ Name: taskcheck
3
+ Version: 1.3.0
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
+ ![immagine](https://github.com/user-attachments/assets/27b83bb1-7a50-4923-a453-0a958fbe11ed)
17
+
18
+ A Taskwarrior scheduler for people who want a realistic plan, not a manual to-do list.
19
+ It turns tasks, working hours, and calendar blocks into an actionable schedule — then keeps due dates visible.
20
+
21
+ Use it if you want to:
22
+ - stop guessing what to do next
23
+ - fit tasks into real availability
24
+ - be sure your long-time plan won't go overdue
25
+ - keep Taskwarrior as the source of truth
26
+
27
+ > [!IMPORTANT]
28
+ > This repo is actively maintained again
29
+
30
+ ## Features
31
+
32
+ - ✨ Auto-schedule tasks from working hours + calendar blocks
33
+ - 🧭 Support complex working-hour maps
34
+ - ⏱️ Consider urgency, due dates, and dependencies
35
+ - 🧪 Dry-run mode to preview changes
36
+ - 🧠 Auto-adjust urgency when deadlines cannot be met
37
+ - 🔄 Force-refresh iCal calendars
38
+ - 📊 Custom reports for planned and unplanned tasks
39
+ - 🎨 Report styling with emoji and extra attributes
40
+ - 🗓️ Block time with iCal or Google Calendar, including all-day events
41
+
42
+ ## Quick start
43
+
44
+ 1. `pipx install taskcheck`
45
+ 2. `taskcheck --install` ← setup only
46
+ 3. add `estimated` + `time_map` UDAs
47
+ 4. edit `~/.config/task/taskcheck.toml` (see [Reference](#Reference))
48
+ 5. `taskcheck --schedule`
49
+
50
+ ## How it works
51
+
52
+ Taskcheck reads your pending and waiting tasks, estimates when each one can be worked on, and writes back a schedule.
53
+ It respects:
54
+ - your working hours
55
+ - task duration estimates
56
+ - calendar events and vacations from iCal / Google Calendar
57
+ - task urgency and due dates
58
+
59
+ It updates Taskwarrior with:
60
+ - `scheduled` → when work should start
61
+ - `completion_date` → when work is expected to finish
62
+ - a warning when a task will miss its due date
63
+
64
+ ### Required UDAs
65
+
66
+ - `estimated`: expected duration in hours
67
+ - `time_map`: which working-hour profile applies to the task, for example `work` or `weekend`
68
+
69
+ Example:
70
+
71
+ ```toml
72
+ [time_maps.work]
73
+ monday = [[9, 12.30], [14, 17]]
74
+ tuesday = [[9, 12.30], [14, 17]]
75
+ ```
76
+
77
+ ### Reports
78
+
79
+ - `taskcheck -r today` → tasks planned for today
80
+ - `taskcheck -r 1w` → tasks planned for the next week
81
+
82
+ ## Reference
83
+
84
+ 📚 Full reference: [REFERENCE.md](REFERENCE.md)
85
+
86
+ In short:
87
+ - `taskcheck --install` is interactive
88
+ - it installs required Taskwarrior settings if you confirm the first prompt
89
+ - it installs optional urgency/report tuning if you confirm the second prompt
90
+ - it can also create the default config file if you confirm the third prompt
91
+ - it does **not** schedule tasks or fetch calendars
92
+ - scheduling happens with `taskcheck --schedule`
93
+ - all flags/config keys/settings are documented in `REFERENCE.md`
94
+
95
+ ## Algorithm
96
+
97
+ The algorithm simulates a workday one chunk at a time.
98
+
99
+ For each day starting from today, it sorts tasks by urgency and picks the most urgent task that fits the available time.
100
+ It assigns a small block of work, then recomputes urgency exactly as Taskwarrior would on that day.
101
+ If urgency changes, the next task choice can change too.
102
+
103
+ For `today`, taskcheck skips past hours.
104
+
105
+ The default work chunk is 2 hours (or less if the task is shorter); you can tune it with the Taskwarrior UDA `min_block`.
106
+
107
+ If a task would finish after its `due_date`, taskcheck retries. It keeps that task's current non-due urgency weight, then lowers the weight by 0.1 only for tasks that have not missed a deadline. Due urgency is never reduced. Retries stop when deadlines are respected or unflagged tasks reach a weight of 0.
108
+
109
+ ## Tips and Tricks
110
+
111
+ - You can exclude a task from being scheduled by removing the `time_map` or `estimated` attributes.
112
+ - You can see tasks that you can execute now with the `task ready` report.
113
+
114
+ See the reference section above for flags, config keys, and installed Taskwarrior settings.
@@ -0,0 +1,99 @@
1
+ ![immagine](https://github.com/user-attachments/assets/27b83bb1-7a50-4923-a453-0a958fbe11ed)
2
+
3
+ A Taskwarrior scheduler for people who want a realistic plan, not a manual to-do list.
4
+ It turns tasks, working hours, and calendar blocks into an actionable schedule — then keeps due dates visible.
5
+
6
+ Use it if you want to:
7
+ - stop guessing what to do next
8
+ - fit tasks into real availability
9
+ - be sure your long-time plan won't go overdue
10
+ - keep Taskwarrior as the source of truth
11
+
12
+ > [!IMPORTANT]
13
+ > This repo is actively maintained again
14
+
15
+ ## Features
16
+
17
+ - ✨ Auto-schedule tasks from working hours + calendar blocks
18
+ - 🧭 Support complex working-hour maps
19
+ - ⏱️ Consider urgency, due dates, and dependencies
20
+ - 🧪 Dry-run mode to preview changes
21
+ - 🧠 Auto-adjust urgency when deadlines cannot be met
22
+ - 🔄 Force-refresh iCal calendars
23
+ - 📊 Custom reports for planned and unplanned tasks
24
+ - 🎨 Report styling with emoji and extra attributes
25
+ - 🗓️ Block time with iCal or Google Calendar, including all-day events
26
+
27
+ ## Quick start
28
+
29
+ 1. `pipx install taskcheck`
30
+ 2. `taskcheck --install` ← setup only
31
+ 3. add `estimated` + `time_map` UDAs
32
+ 4. edit `~/.config/task/taskcheck.toml` (see [Reference](#Reference))
33
+ 5. `taskcheck --schedule`
34
+
35
+ ## How it works
36
+
37
+ Taskcheck reads your pending and waiting tasks, estimates when each one can be worked on, and writes back a schedule.
38
+ It respects:
39
+ - your working hours
40
+ - task duration estimates
41
+ - calendar events and vacations from iCal / Google Calendar
42
+ - task urgency and due dates
43
+
44
+ It updates Taskwarrior with:
45
+ - `scheduled` → when work should start
46
+ - `completion_date` → when work is expected to finish
47
+ - a warning when a task will miss its due date
48
+
49
+ ### Required UDAs
50
+
51
+ - `estimated`: expected duration in hours
52
+ - `time_map`: which working-hour profile applies to the task, for example `work` or `weekend`
53
+
54
+ Example:
55
+
56
+ ```toml
57
+ [time_maps.work]
58
+ monday = [[9, 12.30], [14, 17]]
59
+ tuesday = [[9, 12.30], [14, 17]]
60
+ ```
61
+
62
+ ### Reports
63
+
64
+ - `taskcheck -r today` → tasks planned for today
65
+ - `taskcheck -r 1w` → tasks planned for the next week
66
+
67
+ ## Reference
68
+
69
+ 📚 Full reference: [REFERENCE.md](REFERENCE.md)
70
+
71
+ In short:
72
+ - `taskcheck --install` is interactive
73
+ - it installs required Taskwarrior settings if you confirm the first prompt
74
+ - it installs optional urgency/report tuning if you confirm the second prompt
75
+ - it can also create the default config file if you confirm the third prompt
76
+ - it does **not** schedule tasks or fetch calendars
77
+ - scheduling happens with `taskcheck --schedule`
78
+ - all flags/config keys/settings are documented in `REFERENCE.md`
79
+
80
+ ## Algorithm
81
+
82
+ The algorithm simulates a workday one chunk at a time.
83
+
84
+ For each day starting from today, it sorts tasks by urgency and picks the most urgent task that fits the available time.
85
+ It assigns a small block of work, then recomputes urgency exactly as Taskwarrior would on that day.
86
+ If urgency changes, the next task choice can change too.
87
+
88
+ For `today`, taskcheck skips past hours.
89
+
90
+ The default work chunk is 2 hours (or less if the task is shorter); you can tune it with the Taskwarrior UDA `min_block`.
91
+
92
+ If a task would finish after its `due_date`, taskcheck retries. It keeps that task's current non-due urgency weight, then lowers the weight by 0.1 only for tasks that have not missed a deadline. Due urgency is never reduced. Retries stop when deadlines are respected or unflagged tasks reach a weight of 0.
93
+
94
+ ## Tips and Tricks
95
+
96
+ - You can exclude a task from being scheduled by removing the `time_map` or `estimated` attributes.
97
+ - You can see tasks that you can execute now with the `task ready` report.
98
+
99
+ See the reference section above for flags, config keys, and installed Taskwarrior settings.
@@ -1,11 +1,14 @@
1
1
  [project]
2
2
  name = "taskcheck"
3
- version = "1.1.2"
3
+ version = "1.3.0"
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
+ "google-api-python-client>=2.198.0",
10
+ "google-auth>=2.56.2",
11
+ "google-auth-oauthlib>=1.2.2",
9
12
  "icalendar>=6.0.1",
10
13
  "random-unicode-emoji>=2.8",
11
14
  "requests>=2.32.3",
@@ -52,7 +52,12 @@ arg_parser.add_argument(
52
52
  dest="auto_adjust_urgency",
53
53
  action="store_false",
54
54
  default=True,
55
- help="Disable automatic reduction of urgency weight when tasks cannot be completed on time. (Enabled by default, meaning automatic reduction will occur.)",
55
+ help="Disable automatic per-task urgency adjustment when tasks cannot be completed on time.",
56
+ )
57
+ arg_parser.add_argument(
58
+ "--add-google-calendar",
59
+ action="store_true",
60
+ help="Authenticate with Google, list calendars, and print a config section for the selected calendar.",
56
61
  )
57
62
 
58
63
 
@@ -100,6 +105,12 @@ def main():
100
105
  install()
101
106
  return
102
107
 
108
+ if getattr(args, "add_google_calendar", False) is True:
109
+ from taskcheck.google_calendar import add_google_calendar
110
+
111
+ add_google_calendar(taskrc=args.taskrc)
112
+ return
113
+
103
114
  if args.schedule:
104
115
  config = load_config()
105
116
  check_tasks_kwargs = dict(
@@ -211,16 +211,31 @@ def mark_end_date(
211
211
  def get_calendars(config, verbose=False, force_update=False):
212
212
  calendars = []
213
213
  for calname in config["calendars"]:
214
- calendar = config["calendars"][calname]
215
- calendar = ical_to_dict(
216
- calendar["url"],
217
- config["scheduler"]["days_ahead"],
218
- all_day=calendar["event_all_day_is_blocking"],
219
- expiration=calendar["expiration"],
220
- verbose=verbose,
221
- tz_name=calendar.get("timezone"),
222
- force_update=force_update,
223
- )
214
+ calendar_config = config["calendars"][calname]
215
+ if (
216
+ calendar_config.get("provider") == "google"
217
+ or calendar_config["url"].startswith("google://")
218
+ ):
219
+ from taskcheck.google_calendar import google_calendar_to_dict
220
+
221
+ calendar = google_calendar_to_dict(
222
+ calendar_config.get(
223
+ "calendar_id", calendar_config["url"].removeprefix("google://")
224
+ ),
225
+ calendar_config["token_path"],
226
+ config["scheduler"]["days_ahead"],
227
+ all_day=calendar_config["event_all_day_is_blocking"],
228
+ )
229
+ else:
230
+ calendar = ical_to_dict(
231
+ calendar_config["url"],
232
+ config["scheduler"]["days_ahead"],
233
+ all_day=calendar_config["event_all_day_is_blocking"],
234
+ expiration=calendar_config["expiration"],
235
+ verbose=verbose,
236
+ tz_name=calendar_config.get("timezone"),
237
+ force_update=force_update,
238
+ )
224
239
  calendar.sort(key=lambda e: e["start"])
225
240
  calendars.append(calendar)
226
241
  if verbose:
@@ -0,0 +1,172 @@
1
+ import json
2
+ import warnings
3
+ from datetime import datetime, time, timedelta
4
+ from pathlib import Path
5
+ from urllib.parse import urlparse
6
+
7
+ import requests
8
+ from google.auth.transport.requests import Request
9
+ from google.oauth2.credentials import Credentials
10
+ from google_auth_oauthlib.flow import InstalledAppFlow
11
+ from googleapiclient.discovery import build
12
+
13
+ from taskcheck.common import config_dir, get_task_env
14
+
15
+ SCOPES = [
16
+ "openid",
17
+ "https://www.googleapis.com/auth/userinfo.email",
18
+ "https://www.googleapis.com/auth/userinfo.profile",
19
+ "https://www.googleapis.com/auth/calendar.readonly",
20
+ ]
21
+
22
+
23
+ def get_google_data_dir(taskrc=None):
24
+ env = get_task_env(taskrc)
25
+ base = Path(env.get("TASKDATA", config_dir))
26
+ return base / "google"
27
+
28
+
29
+ def get_client_secrets_path(taskrc=None):
30
+ repo_secret = Path.cwd() / "client_secret.json"
31
+ if repo_secret.exists():
32
+ return repo_secret
33
+ return get_google_data_dir(taskrc) / "client_secret.json"
34
+
35
+
36
+ def get_token_path(account_id, taskrc=None):
37
+ return get_google_data_dir(taskrc) / f"{account_id}.token.json"
38
+
39
+
40
+ def _safe_account_id(email):
41
+ return email.replace("@", "_").replace(".", "_")
42
+
43
+
44
+ def load_credentials(taskrc=None):
45
+ secrets_path = get_client_secrets_path(taskrc)
46
+ if not secrets_path.exists():
47
+ raise FileNotFoundError(f"Missing Google OAuth client secrets: {secrets_path}")
48
+
49
+ token_dir = get_google_data_dir(taskrc)
50
+ token_dir.mkdir(parents=True, exist_ok=True)
51
+ token_path = token_dir / "current.token.json"
52
+ creds = None
53
+ if token_path.exists():
54
+ creds = Credentials.from_authorized_user_file(token_path, SCOPES)
55
+ if creds and creds.expired and creds.refresh_token:
56
+ creds.refresh(Request())
57
+ if not creds or not creds.valid:
58
+ flow = InstalledAppFlow.from_client_secrets_file(str(secrets_path), SCOPES)
59
+ with warnings.catch_warnings():
60
+ warnings.filterwarnings("ignore", message=r'^Scope has changed from .*')
61
+ creds = flow.run_local_server(port=0)
62
+ token_path.write_text(creds.to_json())
63
+ return creds
64
+
65
+
66
+ def get_google_user_email(credentials):
67
+ response = requests.get(
68
+ "https://openidconnect.googleapis.com/v1/userinfo",
69
+ headers={"Authorization": f"Bearer {credentials.token}"},
70
+ timeout=30,
71
+ )
72
+ response.raise_for_status()
73
+ payload = response.json()
74
+ return payload["email"], payload.get("name", payload["email"])
75
+
76
+
77
+ def list_calendars(credentials):
78
+ service = build("calendar", "v3", credentials=credentials, cache_discovery=False)
79
+ result = service.calendarList().list().execute()
80
+ return result.get("items", [])
81
+
82
+
83
+ def google_calendar_to_dict(calendar_id, token_path, days_ahead, all_day=True):
84
+ token_path = Path(token_path).expanduser()
85
+ if not token_path.exists():
86
+ raise FileNotFoundError(f"Missing Google OAuth token: {token_path}")
87
+
88
+ credentials = Credentials.from_authorized_user_file(str(token_path), SCOPES)
89
+ if credentials.expired and credentials.refresh_token:
90
+ credentials.refresh(Request())
91
+ token_path.write_text(credentials.to_json())
92
+ if not credentials.valid:
93
+ raise RuntimeError(f"Invalid Google OAuth token: {token_path}")
94
+
95
+ service = build("calendar", "v3", credentials=credentials, cache_discovery=False)
96
+ timezone = datetime.now().astimezone().tzinfo
97
+ start = datetime.combine(datetime.now().date(), time.min, timezone)
98
+ end = start + timedelta(days=days_ahead + 1)
99
+ params = {
100
+ "calendarId": calendar_id,
101
+ "timeMin": start.isoformat(),
102
+ "timeMax": end.isoformat(),
103
+ "singleEvents": True,
104
+ "orderBy": "startTime",
105
+ }
106
+ events = []
107
+ while True:
108
+ result = service.events().list(**params).execute()
109
+ for item in result.get("items", []):
110
+ event_start = item.get("start", {})
111
+ event_end = item.get("end", {})
112
+ if "dateTime" in event_start and "dateTime" in event_end:
113
+ events.append(
114
+ {"start": event_start["dateTime"], "end": event_end["dateTime"]}
115
+ )
116
+ elif all_day and "date" in event_start and "date" in event_end:
117
+ events.append(
118
+ {
119
+ "start": datetime.fromisoformat(event_start["date"]).isoformat(),
120
+ "end": datetime.fromisoformat(event_end["date"]).isoformat(),
121
+ }
122
+ )
123
+ page_token = result.get("nextPageToken")
124
+ if not page_token:
125
+ break
126
+ params["pageToken"] = page_token
127
+ return events
128
+
129
+
130
+ def select_from_list(items, title_key="summary"):
131
+ for idx, item in enumerate(items, start=1):
132
+ print(f"{idx}. {item.get(title_key, item.get('id'))}")
133
+ while True:
134
+ choice = input("Select one calendar: ").strip()
135
+ if choice.isdigit() and 1 <= int(choice) <= len(items):
136
+ return items[int(choice) - 1]
137
+ print("Invalid selection")
138
+
139
+
140
+ def render_calendar_config(account_id, calendar, taskrc=None):
141
+ token_path = get_token_path(account_id, taskrc)
142
+ return f'''[calendars."{account_id}.{calendar["id"]}"]
143
+ url = "google://{calendar["id"]}"
144
+ provider = "google"
145
+ account = "{account_id}"
146
+ calendar_id = "{calendar["id"]}"
147
+ token_path = "{token_path}"
148
+ event_all_day_is_blocking = true
149
+ expiration = 0.25
150
+ '''
151
+
152
+
153
+ def add_google_calendar(taskrc=None):
154
+ credentials = load_credentials(taskrc)
155
+ email, name = get_google_user_email(credentials)
156
+ account_id = _safe_account_id(email)
157
+ token_path = get_token_path(account_id, taskrc)
158
+ token_path.parent.mkdir(parents=True, exist_ok=True)
159
+ token_path.write_text(credentials.to_json())
160
+
161
+ calendars = list_calendars(credentials)
162
+ if not calendars:
163
+ raise RuntimeError("No Google calendars found for this account")
164
+ calendar = select_from_list(calendars)
165
+ print(render_calendar_config(account_id, calendar, taskrc=taskrc))
166
+ return {
167
+ "account_id": account_id,
168
+ "email": email,
169
+ "name": name,
170
+ "calendar": calendar,
171
+ "token_path": str(token_path),
172
+ }
@@ -111,6 +111,7 @@ def check_tasks_parallel(
111
111
  urgency_coefficients = get_urgency_coefficients(taskrc=taskrc)
112
112
 
113
113
  current_weight = initial_weight_urgency
114
+ frozen_weights = {}
114
115
 
115
116
  # Always re-initialize task_info inside the loop to avoid mutated state
116
117
  task_info_original = initialize_task_info(
@@ -132,6 +133,7 @@ def check_tasks_parallel(
132
133
  urgency_coefficients,
133
134
  verbose,
134
135
  current_weight,
136
+ frozen_weights,
135
137
  )
136
138
 
137
139
  # Check if any tasks cannot be completed on time
@@ -159,7 +161,9 @@ def check_tasks_parallel(
159
161
  if not tasks_overdue or not auto_adjust_urgency:
160
162
  break
161
163
 
162
- # If we have overdue tasks and auto-adjust is enabled, reduce weight and try again
164
+ for task in tasks_overdue:
165
+ frozen_weights.setdefault(task["uuid"], current_weight)
166
+
163
167
  if verbose:
164
168
  console.print(
165
169
  f"[red]{len(tasks_overdue)} task(s) cannot be completed on time[/red]"
@@ -175,12 +179,12 @@ def check_tasks_parallel(
175
179
  if tasks_overdue:
176
180
  # We tried to adjust but still have overdue tasks
177
181
  console.print(
178
- "[red]Warning: cannot find a solution even with urgency weight 0.0[/red]"
182
+ "[red]Warning: cannot find a solution after per-task urgency adjustment[/red]"
179
183
  )
180
184
  else:
181
185
  # We successfully resolved overdue tasks by adjusting weight
182
186
  console.print(
183
- f"[green]Final urgency weight: {max(current_weight, 0.0):.1f}[/green]"
187
+ f"[green]Resolved deadlines with per-task urgency adjustment[/green]"
184
188
  )
185
189
 
186
190
  if dry_run:
@@ -275,6 +279,7 @@ def allocate_time_for_day(
275
279
  urgency_coefficients,
276
280
  verbose,
277
281
  weight_urgency,
282
+ frozen_weights=None,
278
283
  ):
279
284
  date = datetime.today().date() + timedelta(days=day_offset)
280
285
  total_available_hours = compute_total_available_hours(task_info, day_offset)
@@ -287,7 +292,13 @@ def allocate_time_for_day(
287
292
  tasks_remaining = prepare_tasks_remaining(task_info, day_offset)
288
293
 
289
294
  while day_remaining_hours > 0 and tasks_remaining:
290
- recompute_urgencies(tasks_remaining, urgency_coefficients, date, weight_urgency)
295
+ recompute_urgencies(
296
+ tasks_remaining,
297
+ urgency_coefficients,
298
+ date,
299
+ weight_urgency,
300
+ frozen_weights,
301
+ )
291
302
  sorted_task_ids = sorted(
292
303
  tasks_remaining.keys(),
293
304
  key=lambda x: tasks_remaining[x]["urgency"],
@@ -423,8 +434,11 @@ def update_urgency(info, urgency_key, urgency_compute_fn, urgency_coefficients,
423
434
  info["urgency"] = info["urgency"] - old_urgency + urgency_value
424
435
 
425
436
 
426
- def recompute_urgencies(tasks_remaining, urgency_coefficients, date, weight_urgency):
427
- """Recompute urgency simulating that today is `date`"""
437
+ def recompute_urgencies(
438
+ tasks_remaining, urgency_coefficients, date, weight_urgency, frozen_weights=None
439
+ ):
440
+ """Recompute urgency simulating that today is `date`."""
441
+ frozen_weights = frozen_weights or {}
428
442
  # Recompute estimated urgencies as before
429
443
  for info in tasks_remaining.values():
430
444
  # Update estimated urgency
@@ -438,7 +452,8 @@ def recompute_urgencies(tasks_remaining, urgency_coefficients, date, weight_urge
438
452
 
439
453
  # Apply weights to create a combined score
440
454
  base_urgency = info["urgency"] - info["due_urgency"]
441
- base_urgency *= weight_urgency
455
+ task_weight = frozen_weights.get(info["task"]["uuid"], weight_urgency)
456
+ base_urgency *= task_weight
442
457
  # Always add due_urgency, only weight estimated and age
443
458
  weighted_urgency = base_urgency + info["due_urgency"]
444
459
  info["urgency"] = weighted_urgency
@@ -187,7 +187,7 @@ def get_tasks(config, tasks, year, month, day):
187
187
  if m:
188
188
  due = task.get("due", "")
189
189
  if due:
190
- due = datetime.strptime(due, "%Y%m%dT%H%M%SZ")
190
+ due = parse_taskwarrior_timestamp(due)
191
191
  due = due - datetime.today()
192
192
  valid_tasks.append(
193
193
  {
@@ -219,7 +219,7 @@ def get_taskwarrior_date(date, _retry=True, taskrc=None):
219
219
  )
220
220
  date = date.stdout.strip()
221
221
  try:
222
- date = datetime.strptime(date, "%Y-%m-%dT%H:%M:%S")
222
+ date = parse_taskwarrior_timestamp(date)
223
223
  except Exception as _:
224
224
  if _retry:
225
225
  return get_taskwarrior_date("today+" + date, False, taskrc)
@@ -247,7 +247,7 @@ def tostring(value):
247
247
  return value.strftime("%Y-%m-%d %H:%M")
248
248
  elif isinstance(value, str):
249
249
  try:
250
- date = datetime.strptime(value, "%Y%m%dT%H%M%SZ")
250
+ date = parse_taskwarrior_timestamp(value)
251
251
  return date.strftime("%Y-%m-%d %H:%M")
252
252
  except ValueError:
253
253
  return value
@@ -275,7 +275,7 @@ def get_unplanned_tasks(config, tasks, taskrc=None):
275
275
  task
276
276
  for task in tasks
277
277
  if not task.get("due")
278
- or datetime.strptime(task["due"], "%Y%m%dT%H%M%SZ") <= due_limit
278
+ or parse_taskwarrior_timestamp(task["due"]) <= due_limit
279
279
  ]
280
280
 
281
281
 
@@ -298,6 +298,15 @@ def generate_report(config, constraint, verbose=False, force_update=False, taskr
298
298
  display_tasks_table(console, config, this_day_tasks)
299
299
 
300
300
 
301
+ def parse_taskwarrior_timestamp(value):
302
+ for fmt in ("%Y%m%dT%H%M%SZ", "%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%dT%H:%M:%S"):
303
+ try:
304
+ return datetime.strptime(value, fmt)
305
+ except ValueError:
306
+ pass
307
+ raise ValueError(value)
308
+
309
+
301
310
  def fetch_tasks(taskrc=None):
302
311
  """Fetch tasks from the task manager and return them as a JSON object."""
303
312
  from taskcheck.common import get_task_env