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.
Files changed (30) hide show
  1. {taskcheck-1.4.1/taskcheck.egg-info → taskcheck-1.5.0}/PKG-INFO +7 -2
  2. {taskcheck-1.4.1 → taskcheck-1.5.0}/README.md +6 -1
  3. {taskcheck-1.4.1 → taskcheck-1.5.0}/pyproject.toml +1 -1
  4. {taskcheck-1.4.1 → taskcheck-1.5.0}/taskcheck/__main__.py +33 -0
  5. {taskcheck-1.4.1 → taskcheck-1.5.0}/taskcheck/install.py +3 -0
  6. {taskcheck-1.4.1 → taskcheck-1.5.0}/taskcheck/report.py +26 -19
  7. taskcheck-1.5.0/taskcheck/theme.py +67 -0
  8. taskcheck-1.5.0/taskcheck/timeline.py +233 -0
  9. {taskcheck-1.4.1 → taskcheck-1.5.0/taskcheck.egg-info}/PKG-INFO +7 -2
  10. {taskcheck-1.4.1 → taskcheck-1.5.0}/taskcheck.egg-info/SOURCES.txt +3 -0
  11. {taskcheck-1.4.1 → taskcheck-1.5.0}/tests/test_main.py +28 -0
  12. taskcheck-1.5.0/tests/test_timeline.py +130 -0
  13. {taskcheck-1.4.1 → taskcheck-1.5.0}/LICENSE +0 -0
  14. {taskcheck-1.4.1 → taskcheck-1.5.0}/setup.cfg +0 -0
  15. {taskcheck-1.4.1 → taskcheck-1.5.0}/taskcheck/common.py +0 -0
  16. {taskcheck-1.4.1 → taskcheck-1.5.0}/taskcheck/google_calendar.py +0 -0
  17. {taskcheck-1.4.1 → taskcheck-1.5.0}/taskcheck/ical.py +0 -0
  18. {taskcheck-1.4.1 → taskcheck-1.5.0}/taskcheck/parallel.py +0 -0
  19. {taskcheck-1.4.1 → taskcheck-1.5.0}/taskcheck/undo.py +0 -0
  20. {taskcheck-1.4.1 → taskcheck-1.5.0}/taskcheck.egg-info/dependency_links.txt +0 -0
  21. {taskcheck-1.4.1 → taskcheck-1.5.0}/taskcheck.egg-info/entry_points.txt +0 -0
  22. {taskcheck-1.4.1 → taskcheck-1.5.0}/taskcheck.egg-info/requires.txt +0 -0
  23. {taskcheck-1.4.1 → taskcheck-1.5.0}/taskcheck.egg-info/top_level.txt +0 -0
  24. {taskcheck-1.4.1 → taskcheck-1.5.0}/tests/test_common.py +0 -0
  25. {taskcheck-1.4.1 → taskcheck-1.5.0}/tests/test_google_calendar.py +0 -0
  26. {taskcheck-1.4.1 → taskcheck-1.5.0}/tests/test_ical.py +0 -0
  27. {taskcheck-1.4.1 → taskcheck-1.5.0}/tests/test_install.py +0 -0
  28. {taskcheck-1.4.1 → taskcheck-1.5.0}/tests/test_parallel.py +0 -0
  29. {taskcheck-1.4.1 → taskcheck-1.5.0}/tests/test_report.py +0 -0
  30. {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.4.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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "taskcheck"
3
- version = "1.4.1"
3
+ version = "1.5.0"
4
4
  description = "Automatic scheduler for taskwarrior"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -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
 
@@ -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.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