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.
Files changed (31) hide show
  1. {taskcheck-1.4.1/taskcheck.egg-info → taskcheck-1.5.1}/PKG-INFO +10 -3
  2. {taskcheck-1.4.1 → taskcheck-1.5.1}/README.md +9 -2
  3. {taskcheck-1.4.1 → taskcheck-1.5.1}/pyproject.toml +1 -1
  4. {taskcheck-1.4.1 → taskcheck-1.5.1}/taskcheck/__main__.py +40 -3
  5. taskcheck-1.5.1/taskcheck/dry_run.py +22 -0
  6. {taskcheck-1.4.1 → taskcheck-1.5.1}/taskcheck/install.py +3 -0
  7. {taskcheck-1.4.1 → taskcheck-1.5.1}/taskcheck/report.py +26 -19
  8. taskcheck-1.5.1/taskcheck/theme.py +67 -0
  9. taskcheck-1.5.1/taskcheck/timeline.py +233 -0
  10. {taskcheck-1.4.1 → taskcheck-1.5.1/taskcheck.egg-info}/PKG-INFO +10 -3
  11. {taskcheck-1.4.1 → taskcheck-1.5.1}/taskcheck.egg-info/SOURCES.txt +4 -0
  12. {taskcheck-1.4.1 → taskcheck-1.5.1}/tests/test_main.py +72 -1
  13. taskcheck-1.5.1/tests/test_timeline.py +130 -0
  14. {taskcheck-1.4.1 → taskcheck-1.5.1}/LICENSE +0 -0
  15. {taskcheck-1.4.1 → taskcheck-1.5.1}/setup.cfg +0 -0
  16. {taskcheck-1.4.1 → taskcheck-1.5.1}/taskcheck/common.py +0 -0
  17. {taskcheck-1.4.1 → taskcheck-1.5.1}/taskcheck/google_calendar.py +0 -0
  18. {taskcheck-1.4.1 → taskcheck-1.5.1}/taskcheck/ical.py +0 -0
  19. {taskcheck-1.4.1 → taskcheck-1.5.1}/taskcheck/parallel.py +0 -0
  20. {taskcheck-1.4.1 → taskcheck-1.5.1}/taskcheck/undo.py +0 -0
  21. {taskcheck-1.4.1 → taskcheck-1.5.1}/taskcheck.egg-info/dependency_links.txt +0 -0
  22. {taskcheck-1.4.1 → taskcheck-1.5.1}/taskcheck.egg-info/entry_points.txt +0 -0
  23. {taskcheck-1.4.1 → taskcheck-1.5.1}/taskcheck.egg-info/requires.txt +0 -0
  24. {taskcheck-1.4.1 → taskcheck-1.5.1}/taskcheck.egg-info/top_level.txt +0 -0
  25. {taskcheck-1.4.1 → taskcheck-1.5.1}/tests/test_common.py +0 -0
  26. {taskcheck-1.4.1 → taskcheck-1.5.1}/tests/test_google_calendar.py +0 -0
  27. {taskcheck-1.4.1 → taskcheck-1.5.1}/tests/test_ical.py +0 -0
  28. {taskcheck-1.4.1 → taskcheck-1.5.1}/tests/test_install.py +0 -0
  29. {taskcheck-1.4.1 → taskcheck-1.5.1}/tests/test_parallel.py +0 -0
  30. {taskcheck-1.4.1 → taskcheck-1.5.1}/tests/test_report.py +0 -0
  31. {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.4.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
- - 🧪 Dry-run mode to preview changes
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
- - 🧪 Dry-run mode to preview changes
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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "taskcheck"
3
- version = "1.4.1"
3
+ version = "1.5.1"
4
4
  description = "Automatic scheduler for taskwarrior"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -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.schedule and args.dry_run:
150
- # If we just did a dry-run schedule, use those results
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())
@@ -24,6 +24,9 @@ additional_attributes = []
24
24
  additional_attributes_unplanned = []
25
25
  # unplanned_max_due = "30d"
26
26
  emoji_keywords = {}
27
+
28
+ [timeline]
29
+ # theme = "auto" # or "light" / "dark"
27
30
  """
28
31
 
29
32
 
@@ -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
- config = config["report"]
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 config.get("include_unplanned"):
290
- unplanned_tasks = get_unplanned_tasks(config, original_tasks, taskrc=taskrc)
291
- display_unplanned_tasks(console, config, unplanned_tasks)
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(config, tasks, year, month, day)
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, config, this_day_tasks)
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
- date_str = f":calendar: [bold cyan]{year}-{month}-{day}[/bold cyan]"
327
- console.print(Panel(date_str, style="bold blue", expand=False))
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
- table = Table(show_header=True, header_style="bold blue")
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
- table = build_unplanned_tasks_table(config, tasks)
395
- console.print(Panel("Unplanned Tasks", style="bold blue", expand=False))
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
- table = Table(show_header=True, header_style="bold blue")
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.4.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
- - 🧪 Dry-run mode to preview changes
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