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/__init__.py +1 -0
- taskunity/app.py +1268 -0
- taskunity/cli.py +43 -0
- taskunity/models.py +103 -0
- taskunity/render.py +371 -0
- taskunity/static/app.css +1394 -0
- taskunity/static/chart.umd.min.js +14 -0
- taskunity/static/chartjs-adapter-date-fns.bundle.min.js +7 -0
- taskunity/static/htmx.min.js +68 -0
- taskunity/task_store.py +598 -0
- taskunity/templates/base.html +397 -0
- taskunity/templates/index.html +4 -0
- taskunity/templates/partials/board.html +24 -0
- taskunity/templates/partials/calendar.html +25 -0
- taskunity/templates/partials/main.html +283 -0
- taskunity/templates/partials/milestone_banner.html +34 -0
- taskunity/templates/partials/milestone_panel.html +222 -0
- taskunity/templates/partials/milestones.html +55 -0
- taskunity/templates/partials/projects.html +41 -0
- taskunity/templates/partials/task_list.html +52 -0
- taskunity/templates/partials/task_panel.html +310 -0
- taskunity/templates/partials/timeline.html +24 -0
- taskunity-2026.1.dist-info/METADATA +238 -0
- taskunity-2026.1.dist-info/RECORD +27 -0
- taskunity-2026.1.dist-info/WHEEL +5 -0
- taskunity-2026.1.dist-info/entry_points.txt +2 -0
- taskunity-2026.1.dist-info/top_level.txt +1 -0
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}
|