taskcheck 1.4.1__tar.gz → 1.5.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.
- {taskcheck-1.4.1/taskcheck.egg-info → taskcheck-1.5.0}/PKG-INFO +7 -2
- {taskcheck-1.4.1 → taskcheck-1.5.0}/README.md +6 -1
- {taskcheck-1.4.1 → taskcheck-1.5.0}/pyproject.toml +1 -1
- {taskcheck-1.4.1 → taskcheck-1.5.0}/taskcheck/__main__.py +33 -0
- {taskcheck-1.4.1 → taskcheck-1.5.0}/taskcheck/install.py +3 -0
- {taskcheck-1.4.1 → taskcheck-1.5.0}/taskcheck/report.py +26 -19
- taskcheck-1.5.0/taskcheck/theme.py +67 -0
- taskcheck-1.5.0/taskcheck/timeline.py +233 -0
- {taskcheck-1.4.1 → taskcheck-1.5.0/taskcheck.egg-info}/PKG-INFO +7 -2
- {taskcheck-1.4.1 → taskcheck-1.5.0}/taskcheck.egg-info/SOURCES.txt +3 -0
- {taskcheck-1.4.1 → taskcheck-1.5.0}/tests/test_main.py +28 -0
- taskcheck-1.5.0/tests/test_timeline.py +130 -0
- {taskcheck-1.4.1 → taskcheck-1.5.0}/LICENSE +0 -0
- {taskcheck-1.4.1 → taskcheck-1.5.0}/setup.cfg +0 -0
- {taskcheck-1.4.1 → taskcheck-1.5.0}/taskcheck/common.py +0 -0
- {taskcheck-1.4.1 → taskcheck-1.5.0}/taskcheck/google_calendar.py +0 -0
- {taskcheck-1.4.1 → taskcheck-1.5.0}/taskcheck/ical.py +0 -0
- {taskcheck-1.4.1 → taskcheck-1.5.0}/taskcheck/parallel.py +0 -0
- {taskcheck-1.4.1 → taskcheck-1.5.0}/taskcheck/undo.py +0 -0
- {taskcheck-1.4.1 → taskcheck-1.5.0}/taskcheck.egg-info/dependency_links.txt +0 -0
- {taskcheck-1.4.1 → taskcheck-1.5.0}/taskcheck.egg-info/entry_points.txt +0 -0
- {taskcheck-1.4.1 → taskcheck-1.5.0}/taskcheck.egg-info/requires.txt +0 -0
- {taskcheck-1.4.1 → taskcheck-1.5.0}/taskcheck.egg-info/top_level.txt +0 -0
- {taskcheck-1.4.1 → taskcheck-1.5.0}/tests/test_common.py +0 -0
- {taskcheck-1.4.1 → taskcheck-1.5.0}/tests/test_google_calendar.py +0 -0
- {taskcheck-1.4.1 → taskcheck-1.5.0}/tests/test_ical.py +0 -0
- {taskcheck-1.4.1 → taskcheck-1.5.0}/tests/test_install.py +0 -0
- {taskcheck-1.4.1 → taskcheck-1.5.0}/tests/test_parallel.py +0 -0
- {taskcheck-1.4.1 → taskcheck-1.5.0}/tests/test_report.py +0 -0
- {taskcheck-1.4.1 → taskcheck-1.5.0}/tests/test_undo.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: taskcheck
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.5.0
|
|
4
4
|
Summary: Automatic scheduler for taskwarrior
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -37,6 +37,7 @@ Use it if you want to:
|
|
|
37
37
|
- 🧠 Auto-adjust urgency when deadlines cannot be met
|
|
38
38
|
- 🔄 Force-refresh iCal calendars
|
|
39
39
|
- 📊 Custom reports for planned and unplanned tasks
|
|
40
|
+
- 🗓️ Project-grouped timelines with day, week, and month views
|
|
40
41
|
- 🎨 Report styling with emoji and extra attributes
|
|
41
42
|
- 🗓️ Block time with iCal or Google Calendar, including all-day events
|
|
42
43
|
|
|
@@ -76,10 +77,14 @@ monday = [[9, 12.30], [14, 17]]
|
|
|
76
77
|
tuesday = [[9, 12.30], [14, 17]]
|
|
77
78
|
```
|
|
78
79
|
|
|
79
|
-
### Reports
|
|
80
|
+
### Reports and timelines
|
|
80
81
|
|
|
81
82
|
- `taskcheck -r today` → tasks planned for today
|
|
82
83
|
- `taskcheck -r 1w` → tasks planned for the next week
|
|
84
|
+
- `taskcheck --timeline 1w` → automatic day/week/month timeline for the next week
|
|
85
|
+
- `taskcheck --timeline eom --zoom week` → weekly timeline through month end
|
|
86
|
+
|
|
87
|
+
Timeline rows follow dotted project levels such as `work.client`. Without `--zoom`, the most detailed view that fits the terminal width is selected: day, then week, then month. The track uses `▶` for a start, `━` for work, `◀` for an end, and `◆` for a deadline. Thin, heavy, and double rules separate days, weeks, and months. Project colors are deterministic and adapt to light or dark terminals when `COLORFGBG` is available.
|
|
83
88
|
|
|
84
89
|
## Reference
|
|
85
90
|
|
|
@@ -22,6 +22,7 @@ Use it if you want to:
|
|
|
22
22
|
- 🧠 Auto-adjust urgency when deadlines cannot be met
|
|
23
23
|
- 🔄 Force-refresh iCal calendars
|
|
24
24
|
- 📊 Custom reports for planned and unplanned tasks
|
|
25
|
+
- 🗓️ Project-grouped timelines with day, week, and month views
|
|
25
26
|
- 🎨 Report styling with emoji and extra attributes
|
|
26
27
|
- 🗓️ Block time with iCal or Google Calendar, including all-day events
|
|
27
28
|
|
|
@@ -61,10 +62,14 @@ monday = [[9, 12.30], [14, 17]]
|
|
|
61
62
|
tuesday = [[9, 12.30], [14, 17]]
|
|
62
63
|
```
|
|
63
64
|
|
|
64
|
-
### Reports
|
|
65
|
+
### Reports and timelines
|
|
65
66
|
|
|
66
67
|
- `taskcheck -r today` → tasks planned for today
|
|
67
68
|
- `taskcheck -r 1w` → tasks planned for the next week
|
|
69
|
+
- `taskcheck --timeline 1w` → automatic day/week/month timeline for the next week
|
|
70
|
+
- `taskcheck --timeline eom --zoom week` → weekly timeline through month end
|
|
71
|
+
|
|
72
|
+
Timeline rows follow dotted project levels such as `work.client`. Without `--zoom`, the most detailed view that fits the terminal width is selected: day, then week, then month. The track uses `▶` for a start, `━` for work, `◀` for an end, and `◆` for a deadline. Thin, heavy, and double rules separate days, weeks, and months. Project colors are deterministic and adapt to light or dark terminals when `COLORFGBG` is available.
|
|
68
73
|
|
|
69
74
|
## Reference
|
|
70
75
|
|
|
@@ -22,6 +22,18 @@ arg_parser.add_argument(
|
|
|
22
22
|
action="store",
|
|
23
23
|
help="Generate a report of the tasks based on the scheduling; can be any Taskwarrior datetime specification (e.g. today, tomorrow, eom, som, 1st, 2nd, etc.). It is considered as `by`, meaning that the report will be generated for all the days until the specified date and including it.",
|
|
24
24
|
)
|
|
25
|
+
arg_parser.add_argument(
|
|
26
|
+
"-t",
|
|
27
|
+
"--timeline",
|
|
28
|
+
action="store",
|
|
29
|
+
help="Show the schedule as a timeline through the specified Taskwarrior date.",
|
|
30
|
+
)
|
|
31
|
+
arg_parser.add_argument(
|
|
32
|
+
"--zoom",
|
|
33
|
+
choices=("auto", "day", "week", "month"),
|
|
34
|
+
default="auto",
|
|
35
|
+
help="Set timeline column granularity (default: auto).",
|
|
36
|
+
)
|
|
25
37
|
arg_parser.add_argument(
|
|
26
38
|
"-s",
|
|
27
39
|
"--schedule",
|
|
@@ -159,6 +171,27 @@ def main():
|
|
|
159
171
|
)
|
|
160
172
|
print_help = False
|
|
161
173
|
|
|
174
|
+
timeline = getattr(args, "timeline", None)
|
|
175
|
+
if isinstance(timeline, str):
|
|
176
|
+
from taskcheck.timeline import generate_timeline
|
|
177
|
+
|
|
178
|
+
scheduling_results = result if args.schedule and args.dry_run else None
|
|
179
|
+
zoom = getattr(args, "zoom", "day")
|
|
180
|
+
if zoom not in {"auto", "day", "week", "month"}:
|
|
181
|
+
zoom = "auto"
|
|
182
|
+
try:
|
|
183
|
+
config = load_config().get("timeline", {})
|
|
184
|
+
except FileNotFoundError:
|
|
185
|
+
config = {}
|
|
186
|
+
generate_timeline(
|
|
187
|
+
timeline,
|
|
188
|
+
zoom=zoom,
|
|
189
|
+
taskrc=args.taskrc,
|
|
190
|
+
scheduling_results=scheduling_results,
|
|
191
|
+
config=config,
|
|
192
|
+
)
|
|
193
|
+
print_help = False
|
|
194
|
+
|
|
162
195
|
if print_help:
|
|
163
196
|
arg_parser.print_help()
|
|
164
197
|
|
|
@@ -13,6 +13,8 @@ from rich.table import Table
|
|
|
13
13
|
from rich.text import Text
|
|
14
14
|
from rich.panel import Panel
|
|
15
15
|
|
|
16
|
+
from taskcheck.theme import detect_theme
|
|
17
|
+
|
|
16
18
|
from random_unicode_emoji import random_emoji
|
|
17
19
|
|
|
18
20
|
|
|
@@ -280,22 +282,23 @@ def get_unplanned_tasks(config, tasks, taskrc=None):
|
|
|
280
282
|
|
|
281
283
|
|
|
282
284
|
def generate_report(config, constraint, verbose=False, force_update=False, taskrc=None, scheduling_results=None):
|
|
283
|
-
|
|
285
|
+
report_config = config["report"]
|
|
286
|
+
theme = detect_theme(config=config.get("timeline", {}))
|
|
284
287
|
console = Console()
|
|
285
288
|
|
|
286
289
|
original_tasks = fetch_tasks(taskrc)
|
|
287
290
|
tasks = scheduling_results if scheduling_results is not None else original_tasks
|
|
288
291
|
|
|
289
|
-
if
|
|
290
|
-
unplanned_tasks = get_unplanned_tasks(
|
|
291
|
-
display_unplanned_tasks(console,
|
|
292
|
+
if report_config.get("include_unplanned"):
|
|
293
|
+
unplanned_tasks = get_unplanned_tasks(report_config, original_tasks, taskrc=taskrc)
|
|
294
|
+
display_unplanned_tasks(console, report_config, unplanned_tasks, theme=theme)
|
|
292
295
|
|
|
293
296
|
for year, month, day in get_days_in_constraint(constraint, taskrc=taskrc):
|
|
294
|
-
this_day_tasks = get_tasks(
|
|
297
|
+
this_day_tasks = get_tasks(report_config, tasks, year, month, day)
|
|
295
298
|
|
|
296
|
-
display_date_header(console, year, month, day)
|
|
299
|
+
display_date_header(console, year, month, day, theme=theme)
|
|
297
300
|
|
|
298
|
-
display_tasks_table(console,
|
|
301
|
+
display_tasks_table(console, report_config, this_day_tasks, theme=theme)
|
|
299
302
|
|
|
300
303
|
|
|
301
304
|
def parse_taskwarrior_timestamp(value):
|
|
@@ -321,24 +324,26 @@ def fetch_tasks(taskrc=None):
|
|
|
321
324
|
return json.loads(tasks.stdout)
|
|
322
325
|
|
|
323
326
|
|
|
324
|
-
def display_date_header(console, year, month, day):
|
|
327
|
+
def display_date_header(console, year, month, day, theme="dark"):
|
|
325
328
|
"""Display a date header with a calendar emoji."""
|
|
326
|
-
|
|
327
|
-
|
|
329
|
+
accent = "cyan" if theme == "dark" else "blue"
|
|
330
|
+
date_str = f":calendar: [bold {accent}]{year}-{month}-{day}[/bold {accent}]"
|
|
331
|
+
console.print(Panel(date_str, style=f"bold {accent}", expand=False))
|
|
328
332
|
|
|
329
333
|
|
|
330
|
-
def display_tasks_table(console, config, tasks):
|
|
334
|
+
def display_tasks_table(console, config, tasks, theme="dark"):
|
|
331
335
|
"""Display a table of tasks for a specific day."""
|
|
332
336
|
if tasks:
|
|
333
|
-
table = build_tasks_table(config, tasks)
|
|
337
|
+
table = build_tasks_table(config, tasks, theme=theme)
|
|
334
338
|
console.print(table)
|
|
335
339
|
else:
|
|
336
340
|
console.print("[italic dim]No tasks scheduled for this day.[/italic dim]")
|
|
337
341
|
|
|
338
342
|
|
|
339
|
-
def build_tasks_table(config, tasks):
|
|
343
|
+
def build_tasks_table(config, tasks, theme="dark"):
|
|
340
344
|
"""Build a Rich table for displaying tasks."""
|
|
341
|
-
|
|
345
|
+
header_style = "bold cyan" if theme == "dark" else "bold blue"
|
|
346
|
+
table = Table(show_header=True, header_style=header_style)
|
|
342
347
|
table.add_column("Task", style="dim", width=12)
|
|
343
348
|
table.add_column("Project", style="dim", width=12)
|
|
344
349
|
table.add_column("Description")
|
|
@@ -388,11 +393,12 @@ def get_task_emoji(config, task):
|
|
|
388
393
|
return emoji
|
|
389
394
|
|
|
390
395
|
|
|
391
|
-
def display_unplanned_tasks(console, config, tasks):
|
|
396
|
+
def display_unplanned_tasks(console, config, tasks, theme="dark"):
|
|
392
397
|
"""Display unplanned tasks if any are found."""
|
|
393
398
|
if tasks:
|
|
394
|
-
|
|
395
|
-
|
|
399
|
+
accent = "cyan" if theme == "dark" else "blue"
|
|
400
|
+
table = build_unplanned_tasks_table(config, tasks, theme=theme)
|
|
401
|
+
console.print(Panel("Unplanned Tasks", style=f"bold {accent}", expand=False))
|
|
396
402
|
console.print(table)
|
|
397
403
|
else:
|
|
398
404
|
console.print(
|
|
@@ -404,9 +410,10 @@ def display_unplanned_tasks(console, config, tasks):
|
|
|
404
410
|
)
|
|
405
411
|
|
|
406
412
|
|
|
407
|
-
def build_unplanned_tasks_table(config, tasks):
|
|
413
|
+
def build_unplanned_tasks_table(config, tasks, theme="dark"):
|
|
408
414
|
"""Build a Rich table for displaying unplanned tasks."""
|
|
409
|
-
|
|
415
|
+
header_style = "bold cyan" if theme == "dark" else "bold blue"
|
|
416
|
+
table = Table(show_header=True, header_style=header_style)
|
|
410
417
|
table.add_column("Task", style="dim", width=12)
|
|
411
418
|
table.add_column("Project", style="dim", width=12)
|
|
412
419
|
table.add_column("Description")
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import selectors
|
|
4
|
+
import termios
|
|
5
|
+
import tty
|
|
6
|
+
|
|
7
|
+
OSC11_QUERY = b"\x1b]11;?\x07"
|
|
8
|
+
OSC11_RESPONSE = re.compile(rb"\x1b]11;(?P<spec>[^\x07\x1b]+)(?:\x07|\x1b\\)")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _parse_osc11_background(spec):
|
|
12
|
+
text = spec.decode(errors="ignore").strip().lower()
|
|
13
|
+
if text.startswith("rgb:"):
|
|
14
|
+
channels = text[4:].split("/")[:3]
|
|
15
|
+
try:
|
|
16
|
+
rgb = [int(channel[: len(channel) // 2 or 1], 16) for channel in channels]
|
|
17
|
+
except ValueError:
|
|
18
|
+
return None
|
|
19
|
+
return "light" if sum(rgb) / 3 >= 128 else "dark"
|
|
20
|
+
return None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _probe_osc11_background(timeout=0.2):
|
|
24
|
+
try:
|
|
25
|
+
with open("/dev/tty", "rb+", buffering=0) as tty_file:
|
|
26
|
+
fd = tty_file.fileno()
|
|
27
|
+
old = termios.tcgetattr(fd)
|
|
28
|
+
try:
|
|
29
|
+
tty.setraw(fd)
|
|
30
|
+
tty_file.write(OSC11_QUERY)
|
|
31
|
+
sel = selectors.DefaultSelector()
|
|
32
|
+
sel.register(tty_file, selectors.EVENT_READ)
|
|
33
|
+
events = sel.select(timeout)
|
|
34
|
+
if not events:
|
|
35
|
+
return None
|
|
36
|
+
data = tty_file.read(128)
|
|
37
|
+
finally:
|
|
38
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
39
|
+
except (OSError, termios.error):
|
|
40
|
+
return None
|
|
41
|
+
if not data:
|
|
42
|
+
return None
|
|
43
|
+
match = OSC11_RESPONSE.search(data)
|
|
44
|
+
if not match:
|
|
45
|
+
return None
|
|
46
|
+
return _parse_osc11_background(match.group("spec"))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def detect_theme(environ=None, config=None):
|
|
50
|
+
environ = os.environ if environ is None else environ
|
|
51
|
+
config = config or {}
|
|
52
|
+
for key in ("TASKCHECK_THEME", "PI_THEME"):
|
|
53
|
+
value = environ.get(key, "").strip().lower()
|
|
54
|
+
if value in {"light", "dark"}:
|
|
55
|
+
return value
|
|
56
|
+
value = str(config.get("theme", "")).strip().lower()
|
|
57
|
+
if value in {"light", "dark"}:
|
|
58
|
+
return value
|
|
59
|
+
osc11 = _probe_osc11_background()
|
|
60
|
+
if osc11:
|
|
61
|
+
return osc11
|
|
62
|
+
value = environ.get("COLORFGBG", "")
|
|
63
|
+
try:
|
|
64
|
+
background = int(value.split(";")[-1])
|
|
65
|
+
except ValueError:
|
|
66
|
+
return "dark"
|
|
67
|
+
return "light" if background >= 8 else "dark"
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import colorsys
|
|
2
|
+
import hashlib
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import date, datetime, timedelta
|
|
6
|
+
|
|
7
|
+
from rich.console import Console, Group
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
|
|
10
|
+
from taskcheck.report import (
|
|
11
|
+
fetch_tasks,
|
|
12
|
+
get_taskwarrior_date,
|
|
13
|
+
parse_taskwarrior_timestamp,
|
|
14
|
+
)
|
|
15
|
+
from taskcheck.theme import detect_theme
|
|
16
|
+
|
|
17
|
+
SCHEDULING_DATE = re.compile(r"^(\d{4}-\d{2}-\d{2})\s+-\s+P")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Period:
|
|
22
|
+
start: date
|
|
23
|
+
end: date
|
|
24
|
+
label: str
|
|
25
|
+
width: int
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def project_color(project, theme):
|
|
31
|
+
digest = hashlib.sha256((project or "No project").encode()).digest()
|
|
32
|
+
hue = int.from_bytes(digest[:3], "big") / 0xFFFFFF
|
|
33
|
+
lightness = 0.32 if theme == "light" else 0.72
|
|
34
|
+
red, green, blue = colorsys.hls_to_rgb(hue, lightness, 0.68)
|
|
35
|
+
return f"#{round(red * 255):02x}{round(green * 255):02x}{round(blue * 255):02x}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def parse_scheduling_dates(task):
|
|
39
|
+
dates = []
|
|
40
|
+
for line in task.get("scheduling", "").splitlines():
|
|
41
|
+
match = SCHEDULING_DATE.match(line)
|
|
42
|
+
if match:
|
|
43
|
+
dates.append(date.fromisoformat(match.group(1)))
|
|
44
|
+
return sorted(set(dates))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _deadline(task):
|
|
48
|
+
value = task.get("due")
|
|
49
|
+
if not value:
|
|
50
|
+
return None
|
|
51
|
+
try:
|
|
52
|
+
return parse_taskwarrior_timestamp(value).date()
|
|
53
|
+
except ValueError:
|
|
54
|
+
try:
|
|
55
|
+
return date.fromisoformat(value[:10])
|
|
56
|
+
except ValueError:
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _periods(start, end, zoom):
|
|
61
|
+
periods = []
|
|
62
|
+
current = start
|
|
63
|
+
if zoom == "week":
|
|
64
|
+
current -= timedelta(days=current.weekday())
|
|
65
|
+
elif zoom == "month":
|
|
66
|
+
current = current.replace(day=1)
|
|
67
|
+
|
|
68
|
+
while current <= end:
|
|
69
|
+
if zoom == "day":
|
|
70
|
+
period_end = current
|
|
71
|
+
label = current.strftime("%d")
|
|
72
|
+
width = 3
|
|
73
|
+
next_period = current + timedelta(days=1)
|
|
74
|
+
elif zoom == "week":
|
|
75
|
+
period_end = current + timedelta(days=6)
|
|
76
|
+
label = f"W{current.isocalendar().week:02d}"
|
|
77
|
+
width = 5
|
|
78
|
+
next_period = current + timedelta(days=7)
|
|
79
|
+
else:
|
|
80
|
+
if current.month == 12:
|
|
81
|
+
next_period = date(current.year + 1, 1, 1)
|
|
82
|
+
else:
|
|
83
|
+
next_period = date(current.year, current.month + 1, 1)
|
|
84
|
+
period_end = next_period - timedelta(days=1)
|
|
85
|
+
label = current.strftime("%b %Y")
|
|
86
|
+
width = 8
|
|
87
|
+
periods.append(Period(current, period_end, label, width))
|
|
88
|
+
current = next_period
|
|
89
|
+
return periods
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def select_zoom(start, end, columns, label_width):
|
|
93
|
+
for zoom in ("day", "week", "month"):
|
|
94
|
+
periods = _periods(start, end, zoom)
|
|
95
|
+
if label_width + sum(period.width + 1 for period in periods) <= columns:
|
|
96
|
+
return zoom
|
|
97
|
+
return "month"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _separator(periods, index, zoom):
|
|
101
|
+
if index == 0:
|
|
102
|
+
return "║"
|
|
103
|
+
current = periods[index].start
|
|
104
|
+
previous = periods[index - 1].start
|
|
105
|
+
if zoom == "month" or (current.year, current.month) != (
|
|
106
|
+
previous.year,
|
|
107
|
+
previous.month,
|
|
108
|
+
):
|
|
109
|
+
return "║"
|
|
110
|
+
if zoom == "week" or current.weekday() == 0:
|
|
111
|
+
return "┃"
|
|
112
|
+
return "│"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _cell(task_dates, deadline, period):
|
|
116
|
+
scheduled = [day for day in task_dates if period.start <= day <= period.end]
|
|
117
|
+
starts = bool(task_dates) and period.start <= task_dates[0] <= period.end
|
|
118
|
+
ends = bool(task_dates) and period.start <= task_dates[-1] <= period.end
|
|
119
|
+
due = deadline is not None and period.start <= deadline <= period.end
|
|
120
|
+
|
|
121
|
+
if starts and ends:
|
|
122
|
+
core = "▶━◀"
|
|
123
|
+
elif starts:
|
|
124
|
+
core = "▶━━"
|
|
125
|
+
elif ends:
|
|
126
|
+
core = "━━◀"
|
|
127
|
+
elif scheduled:
|
|
128
|
+
core = "━━━"
|
|
129
|
+
else:
|
|
130
|
+
core = " "
|
|
131
|
+
if due:
|
|
132
|
+
core = core[0] + "◆" + core[2]
|
|
133
|
+
return core.center(period.width)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _label(text, width):
|
|
137
|
+
if len(text) <= width:
|
|
138
|
+
return text.ljust(width)
|
|
139
|
+
return text[: width - 1] + "…"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _header(periods, label_width, zoom):
|
|
143
|
+
lines = []
|
|
144
|
+
if zoom in {"day", "week"}:
|
|
145
|
+
context = Text(" " * label_width)
|
|
146
|
+
previous_month = None
|
|
147
|
+
for index, period in enumerate(periods):
|
|
148
|
+
context.append(_separator(periods, index, zoom), style="dim")
|
|
149
|
+
month = (period.start.year, period.start.month)
|
|
150
|
+
label = period.start.strftime("%b") if month != previous_month else ""
|
|
151
|
+
context.append(label.center(period.width), style="bold cyan")
|
|
152
|
+
previous_month = month
|
|
153
|
+
lines.append(context)
|
|
154
|
+
|
|
155
|
+
line = Text(" " * label_width)
|
|
156
|
+
for index, period in enumerate(periods):
|
|
157
|
+
line.append(_separator(periods, index, zoom), style="dim")
|
|
158
|
+
line.append(period.label.center(period.width), style="bold")
|
|
159
|
+
lines.append(line)
|
|
160
|
+
return lines
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def build_timeline(
|
|
164
|
+
tasks, start, end, zoom="auto", theme=None, config=None, columns=80
|
|
165
|
+
):
|
|
166
|
+
if zoom not in {"auto", "day", "week", "month"}:
|
|
167
|
+
raise ValueError(f"Unsupported zoom: {zoom}")
|
|
168
|
+
planned = [(task, parse_scheduling_dates(task)) for task in tasks]
|
|
169
|
+
planned = [(task, dates) for task, dates in planned if dates]
|
|
170
|
+
planned.sort(key=lambda item: (item[0].get("project", ""), item[0].get("id", 0)))
|
|
171
|
+
labels = [
|
|
172
|
+
f"#{task.get('id', '?')} {task.get('description', '')}" for task, _ in planned
|
|
173
|
+
]
|
|
174
|
+
label_width = min(36, max([20, *(len(label) + 6 for label in labels)]))
|
|
175
|
+
zoom = select_zoom(start, end, columns, label_width) if zoom == "auto" else zoom
|
|
176
|
+
periods = _periods(start, end, zoom)
|
|
177
|
+
theme = theme or detect_theme(config=config)
|
|
178
|
+
|
|
179
|
+
lines = [
|
|
180
|
+
Text(
|
|
181
|
+
f"Timeline · {zoom} · ▶ start · ━ work · ◀ end · ◆ deadline", style="bold"
|
|
182
|
+
),
|
|
183
|
+
*_header(periods, label_width, zoom),
|
|
184
|
+
]
|
|
185
|
+
previous_parts = []
|
|
186
|
+
for task, dates in planned:
|
|
187
|
+
project = task.get("project") or "No project"
|
|
188
|
+
parts = project.split(".")
|
|
189
|
+
common = 0
|
|
190
|
+
while (
|
|
191
|
+
common < min(len(parts), len(previous_parts))
|
|
192
|
+
and parts[common] == previous_parts[common]
|
|
193
|
+
):
|
|
194
|
+
common += 1
|
|
195
|
+
for depth in range(common, len(parts)):
|
|
196
|
+
path = ".".join(parts[: depth + 1])
|
|
197
|
+
group = Text(
|
|
198
|
+
_label(" " * depth + parts[depth], label_width),
|
|
199
|
+
style=f"bold {project_color(path, theme)}",
|
|
200
|
+
)
|
|
201
|
+
for index, period in enumerate(periods):
|
|
202
|
+
group.append(_separator(periods, index, zoom), style="dim")
|
|
203
|
+
group.append(" " * period.width)
|
|
204
|
+
lines.append(group)
|
|
205
|
+
previous_parts = parts
|
|
206
|
+
|
|
207
|
+
color = project_color(project, theme)
|
|
208
|
+
row = Text(
|
|
209
|
+
_label(
|
|
210
|
+
" " * len(parts)
|
|
211
|
+
+ f"#{task.get('id', '?')} {task.get('description', '')}",
|
|
212
|
+
label_width,
|
|
213
|
+
),
|
|
214
|
+
style=color,
|
|
215
|
+
)
|
|
216
|
+
deadline = _deadline(task)
|
|
217
|
+
for index, period in enumerate(periods):
|
|
218
|
+
row.append(_separator(periods, index, zoom), style="dim")
|
|
219
|
+
row.append(_cell(dates, deadline, period), style=f"bold {color}")
|
|
220
|
+
lines.append(row)
|
|
221
|
+
return Group(*lines)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def generate_timeline(constraint, zoom="auto", taskrc=None, scheduling_results=None, config=None):
|
|
225
|
+
start = datetime.today().date()
|
|
226
|
+
end = get_taskwarrior_date(constraint, taskrc=taskrc).date()
|
|
227
|
+
tasks = (
|
|
228
|
+
scheduling_results if scheduling_results is not None else fetch_tasks(taskrc)
|
|
229
|
+
)
|
|
230
|
+
console = Console()
|
|
231
|
+
console.print(
|
|
232
|
+
build_timeline(tasks, start, end, zoom, config=config, columns=console.width)
|
|
233
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: taskcheck
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.5.0
|
|
4
4
|
Summary: Automatic scheduler for taskwarrior
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -37,6 +37,7 @@ Use it if you want to:
|
|
|
37
37
|
- 🧠 Auto-adjust urgency when deadlines cannot be met
|
|
38
38
|
- 🔄 Force-refresh iCal calendars
|
|
39
39
|
- 📊 Custom reports for planned and unplanned tasks
|
|
40
|
+
- 🗓️ Project-grouped timelines with day, week, and month views
|
|
40
41
|
- 🎨 Report styling with emoji and extra attributes
|
|
41
42
|
- 🗓️ Block time with iCal or Google Calendar, including all-day events
|
|
42
43
|
|
|
@@ -76,10 +77,14 @@ monday = [[9, 12.30], [14, 17]]
|
|
|
76
77
|
tuesday = [[9, 12.30], [14, 17]]
|
|
77
78
|
```
|
|
78
79
|
|
|
79
|
-
### Reports
|
|
80
|
+
### Reports and timelines
|
|
80
81
|
|
|
81
82
|
- `taskcheck -r today` → tasks planned for today
|
|
82
83
|
- `taskcheck -r 1w` → tasks planned for the next week
|
|
84
|
+
- `taskcheck --timeline 1w` → automatic day/week/month timeline for the next week
|
|
85
|
+
- `taskcheck --timeline eom --zoom week` → weekly timeline through month end
|
|
86
|
+
|
|
87
|
+
Timeline rows follow dotted project levels such as `work.client`. Without `--zoom`, the most detailed view that fits the terminal width is selected: day, then week, then month. The track uses `▶` for a start, `━` for work, `◀` for an end, and `◆` for a deadline. Thin, heavy, and double rules separate days, weeks, and months. Project colors are deterministic and adapt to light or dark terminals when `COLORFGBG` is available.
|
|
83
88
|
|
|
84
89
|
## Reference
|
|
85
90
|
|
|
@@ -8,6 +8,8 @@ taskcheck/ical.py
|
|
|
8
8
|
taskcheck/install.py
|
|
9
9
|
taskcheck/parallel.py
|
|
10
10
|
taskcheck/report.py
|
|
11
|
+
taskcheck/theme.py
|
|
12
|
+
taskcheck/timeline.py
|
|
11
13
|
taskcheck/undo.py
|
|
12
14
|
taskcheck.egg-info/PKG-INFO
|
|
13
15
|
taskcheck.egg-info/SOURCES.txt
|
|
@@ -22,4 +24,5 @@ tests/test_install.py
|
|
|
22
24
|
tests/test_main.py
|
|
23
25
|
tests/test_parallel.py
|
|
24
26
|
tests/test_report.py
|
|
27
|
+
tests/test_timeline.py
|
|
25
28
|
tests/test_undo.py
|
|
@@ -14,6 +14,8 @@ class TestArgumentParsing:
|
|
|
14
14
|
assert args.verbose is False
|
|
15
15
|
assert args.install is False
|
|
16
16
|
assert args.report is None
|
|
17
|
+
assert args.timeline is None
|
|
18
|
+
assert args.zoom == "auto"
|
|
17
19
|
assert args.schedule is False
|
|
18
20
|
assert args.undo is False
|
|
19
21
|
assert args.force_update is False
|
|
@@ -74,6 +76,12 @@ class TestArgumentParsing:
|
|
|
74
76
|
assert args.taskrc == "/test"
|
|
75
77
|
assert args.urgency_weight == 0.3
|
|
76
78
|
|
|
79
|
+
def test_timeline_arguments(self):
|
|
80
|
+
args = arg_parser.parse_args(["--timeline", "eom", "--zoom", "month"])
|
|
81
|
+
|
|
82
|
+
assert args.timeline == "eom"
|
|
83
|
+
assert args.zoom == "month"
|
|
84
|
+
|
|
77
85
|
def test_urgency_weight_argument(self):
|
|
78
86
|
"""Test urgency weight argument parsing."""
|
|
79
87
|
args = arg_parser.parse_args(["--urgency-weight", "0.7"])
|
|
@@ -592,6 +600,26 @@ class TestMainFunction:
|
|
|
592
600
|
mock_load.assert_not_called()
|
|
593
601
|
|
|
594
602
|
|
|
603
|
+
@patch("taskcheck.timeline.generate_timeline")
|
|
604
|
+
def test_main_timeline_command(self, mock_generate, test_taskrc):
|
|
605
|
+
with patch(
|
|
606
|
+
"sys.argv",
|
|
607
|
+
[
|
|
608
|
+
"taskcheck",
|
|
609
|
+
"--timeline",
|
|
610
|
+
"eom",
|
|
611
|
+
"--zoom",
|
|
612
|
+
"month",
|
|
613
|
+
"--taskrc",
|
|
614
|
+
test_taskrc,
|
|
615
|
+
],
|
|
616
|
+
):
|
|
617
|
+
main()
|
|
618
|
+
|
|
619
|
+
mock_generate.assert_called_once()
|
|
620
|
+
assert mock_generate.call_args.kwargs["config"] == {}
|
|
621
|
+
|
|
622
|
+
|
|
595
623
|
class TestImportErrorHandling:
|
|
596
624
|
def test_install_import_error(self):
|
|
597
625
|
"""Test behavior when install module cannot be imported."""
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
from datetime import date
|
|
2
|
+
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
|
|
5
|
+
from taskcheck.timeline import (
|
|
6
|
+
build_timeline,
|
|
7
|
+
detect_theme,
|
|
8
|
+
parse_scheduling_dates,
|
|
9
|
+
project_color,
|
|
10
|
+
select_zoom,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def render(renderable):
|
|
15
|
+
console = Console(width=200, record=True, color_system=None)
|
|
16
|
+
console.print(renderable)
|
|
17
|
+
return console.export_text()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_parse_scheduling_dates_ignores_invalid_lines():
|
|
21
|
+
task = {"scheduling": "2024-01-02 - PT2H\ninvalid\n2024-01-03 - PT30M"}
|
|
22
|
+
|
|
23
|
+
assert parse_scheduling_dates(task) == [date(2024, 1, 2), date(2024, 1, 3)]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_day_timeline_groups_projects_and_marks_events():
|
|
27
|
+
tasks = [
|
|
28
|
+
{
|
|
29
|
+
"id": 1,
|
|
30
|
+
"project": "work.client",
|
|
31
|
+
"description": "Build feature",
|
|
32
|
+
"scheduling": "2024-01-01 - PT2H\n2024-01-02 - PT2H\n2024-01-03 - PT1H",
|
|
33
|
+
"due": "20240102T170000Z",
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"id": 2,
|
|
37
|
+
"project": "home",
|
|
38
|
+
"description": "Repair shelf",
|
|
39
|
+
"scheduling": "2024-01-02 - PT1H",
|
|
40
|
+
},
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
output = render(
|
|
44
|
+
build_timeline(tasks, date(2024, 1, 1), date(2024, 1, 3), "day", "dark")
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
assert "▶ start" in output
|
|
48
|
+
assert "━ work" in output
|
|
49
|
+
assert "◀ end" in output
|
|
50
|
+
assert "◆ deadline" in output
|
|
51
|
+
assert "work" in output
|
|
52
|
+
assert " client" in output
|
|
53
|
+
assert "#1 Build feature" in output
|
|
54
|
+
assert "▶━━│━◆━│━━◀" in output
|
|
55
|
+
assert "▶━◀" in output
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_select_zoom_uses_the_most_detailed_view_that_fits():
|
|
59
|
+
start = date(2024, 1, 1)
|
|
60
|
+
end = date(2024, 1, 31)
|
|
61
|
+
|
|
62
|
+
assert select_zoom(start, end, 200, 20) == "day"
|
|
63
|
+
assert select_zoom(start, end, 80, 20) == "week"
|
|
64
|
+
assert select_zoom(start, end, 40, 20) == "month"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_day_boundaries_distinguish_week_and_month():
|
|
68
|
+
output = render(
|
|
69
|
+
build_timeline([], date(2024, 1, 28), date(2024, 2, 2), "day", "dark")
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
assert "┃" in output # week boundary
|
|
73
|
+
assert "║" in output # month boundary
|
|
74
|
+
assert "│" in output # day boundary
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_week_and_month_zoom_aggregate_events():
|
|
78
|
+
tasks = [
|
|
79
|
+
{
|
|
80
|
+
"id": 1,
|
|
81
|
+
"project": "work",
|
|
82
|
+
"description": "Long task",
|
|
83
|
+
"scheduling": "2024-01-30 - PT1H\n2024-02-12 - PT1H",
|
|
84
|
+
"due": "20240215T120000Z",
|
|
85
|
+
}
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
week = render(
|
|
89
|
+
build_timeline(tasks, date(2024, 1, 29), date(2024, 2, 18), "week", "dark")
|
|
90
|
+
)
|
|
91
|
+
month = render(
|
|
92
|
+
build_timeline(tasks, date(2024, 1, 1), date(2024, 2, 29), "month", "dark")
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
assert "W05" in week and "W07" in week
|
|
96
|
+
assert "Jan 2024" in month and "Feb 2024" in month
|
|
97
|
+
assert "▶" in week and "◀" in week and "◆" in week
|
|
98
|
+
assert "▶" in month and "◀" in month and "◆" in month
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_auto_zoom_uses_available_columns():
|
|
102
|
+
output = render(
|
|
103
|
+
build_timeline([], date(2024, 1, 1), date(2024, 1, 31), columns=80)
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
assert "Timeline · week" in output
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_build_timeline_uses_config_theme():
|
|
110
|
+
output = render(
|
|
111
|
+
build_timeline([], date(2024, 1, 1), date(2024, 1, 1), "day", config={"theme": "light"})
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
assert "Timeline · day" in output
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_theme_detection_and_project_colors(monkeypatch):
|
|
118
|
+
monkeypatch.setattr("taskcheck.theme._probe_osc11_background", lambda: "light")
|
|
119
|
+
assert detect_theme() == "light"
|
|
120
|
+
monkeypatch.setattr("taskcheck.theme._probe_osc11_background", lambda: None)
|
|
121
|
+
monkeypatch.setenv("COLORFGBG", "0;15")
|
|
122
|
+
assert detect_theme() == "light"
|
|
123
|
+
monkeypatch.setenv("COLORFGBG", "15;0")
|
|
124
|
+
assert detect_theme() == "dark"
|
|
125
|
+
assert detect_theme(config={"theme": "light"}) == "light"
|
|
126
|
+
assert detect_theme(config={"theme": "dark"}) == "dark"
|
|
127
|
+
|
|
128
|
+
assert project_color("work", "dark") == project_color("work", "dark")
|
|
129
|
+
assert project_color("work", "dark") != project_color("home", "dark")
|
|
130
|
+
assert project_color("work", "dark") != project_color("work", "light")
|
|
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
|
|
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
|