taskunity 2026.1__py3-none-any.whl

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.
taskunity/cli.py ADDED
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+
6
+ import uvicorn
7
+
8
+ from .app import create_app
9
+ from .task_store import copy_starter_files, ensure_workspace
10
+
11
+
12
+ def main(argv: list[str] | None = None) -> None:
13
+ parser = argparse.ArgumentParser(prog="taskunity", description="Local file-backed workspace/task tracker")
14
+ sub = parser.add_subparsers(dest="command", required=True)
15
+
16
+ init_p = sub.add_parser("init", help="Initialize a new workspace")
17
+ init_p.add_argument("path", nargs="?", default=".", help="Workspace path")
18
+ init_p.add_argument("--no-sample", action="store_true", help="Ignored; init no longer creates sample content")
19
+
20
+ serve_p = sub.add_parser("serve", help="Serve the local web UI")
21
+ serve_p.add_argument("--workspace", default=".", help="Workspace path")
22
+ serve_p.add_argument("--host", default="127.0.0.1", help="Host interface")
23
+ serve_p.add_argument("--port", default=8000, type=int, help="Port")
24
+ serve_p.add_argument("--reload", action="store_true", help="Enable uvicorn reload")
25
+
26
+ args = parser.parse_args(argv)
27
+
28
+ if args.command == "init":
29
+ target = Path(args.path).resolve()
30
+ if args.no_sample:
31
+ ensure_workspace(target)
32
+ else:
33
+ copy_starter_files(target)
34
+ print(f"Initialized taskunity workspace: {target}")
35
+ print("Run: taskunity serve --workspace", target)
36
+ return
37
+
38
+ if args.command == "serve":
39
+ workspace = Path(args.workspace).resolve()
40
+ ensure_workspace(workspace)
41
+ app = create_app(workspace)
42
+ print(f"Serving workspace: {workspace}")
43
+ uvicorn.run(app, host=args.host, port=args.port, reload=args.reload)
taskunity/models.py ADDED
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ import secrets
4
+ from datetime import datetime
5
+ from typing import Any, Literal
6
+
7
+ from pydantic import BaseModel, Field, field_validator
8
+
9
+ TaskStatus = Literal["backlog", "working", "blocked", "done"]
10
+ Priority = Literal["low", "normal", "high", "critical"]
11
+
12
+
13
+ class Project(BaseModel):
14
+ name: str
15
+ description: str = ""
16
+ color: str = "#2e6fd8"
17
+
18
+
19
+ class ChecklistItem(BaseModel):
20
+ text: str
21
+ done: bool = False
22
+
23
+
24
+ class Note(BaseModel):
25
+ created_at: str = Field(default_factory=lambda: datetime.now().isoformat(timespec="seconds"))
26
+ body: str
27
+
28
+
29
+ class Attachment(BaseModel):
30
+ filename: str
31
+ path: str
32
+ kind: str = "file"
33
+ description: str = ""
34
+ uploaded_at: str = Field(default_factory=lambda: datetime.now().isoformat(timespec="seconds"))
35
+
36
+
37
+ class TaskActivityEvent(BaseModel):
38
+ id: str = Field(default_factory=lambda: secrets.token_hex(8))
39
+ event_type: Literal["note", "image", "progress_update"]
40
+ created_at: str = Field(default_factory=lambda: datetime.now().isoformat(timespec="seconds"))
41
+ note_text: str | None = None
42
+ image_path: str | None = None
43
+ image_filename: str | None = None
44
+ progress_before: int | None = None
45
+ progress_after: int | None = None
46
+
47
+
48
+ class Task(BaseModel):
49
+ id: str
50
+ title: str
51
+ status: TaskStatus = "backlog"
52
+ priority: Priority = "normal"
53
+ project: str = ""
54
+ summary: str = ""
55
+ description: str = ""
56
+ tags: list[str] = Field(default_factory=list)
57
+ start_date: str | None = None
58
+ due_date: str | None = None
59
+ completed_date: str | None = None
60
+ percent_complete: int = 0
61
+ depends_on: list[str] = Field(default_factory=list)
62
+ checklist: list[ChecklistItem] = Field(default_factory=list)
63
+ notes: list[Note] = Field(default_factory=list)
64
+ attachments: list[Attachment] = Field(default_factory=list)
65
+ activity: list[TaskActivityEvent] = Field(default_factory=list)
66
+ extra: dict[str, Any] = Field(default_factory=dict)
67
+
68
+
69
+ MilestoneStatus = Literal["planned", "active", "done"]
70
+
71
+
72
+ class Milestone(BaseModel):
73
+ id: str
74
+ title: str
75
+ status: MilestoneStatus = "active"
76
+ color: str = "#3567e0"
77
+ summary: str = ""
78
+ description: str = ""
79
+ projects: list[str] = Field(default_factory=list)
80
+ start_date: str | None = None
81
+ target_date: str | None = None
82
+ task_ids: list[str] = Field(default_factory=list)
83
+ notes: list[Note] = Field(default_factory=list)
84
+ attachments: list[Attachment] = Field(default_factory=list)
85
+ extra: dict[str, Any] = Field(default_factory=dict)
86
+
87
+ @field_validator("projects", mode="before")
88
+ @classmethod
89
+ def _coerce_projects(cls, value: Any) -> Any:
90
+ if not value:
91
+ return []
92
+ if isinstance(value, str):
93
+ return [value]
94
+ return list(value)
95
+
96
+ @field_validator("task_ids", mode="before")
97
+ @classmethod
98
+ def _coerce_task_ids(cls, value: Any) -> Any:
99
+ if not value:
100
+ return []
101
+ if isinstance(value, str):
102
+ return [value]
103
+ return list(value)
taskunity/render.py ADDED
@@ -0,0 +1,371 @@
1
+ from __future__ import annotations
2
+
3
+ import calendar as calendar_lib
4
+ from collections import Counter, defaultdict
5
+ from datetime import date, datetime, timedelta
6
+ from typing import Any
7
+
8
+ from .models import Milestone, Task
9
+
10
+ STATUSES = ["backlog", "working", "blocked", "done"]
11
+ WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
12
+ PRIORITY_ORDER = {"critical": 0, "high": 1, "normal": 2, "low": 3}
13
+ STATUS_ORDER = {"working": 0, "blocked": 1, "backlog": 2, "done": 3}
14
+ SORTS = {
15
+ "priority": "Priority",
16
+ "due_date": "Due date",
17
+ "title": "Title",
18
+ "status": "Status",
19
+ "percent_complete": "Progress",
20
+ "project": "Project",
21
+ }
22
+
23
+
24
+ def sort_tasks(tasks: list[Task], sort: str = "priority") -> list[Task]:
25
+ if sort == "due_date":
26
+ return sorted(tasks, key=lambda t: (parse_date(t.due_date) or date.max, t.title.lower()))
27
+ if sort == "title":
28
+ return sorted(tasks, key=lambda t: t.title.lower())
29
+ if sort == "status":
30
+ return sorted(tasks, key=lambda t: (STATUS_ORDER.get(t.status, 9), t.title.lower()))
31
+ if sort == "percent_complete":
32
+ return sorted(tasks, key=lambda t: (-t.percent_complete, t.title.lower()))
33
+ if sort == "project":
34
+ return sorted(tasks, key=lambda t: ((t.project or "~").lower(), t.title.lower()))
35
+ return sorted(tasks, key=lambda t: (PRIORITY_ORDER.get(t.priority, 9), t.title.lower()))
36
+
37
+
38
+ def parse_date(value: str | None) -> date | None:
39
+ if not value:
40
+ return None
41
+ try:
42
+ return datetime.fromisoformat(value).date()
43
+ except ValueError:
44
+ try:
45
+ return date.fromisoformat(value)
46
+ except ValueError:
47
+ return None
48
+
49
+
50
+ def dashboard_model(tasks: list[Task]) -> dict:
51
+ counts = Counter(t.status for t in tasks)
52
+ by_status = {status: [] for status in STATUSES}
53
+ for task in tasks:
54
+ by_status.setdefault(task.status, []).append(task)
55
+
56
+ done = counts.get("done", 0)
57
+ total = len(tasks)
58
+ progress = round((done / total) * 100) if total else 0
59
+
60
+ upcoming = sorted(
61
+ [t for t in tasks if t.status != "done" and parse_date(t.due_date)],
62
+ key=lambda t: parse_date(t.due_date) or date.max,
63
+ )[:8]
64
+
65
+ blocked = [t for t in tasks if t.status == "blocked"]
66
+ recent_notes = []
67
+ for task in tasks:
68
+ for note in task.notes[-3:]:
69
+ recent_notes.append({"task": task, "note": note})
70
+ recent_notes.sort(key=lambda x: x["note"].created_at, reverse=True)
71
+
72
+ return {
73
+ "tasks": tasks,
74
+ "counts": counts,
75
+ "by_status": by_status,
76
+ "total": total,
77
+ "progress": progress,
78
+ "upcoming": upcoming,
79
+ "blocked": blocked,
80
+ "recent_notes": recent_notes[:8],
81
+ "timeline": build_timeline(tasks),
82
+ }
83
+
84
+
85
+ def build_timeline(tasks: list[Task]) -> dict:
86
+ dated = []
87
+ all_dates = []
88
+ for task in tasks:
89
+ start = parse_date(task.start_date) or parse_date(task.due_date)
90
+ end = parse_date(task.completed_date) or parse_date(task.due_date) or start
91
+ if start and end:
92
+ if end < start:
93
+ start, end = end, start
94
+ dated.append((task, start, end))
95
+ all_dates.extend([start, end])
96
+
97
+ if not dated:
98
+ return {"rows": [], "start": None, "end": None, "days": 0}
99
+
100
+ start_min = min(all_dates)
101
+ end_max = max(all_dates)
102
+ total_days = max((end_max - start_min).days + 1, 1)
103
+ items = []
104
+ for task, start, end in dated:
105
+ left = ((start - start_min).days / total_days) * 100
106
+ width = max((((end - start).days + 1) / total_days) * 100, 2)
107
+ items.append({"task": task, "left": left, "width": width, "start": start, "end": end})
108
+ end_pos = {item["task"].id: item["left"] + item["width"] for item in items}
109
+ for item in items:
110
+ item["dep_marks"] = [
111
+ {"id": dep, "pos": min(end_pos[dep], 100)}
112
+ for dep in item["task"].depends_on
113
+ if dep in end_pos
114
+ ]
115
+ return {"rows": items, "start": start_min, "end": end_max, "days": total_days}
116
+
117
+
118
+ def milestone_rollup(milestone: Milestone, tasks_by_id: dict[str, Task]) -> dict:
119
+ ordered = [tasks_by_id[tid] for tid in milestone.task_ids if tid in tasks_by_id]
120
+ counts = Counter(t.status for t in ordered)
121
+ total = len(ordered)
122
+ done = counts.get("done", 0)
123
+ progress = round(sum(t.percent_complete for t in ordered) / total) if total else 0
124
+ upcoming = sorted(
125
+ [t for t in ordered if t.status != "done" and parse_date(t.due_date)],
126
+ key=lambda t: parse_date(t.due_date) or date.max,
127
+ )
128
+ next_due = upcoming[0].due_date if upcoming else None
129
+ missing = [tid for tid in milestone.task_ids if tid not in tasks_by_id]
130
+ return {
131
+ "milestone": milestone,
132
+ "tasks": ordered,
133
+ "missing": missing,
134
+ "counts": counts,
135
+ "total": total,
136
+ "done": done,
137
+ "working": counts.get("working", 0),
138
+ "blocked": counts.get("blocked", 0),
139
+ "backlog": counts.get("backlog", 0),
140
+ "progress": progress,
141
+ "next_due": next_due,
142
+ }
143
+
144
+
145
+ def tags_summary(tasks: list[Task]) -> dict[str, int]:
146
+ counts: defaultdict[str, int] = defaultdict(int)
147
+ for task in tasks:
148
+ for tag in task.tags:
149
+ counts[tag] += 1
150
+ return dict(sorted(counts.items(), key=lambda kv: (-kv[1], kv[0])))
151
+
152
+
153
+ def filter_tasks(
154
+ tasks: list[Task],
155
+ projects: list[str] | None = None,
156
+ date_from: str = "",
157
+ date_to: str = "",
158
+ q: str = "",
159
+ ) -> list[Task]:
160
+ result = list(tasks)
161
+ projects = [p for p in (projects or []) if p]
162
+ if projects:
163
+ result = [t for t in result if (t.project or "") in projects]
164
+
165
+ needle = (q or "").strip().lower()
166
+ if needle:
167
+ def matches(task: Task) -> bool:
168
+ haystack = " ".join(
169
+ [
170
+ task.id,
171
+ task.title,
172
+ task.project,
173
+ task.summary,
174
+ task.description,
175
+ " ".join(task.tags),
176
+ ]
177
+ ).lower()
178
+ return needle in haystack
179
+
180
+ result = [t for t in result if matches(t)]
181
+
182
+ start = parse_date(date_from)
183
+ end = parse_date(date_to)
184
+ if start or end:
185
+ def in_range(task: Task) -> bool:
186
+ dates = [
187
+ d
188
+ for d in (
189
+ parse_date(task.start_date),
190
+ parse_date(task.due_date),
191
+ parse_date(task.completed_date),
192
+ )
193
+ if d
194
+ ]
195
+ if not dates:
196
+ return False
197
+ lo, hi = min(dates), max(dates)
198
+ if start and hi < start:
199
+ return False
200
+ if end and lo > end:
201
+ return False
202
+ return True
203
+
204
+ result = [t for t in result if in_range(t)]
205
+ return result
206
+
207
+
208
+ def hide_stale_closed_tasks(tasks: list[Task], stale_days: int = 45) -> tuple[list[Task], int]:
209
+ cutoff = date.today() - timedelta(days=max(1, int(stale_days)))
210
+ kept: list[Task] = []
211
+ hidden = 0
212
+ for task in tasks:
213
+ if task.status not in {"done", "blocked"}:
214
+ kept.append(task)
215
+ continue
216
+ anchor = parse_date(task.completed_date) or parse_date(task.due_date) or parse_date(task.start_date)
217
+ if anchor and anchor < cutoff:
218
+ hidden += 1
219
+ continue
220
+ kept.append(task)
221
+ return kept, hidden
222
+
223
+
224
+ def build_calendar(
225
+ tasks: list[Task],
226
+ date_from: str = "",
227
+ date_to: str = "",
228
+ focus_month: int | None = None,
229
+ focus_year: int | None = None,
230
+ ) -> dict:
231
+ events: list[tuple[date, Task]] = []
232
+ for task in tasks:
233
+ anchor = parse_date(task.due_date) or parse_date(task.start_date)
234
+ if anchor:
235
+ events.append((anchor, task))
236
+
237
+ start = parse_date(date_from)
238
+ end = parse_date(date_to)
239
+ event_dates = [d for d, _ in events]
240
+ if not start:
241
+ start = min(event_dates) if event_dates else date.today().replace(day=1)
242
+ if not end:
243
+ end = max(event_dates) if event_dates else start
244
+ if end < start:
245
+ start, end = end, start
246
+
247
+ by_day: defaultdict[date, list[Task]] = defaultdict(list)
248
+ for d, task in events:
249
+ by_day[d].append(task)
250
+
251
+ today = date.today()
252
+ if focus_year is None or focus_month is None:
253
+ if date_from or date_to:
254
+ if event_dates:
255
+ latest = max(event_dates)
256
+ focus_year = latest.year
257
+ focus_month = latest.month
258
+ else:
259
+ focus_year = today.year
260
+ focus_month = today.month
261
+ else:
262
+ focus_year = today.year
263
+ focus_month = today.month
264
+ month_cursor = date(focus_year, focus_month, 1)
265
+ days_in_month = calendar_lib.monthrange(month_cursor.year, month_cursor.month)[1]
266
+ month_end = date(month_cursor.year, month_cursor.month, days_in_month)
267
+ grid_start = month_cursor - timedelta(days=month_cursor.weekday())
268
+ grid_end = month_end + timedelta(days=(6 - month_end.weekday()))
269
+
270
+ weeks = []
271
+ cursor = grid_start
272
+ while cursor <= grid_end:
273
+ week = []
274
+ for _ in range(7):
275
+ week.append(
276
+ {
277
+ "date": cursor,
278
+ "in_range": start <= cursor <= end,
279
+ "in_month": cursor.month == month_cursor.month and cursor.year == month_cursor.year,
280
+ "today": cursor == today,
281
+ "tasks": by_day.get(cursor, []),
282
+ }
283
+ )
284
+ cursor += timedelta(days=1)
285
+ weeks.append(week)
286
+
287
+ prev_year = focus_year - 1 if focus_month == 1 else focus_year
288
+ prev_month = 12 if focus_month == 1 else focus_month - 1
289
+ next_year = focus_year + 1 if focus_month == 12 else focus_year
290
+ next_month = 1 if focus_month == 12 else focus_month + 1
291
+
292
+ return {
293
+ "weeks": weeks,
294
+ "weekdays": WEEKDAYS,
295
+ "start": start,
296
+ "end": end,
297
+ "month": focus_month,
298
+ "year": focus_year,
299
+ "label": month_cursor.strftime("%B %Y"),
300
+ "month_options": [{"value": i, "label": calendar_lib.month_name[i]} for i in range(1, 13)],
301
+ "prev_month": prev_month,
302
+ "prev_year": prev_year,
303
+ "next_month": next_month,
304
+ "next_year": next_year,
305
+ "has_events": bool(events),
306
+ "current_month_label": month_cursor.strftime("%B %Y"),
307
+ }
308
+
309
+
310
+ def tasks_to_jsonantt(
311
+ tasks: list[Task],
312
+ *,
313
+ title: str = "Taskwright Export",
314
+ project_colors: dict[str, str] | None = None,
315
+ project_order: list[str] | None = None,
316
+ ) -> dict[str, Any]:
317
+ by_project: dict[str, list[Task]] = defaultdict(list)
318
+ for task in tasks:
319
+ by_project[(task.project or "").strip() or "Unassigned"].append(task)
320
+
321
+ ordered_names: list[str] = []
322
+ for name in project_order or []:
323
+ if name in by_project and name not in ordered_names:
324
+ ordered_names.append(name)
325
+ for name in sorted(by_project.keys(), key=str.lower):
326
+ if name not in ordered_names:
327
+ ordered_names.append(name)
328
+
329
+ exported_ids = {task.id for task in tasks}
330
+ colors = project_colors or {}
331
+ arrows: list[dict[str, str]] = []
332
+ seen_arrows: set[tuple[str, str]] = set()
333
+
334
+ project_layers: list[dict[str, Any]] = []
335
+ for project_name in ordered_names:
336
+ project_layer: dict[str, Any] = {"name": project_name, "tasks": []}
337
+ if colors.get(project_name):
338
+ project_layer["color"] = colors[project_name]
339
+ for task in by_project[project_name]:
340
+ entry: dict[str, Any] = {
341
+ "name": task.title,
342
+ "id": task.id,
343
+ "status": task.status,
344
+ "priority": task.priority,
345
+ "percent_complete": task.percent_complete,
346
+ }
347
+ if task.summary:
348
+ entry["summary"] = task.summary
349
+ if task.description:
350
+ entry["description"] = task.description
351
+ if task.tags:
352
+ entry["tags"] = task.tags
353
+ start = task.start_date or task.due_date or task.completed_date
354
+ end = task.due_date or task.completed_date or start
355
+ if start:
356
+ entry["start"] = start
357
+ if end:
358
+ entry["end"] = end
359
+ deps = [dep for dep in task.depends_on if dep in exported_ids and dep != task.id]
360
+ not_before = deps[0] if deps else None
361
+ if not_before:
362
+ entry["not_before"] = not_before
363
+ for dep in deps:
364
+ edge = (dep, task.id)
365
+ if edge not in seen_arrows:
366
+ seen_arrows.add(edge)
367
+ arrows.append({"from": dep, "to": task.id})
368
+ project_layer["tasks"].append(entry)
369
+ project_layers.append(project_layer)
370
+
371
+ return {"title": title, "dateformat": "%Y-%m-%d", "tasks": project_layers, "arrows": arrows}