taskcheck 1.4.1__tar.gz → 1.5.1__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.1}/PKG-INFO +10 -3
- {taskcheck-1.4.1 → taskcheck-1.5.1}/README.md +9 -2
- {taskcheck-1.4.1 → taskcheck-1.5.1}/pyproject.toml +1 -1
- {taskcheck-1.4.1 → taskcheck-1.5.1}/taskcheck/__main__.py +40 -3
- taskcheck-1.5.1/taskcheck/dry_run.py +22 -0
- {taskcheck-1.4.1 → taskcheck-1.5.1}/taskcheck/install.py +3 -0
- {taskcheck-1.4.1 → taskcheck-1.5.1}/taskcheck/report.py +26 -19
- taskcheck-1.5.1/taskcheck/theme.py +67 -0
- taskcheck-1.5.1/taskcheck/timeline.py +233 -0
- {taskcheck-1.4.1 → taskcheck-1.5.1/taskcheck.egg-info}/PKG-INFO +10 -3
- {taskcheck-1.4.1 → taskcheck-1.5.1}/taskcheck.egg-info/SOURCES.txt +4 -0
- {taskcheck-1.4.1 → taskcheck-1.5.1}/tests/test_main.py +72 -1
- taskcheck-1.5.1/tests/test_timeline.py +130 -0
- {taskcheck-1.4.1 → taskcheck-1.5.1}/LICENSE +0 -0
- {taskcheck-1.4.1 → taskcheck-1.5.1}/setup.cfg +0 -0
- {taskcheck-1.4.1 → taskcheck-1.5.1}/taskcheck/common.py +0 -0
- {taskcheck-1.4.1 → taskcheck-1.5.1}/taskcheck/google_calendar.py +0 -0
- {taskcheck-1.4.1 → taskcheck-1.5.1}/taskcheck/ical.py +0 -0
- {taskcheck-1.4.1 → taskcheck-1.5.1}/taskcheck/parallel.py +0 -0
- {taskcheck-1.4.1 → taskcheck-1.5.1}/taskcheck/undo.py +0 -0
- {taskcheck-1.4.1 → taskcheck-1.5.1}/taskcheck.egg-info/dependency_links.txt +0 -0
- {taskcheck-1.4.1 → taskcheck-1.5.1}/taskcheck.egg-info/entry_points.txt +0 -0
- {taskcheck-1.4.1 → taskcheck-1.5.1}/taskcheck.egg-info/requires.txt +0 -0
- {taskcheck-1.4.1 → taskcheck-1.5.1}/taskcheck.egg-info/top_level.txt +0 -0
- {taskcheck-1.4.1 → taskcheck-1.5.1}/tests/test_common.py +0 -0
- {taskcheck-1.4.1 → taskcheck-1.5.1}/tests/test_google_calendar.py +0 -0
- {taskcheck-1.4.1 → taskcheck-1.5.1}/tests/test_ical.py +0 -0
- {taskcheck-1.4.1 → taskcheck-1.5.1}/tests/test_install.py +0 -0
- {taskcheck-1.4.1 → taskcheck-1.5.1}/tests/test_parallel.py +0 -0
- {taskcheck-1.4.1 → taskcheck-1.5.1}/tests/test_report.py +0 -0
- {taskcheck-1.4.1 → taskcheck-1.5.1}/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.1
|
|
4
4
|
Summary: Automatic scheduler for taskwarrior
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -32,11 +32,12 @@ Use it if you want to:
|
|
|
32
32
|
- ✨ Auto-schedule tasks from working hours + calendar blocks
|
|
33
33
|
- 🧭 Support complex working-hour maps
|
|
34
34
|
- ⏱️ Consider urgency, due dates, and dependencies
|
|
35
|
-
- 🧪
|
|
35
|
+
- 🧪 Persisted dry-run previews for reports and timelines
|
|
36
36
|
- ↩️ Undo the latest schedule update
|
|
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,16 @@ 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
|
+
Create a preview with `taskcheck --schedule --dry-run`. It does not update Taskwarrior; instead, it saves the generated schedule to the Taskwarrior data directory. Reuse it with `taskcheck --report today --dry-run` or `taskcheck --timeline 1w --dry-run`.
|
|
88
|
+
|
|
89
|
+
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 through OSC 11, with config and environment fallbacks.
|
|
83
90
|
|
|
84
91
|
## Reference
|
|
85
92
|
|
|
@@ -17,11 +17,12 @@ Use it if you want to:
|
|
|
17
17
|
- ✨ Auto-schedule tasks from working hours + calendar blocks
|
|
18
18
|
- 🧭 Support complex working-hour maps
|
|
19
19
|
- ⏱️ Consider urgency, due dates, and dependencies
|
|
20
|
-
- 🧪
|
|
20
|
+
- 🧪 Persisted dry-run previews for reports and timelines
|
|
21
21
|
- ↩️ Undo the latest schedule update
|
|
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,16 @@ 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
|
+
Create a preview with `taskcheck --schedule --dry-run`. It does not update Taskwarrior; instead, it saves the generated schedule to the Taskwarrior data directory. Reuse it with `taskcheck --report today --dry-run` or `taskcheck --timeline 1w --dry-run`.
|
|
73
|
+
|
|
74
|
+
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 through OSC 11, with config and environment fallbacks.
|
|
68
75
|
|
|
69
76
|
## Reference
|
|
70
77
|
|
|
@@ -4,6 +4,7 @@ from importlib.metadata import version
|
|
|
4
4
|
|
|
5
5
|
from taskcheck.parallel import check_tasks_parallel
|
|
6
6
|
from taskcheck.common import config_dir
|
|
7
|
+
from taskcheck.dry_run import load_dry_run_results, save_dry_run_results
|
|
7
8
|
|
|
8
9
|
arg_parser = argparse.ArgumentParser()
|
|
9
10
|
arg_parser.add_argument("--version", action="version", version=f"taskcheck {version('taskcheck')}")
|
|
@@ -22,6 +23,18 @@ arg_parser.add_argument(
|
|
|
22
23
|
action="store",
|
|
23
24
|
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
25
|
)
|
|
26
|
+
arg_parser.add_argument(
|
|
27
|
+
"-t",
|
|
28
|
+
"--timeline",
|
|
29
|
+
action="store",
|
|
30
|
+
help="Show the schedule as a timeline through the specified Taskwarrior date.",
|
|
31
|
+
)
|
|
32
|
+
arg_parser.add_argument(
|
|
33
|
+
"--zoom",
|
|
34
|
+
choices=("auto", "day", "week", "month"),
|
|
35
|
+
default="auto",
|
|
36
|
+
help="Set timeline column granularity (default: auto).",
|
|
37
|
+
)
|
|
25
38
|
arg_parser.add_argument(
|
|
26
39
|
"-s",
|
|
27
40
|
"--schedule",
|
|
@@ -139,6 +152,8 @@ def main():
|
|
|
139
152
|
config,
|
|
140
153
|
**check_tasks_kwargs,
|
|
141
154
|
)
|
|
155
|
+
if args.dry_run and result is not None:
|
|
156
|
+
save_dry_run_results(result, taskrc=args.taskrc)
|
|
142
157
|
print_help = False
|
|
143
158
|
|
|
144
159
|
if args.report:
|
|
@@ -146,9 +161,8 @@ def main():
|
|
|
146
161
|
|
|
147
162
|
config = load_config()
|
|
148
163
|
scheduling_results = None
|
|
149
|
-
if args.
|
|
150
|
-
|
|
151
|
-
scheduling_results = result
|
|
164
|
+
if args.dry_run:
|
|
165
|
+
scheduling_results = result if args.schedule else load_dry_run_results(args.taskrc)
|
|
152
166
|
generate_report(
|
|
153
167
|
config,
|
|
154
168
|
args.report,
|
|
@@ -159,6 +173,29 @@ def main():
|
|
|
159
173
|
)
|
|
160
174
|
print_help = False
|
|
161
175
|
|
|
176
|
+
timeline = getattr(args, "timeline", None)
|
|
177
|
+
if isinstance(timeline, str):
|
|
178
|
+
from taskcheck.timeline import generate_timeline
|
|
179
|
+
|
|
180
|
+
scheduling_results = result if args.schedule and args.dry_run else None
|
|
181
|
+
if args.dry_run and scheduling_results is None:
|
|
182
|
+
scheduling_results = load_dry_run_results(args.taskrc)
|
|
183
|
+
zoom = getattr(args, "zoom", "day")
|
|
184
|
+
if zoom not in {"auto", "day", "week", "month"}:
|
|
185
|
+
zoom = "auto"
|
|
186
|
+
try:
|
|
187
|
+
config = load_config().get("timeline", {})
|
|
188
|
+
except FileNotFoundError:
|
|
189
|
+
config = {}
|
|
190
|
+
generate_timeline(
|
|
191
|
+
timeline,
|
|
192
|
+
zoom=zoom,
|
|
193
|
+
taskrc=args.taskrc,
|
|
194
|
+
scheduling_results=scheduling_results,
|
|
195
|
+
config=config,
|
|
196
|
+
)
|
|
197
|
+
print_help = False
|
|
198
|
+
|
|
162
199
|
if print_help:
|
|
163
200
|
arg_parser.print_help()
|
|
164
201
|
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from taskcheck.undo import get_task_data_dir
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def get_dry_run_path(taskrc=None):
|
|
8
|
+
return get_task_data_dir(taskrc) / "taskcheck" / "dry-run.json"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def save_dry_run_results(results, taskrc=None):
|
|
12
|
+
path = get_dry_run_path(taskrc)
|
|
13
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
14
|
+
path.write_text(json.dumps(results, indent=2, sort_keys=True))
|
|
15
|
+
return path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load_dry_run_results(taskrc=None):
|
|
19
|
+
path = get_dry_run_path(taskrc)
|
|
20
|
+
if not path.exists():
|
|
21
|
+
return None
|
|
22
|
+
return json.loads(path.read_text())
|
|
@@ -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.1
|
|
4
4
|
Summary: Automatic scheduler for taskwarrior
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -32,11 +32,12 @@ Use it if you want to:
|
|
|
32
32
|
- ✨ Auto-schedule tasks from working hours + calendar blocks
|
|
33
33
|
- 🧭 Support complex working-hour maps
|
|
34
34
|
- ⏱️ Consider urgency, due dates, and dependencies
|
|
35
|
-
- 🧪
|
|
35
|
+
- 🧪 Persisted dry-run previews for reports and timelines
|
|
36
36
|
- ↩️ Undo the latest schedule update
|
|
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,16 @@ 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
|
+
Create a preview with `taskcheck --schedule --dry-run`. It does not update Taskwarrior; instead, it saves the generated schedule to the Taskwarrior data directory. Reuse it with `taskcheck --report today --dry-run` or `taskcheck --timeline 1w --dry-run`.
|
|
88
|
+
|
|
89
|
+
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 through OSC 11, with config and environment fallbacks.
|
|
83
90
|
|
|
84
91
|
## Reference
|
|
85
92
|
|
|
@@ -3,11 +3,14 @@ README.md
|
|
|
3
3
|
pyproject.toml
|
|
4
4
|
taskcheck/__main__.py
|
|
5
5
|
taskcheck/common.py
|
|
6
|
+
taskcheck/dry_run.py
|
|
6
7
|
taskcheck/google_calendar.py
|
|
7
8
|
taskcheck/ical.py
|
|
8
9
|
taskcheck/install.py
|
|
9
10
|
taskcheck/parallel.py
|
|
10
11
|
taskcheck/report.py
|
|
12
|
+
taskcheck/theme.py
|
|
13
|
+
taskcheck/timeline.py
|
|
11
14
|
taskcheck/undo.py
|
|
12
15
|
taskcheck.egg-info/PKG-INFO
|
|
13
16
|
taskcheck.egg-info/SOURCES.txt
|
|
@@ -22,4 +25,5 @@ tests/test_install.py
|
|
|
22
25
|
tests/test_main.py
|
|
23
26
|
tests/test_parallel.py
|
|
24
27
|
tests/test_report.py
|
|
28
|
+
tests/test_timeline.py
|
|
25
29
|
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"])
|
|
@@ -405,10 +413,11 @@ class TestMainFunction:
|
|
|
405
413
|
auto_adjust_urgency=True,
|
|
406
414
|
)
|
|
407
415
|
|
|
416
|
+
@patch("taskcheck.__main__.save_dry_run_results")
|
|
408
417
|
@patch("taskcheck.__main__.load_config")
|
|
409
418
|
@patch("taskcheck.__main__.check_tasks_parallel")
|
|
410
419
|
def test_main_schedule_with_dry_run(
|
|
411
|
-
self, mock_check_tasks, mock_load_config, sample_config, test_taskrc
|
|
420
|
+
self, mock_check_tasks, mock_load_config, mock_save, sample_config, test_taskrc
|
|
412
421
|
):
|
|
413
422
|
"""Test that dry-run mode returns scheduling results without modifying tasks."""
|
|
414
423
|
mock_load_config.return_value = sample_config
|
|
@@ -429,6 +438,7 @@ class TestMainFunction:
|
|
|
429
438
|
}
|
|
430
439
|
]
|
|
431
440
|
mock_check_tasks.return_value = dry_run_results
|
|
441
|
+
mock_save.return_value = None
|
|
432
442
|
|
|
433
443
|
with patch(
|
|
434
444
|
"sys.argv",
|
|
@@ -461,6 +471,7 @@ class TestMainFunction:
|
|
|
461
471
|
)
|
|
462
472
|
mock_load_config.assert_called_once()
|
|
463
473
|
|
|
474
|
+
@patch("taskcheck.__main__.save_dry_run_results")
|
|
464
475
|
@patch("taskcheck.__main__.load_config")
|
|
465
476
|
@patch("taskcheck.__main__.check_tasks_parallel")
|
|
466
477
|
@patch("taskcheck.report.generate_report")
|
|
@@ -469,6 +480,7 @@ class TestMainFunction:
|
|
|
469
480
|
mock_generate_report,
|
|
470
481
|
mock_check_tasks,
|
|
471
482
|
mock_load_config,
|
|
483
|
+
mock_save,
|
|
472
484
|
sample_config,
|
|
473
485
|
test_taskrc,
|
|
474
486
|
):
|
|
@@ -491,6 +503,7 @@ class TestMainFunction:
|
|
|
491
503
|
}
|
|
492
504
|
]
|
|
493
505
|
mock_check_tasks.return_value = dry_run_results
|
|
506
|
+
mock_save.return_value = None
|
|
494
507
|
|
|
495
508
|
with patch(
|
|
496
509
|
"sys.argv",
|
|
@@ -528,6 +541,7 @@ class TestMainFunction:
|
|
|
528
541
|
taskrc=test_taskrc,
|
|
529
542
|
scheduling_results=dry_run_results,
|
|
530
543
|
)
|
|
544
|
+
mock_save.assert_called_once_with(dry_run_results, taskrc=test_taskrc)
|
|
531
545
|
|
|
532
546
|
# Verify that check_tasks_parallel was called with dry_run=True
|
|
533
547
|
mock_check_tasks.assert_called_once_with(
|
|
@@ -540,6 +554,43 @@ class TestMainFunction:
|
|
|
540
554
|
auto_adjust_urgency=True,
|
|
541
555
|
)
|
|
542
556
|
|
|
557
|
+
@patch("taskcheck.__main__.load_dry_run_results")
|
|
558
|
+
@patch("taskcheck.__main__.load_config")
|
|
559
|
+
@patch("taskcheck.report.generate_report")
|
|
560
|
+
def test_main_report_dry_run_loads_cached_results(
|
|
561
|
+
self, mock_generate_report, mock_load_config, mock_load_cache, sample_config, test_taskrc
|
|
562
|
+
):
|
|
563
|
+
mock_load_config.return_value = sample_config
|
|
564
|
+
cached = [{"id": "1", "scheduling": "2024-12-20 - PT2H"}]
|
|
565
|
+
mock_load_cache.return_value = cached
|
|
566
|
+
|
|
567
|
+
with patch("sys.argv", ["taskcheck", "--report", "today", "--dry-run", "--taskrc", test_taskrc]):
|
|
568
|
+
with patch("taskcheck.__main__.arg_parser.parse_args") as mock_parse:
|
|
569
|
+
mock_args = Mock()
|
|
570
|
+
mock_args.install = False
|
|
571
|
+
mock_args.schedule = False
|
|
572
|
+
mock_args.report = "today"
|
|
573
|
+
mock_args.timeline = None
|
|
574
|
+
mock_args.zoom = "auto"
|
|
575
|
+
mock_args.verbose = False
|
|
576
|
+
mock_args.force_update = False
|
|
577
|
+
mock_args.taskrc = test_taskrc
|
|
578
|
+
mock_args.urgency_weight = None
|
|
579
|
+
mock_args.dry_run = True
|
|
580
|
+
mock_args.auto_adjust_urgency = True
|
|
581
|
+
mock_parse.return_value = mock_args
|
|
582
|
+
main()
|
|
583
|
+
|
|
584
|
+
mock_load_cache.assert_called_once_with(test_taskrc)
|
|
585
|
+
mock_generate_report.assert_called_once_with(
|
|
586
|
+
sample_config,
|
|
587
|
+
"today",
|
|
588
|
+
False,
|
|
589
|
+
force_update=False,
|
|
590
|
+
taskrc=test_taskrc,
|
|
591
|
+
scheduling_results=cached,
|
|
592
|
+
)
|
|
593
|
+
|
|
543
594
|
@patch("taskcheck.__main__.load_config")
|
|
544
595
|
def test_main_config_loading_error(self, mock_load_config):
|
|
545
596
|
"""Test behavior when config loading fails."""
|
|
@@ -592,6 +643,26 @@ class TestMainFunction:
|
|
|
592
643
|
mock_load.assert_not_called()
|
|
593
644
|
|
|
594
645
|
|
|
646
|
+
@patch("taskcheck.timeline.generate_timeline")
|
|
647
|
+
def test_main_timeline_command(self, mock_generate, test_taskrc):
|
|
648
|
+
with patch(
|
|
649
|
+
"sys.argv",
|
|
650
|
+
[
|
|
651
|
+
"taskcheck",
|
|
652
|
+
"--timeline",
|
|
653
|
+
"eom",
|
|
654
|
+
"--zoom",
|
|
655
|
+
"month",
|
|
656
|
+
"--taskrc",
|
|
657
|
+
test_taskrc,
|
|
658
|
+
],
|
|
659
|
+
):
|
|
660
|
+
main()
|
|
661
|
+
|
|
662
|
+
mock_generate.assert_called_once()
|
|
663
|
+
assert mock_generate.call_args.kwargs["config"] == {}
|
|
664
|
+
|
|
665
|
+
|
|
595
666
|
class TestImportErrorHandling:
|
|
596
667
|
def test_install_import_error(self):
|
|
597
668
|
"""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
|