taskunity 2026.1__tar.gz → 2026.2__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.
- {taskunity-2026.1/src/taskunity.egg-info → taskunity-2026.2}/PKG-INFO +1 -1
- {taskunity-2026.1 → taskunity-2026.2}/pyproject.toml +1 -1
- {taskunity-2026.1 → taskunity-2026.2}/src/taskunity/app.py +475 -49
- {taskunity-2026.1 → taskunity-2026.2}/src/taskunity/static/app.css +120 -2
- taskunity-2026.2/src/taskunity/templates/partials/milestone_banner.html +204 -0
- {taskunity-2026.1 → taskunity-2026.2}/src/taskunity/templates/partials/milestone_panel.html +0 -53
- {taskunity-2026.1 → taskunity-2026.2}/src/taskunity/templates/partials/projects.html +11 -0
- {taskunity-2026.1 → taskunity-2026.2}/src/taskunity/templates/partials/task_panel.html +176 -47
- {taskunity-2026.1 → taskunity-2026.2/src/taskunity.egg-info}/PKG-INFO +1 -1
- taskunity-2026.1/src/taskunity/templates/partials/milestone_banner.html +0 -34
- {taskunity-2026.1 → taskunity-2026.2}/MANIFEST.in +0 -0
- {taskunity-2026.1 → taskunity-2026.2}/README.md +0 -0
- {taskunity-2026.1 → taskunity-2026.2}/setup.cfg +0 -0
- {taskunity-2026.1 → taskunity-2026.2}/src/taskunity/__init__.py +0 -0
- {taskunity-2026.1 → taskunity-2026.2}/src/taskunity/cli.py +0 -0
- {taskunity-2026.1 → taskunity-2026.2}/src/taskunity/models.py +0 -0
- {taskunity-2026.1 → taskunity-2026.2}/src/taskunity/render.py +0 -0
- {taskunity-2026.1 → taskunity-2026.2}/src/taskunity/static/chart.umd.min.js +0 -0
- {taskunity-2026.1 → taskunity-2026.2}/src/taskunity/static/chartjs-adapter-date-fns.bundle.min.js +0 -0
- {taskunity-2026.1 → taskunity-2026.2}/src/taskunity/static/htmx.min.js +0 -0
- {taskunity-2026.1 → taskunity-2026.2}/src/taskunity/task_store.py +0 -0
- {taskunity-2026.1 → taskunity-2026.2}/src/taskunity/templates/base.html +0 -0
- {taskunity-2026.1 → taskunity-2026.2}/src/taskunity/templates/index.html +0 -0
- {taskunity-2026.1 → taskunity-2026.2}/src/taskunity/templates/partials/board.html +0 -0
- {taskunity-2026.1 → taskunity-2026.2}/src/taskunity/templates/partials/calendar.html +0 -0
- {taskunity-2026.1 → taskunity-2026.2}/src/taskunity/templates/partials/main.html +0 -0
- {taskunity-2026.1 → taskunity-2026.2}/src/taskunity/templates/partials/milestones.html +0 -0
- {taskunity-2026.1 → taskunity-2026.2}/src/taskunity/templates/partials/task_list.html +0 -0
- {taskunity-2026.1 → taskunity-2026.2}/src/taskunity/templates/partials/timeline.html +0 -0
- {taskunity-2026.1 → taskunity-2026.2}/src/taskunity.egg-info/SOURCES.txt +0 -0
- {taskunity-2026.1 → taskunity-2026.2}/src/taskunity.egg-info/dependency_links.txt +0 -0
- {taskunity-2026.1 → taskunity-2026.2}/src/taskunity.egg-info/entry_points.txt +0 -0
- {taskunity-2026.1 → taskunity-2026.2}/src/taskunity.egg-info/requires.txt +0 -0
- {taskunity-2026.1 → taskunity-2026.2}/src/taskunity.egg-info/top_level.txt +0 -0
- {taskunity-2026.1 → taskunity-2026.2}/tests/test_git_workspace_scope.py +0 -0
- {taskunity-2026.1 → taskunity-2026.2}/tests/test_render_jsonantt.py +0 -0
- {taskunity-2026.1 → taskunity-2026.2}/tests/test_workspace_config.py +0 -0
|
@@ -4,7 +4,7 @@ import csv
|
|
|
4
4
|
import io
|
|
5
5
|
import json
|
|
6
6
|
import urllib.parse
|
|
7
|
-
from datetime import date
|
|
7
|
+
from datetime import date, datetime, timedelta
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
|
|
10
10
|
import markdown as markdown_lib
|
|
@@ -13,7 +13,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
|
|
13
13
|
from fastapi.staticfiles import StaticFiles
|
|
14
14
|
from fastapi.templating import Jinja2Templates
|
|
15
15
|
|
|
16
|
-
from .models import ChecklistItem, Task
|
|
16
|
+
from .models import ChecklistItem, Task, TaskActivityEvent
|
|
17
17
|
from .render import (
|
|
18
18
|
SORTS,
|
|
19
19
|
STATUSES,
|
|
@@ -148,6 +148,64 @@ def build_task_activity_entries(task: Task | None) -> list[dict[str, object]]:
|
|
|
148
148
|
return sorted(entries, key=lambda item: str(item.get("created_at") or ""), reverse=True)
|
|
149
149
|
|
|
150
150
|
|
|
151
|
+
def _parse_event_datetime(value: str | None) -> datetime | None:
|
|
152
|
+
raw = (value or "").strip()
|
|
153
|
+
if not raw:
|
|
154
|
+
return None
|
|
155
|
+
try:
|
|
156
|
+
return datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
|
157
|
+
except ValueError:
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _normalize_event_points(points: list[dict[str, object]], fallback_iso: str) -> list[dict[str, object]]:
|
|
162
|
+
ordered: list[tuple[datetime, int, dict[str, object]]] = []
|
|
163
|
+
fallback_dt = _parse_event_datetime(fallback_iso) or datetime.now()
|
|
164
|
+
for index, point in enumerate(points):
|
|
165
|
+
dt = _parse_event_datetime(str(point.get("created_at") or "")) or fallback_dt
|
|
166
|
+
ordered.append((dt, index, point))
|
|
167
|
+
|
|
168
|
+
ordered.sort(key=lambda item: (item[0], item[1]))
|
|
169
|
+
normalized: list[dict[str, object]] = []
|
|
170
|
+
last_dt: datetime | None = None
|
|
171
|
+
for dt, _, point in ordered:
|
|
172
|
+
if last_dt is not None and dt <= last_dt:
|
|
173
|
+
dt = last_dt + timedelta(seconds=1)
|
|
174
|
+
last_dt = dt
|
|
175
|
+
normalized.append(
|
|
176
|
+
{
|
|
177
|
+
"x": dt.isoformat(timespec="seconds"),
|
|
178
|
+
"y": point.get("y", 100),
|
|
179
|
+
"label": point.get("label", ""),
|
|
180
|
+
"event_type": point.get("event_type", "update"),
|
|
181
|
+
"preview_title": point.get("preview_title", ""),
|
|
182
|
+
"preview_body": point.get("preview_body", ""),
|
|
183
|
+
"preview_path": point.get("preview_path", ""),
|
|
184
|
+
"is_image": bool(point.get("is_image")),
|
|
185
|
+
}
|
|
186
|
+
)
|
|
187
|
+
return normalized
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _clip_progress(value: int | None, fallback: int = 0) -> int:
|
|
191
|
+
try:
|
|
192
|
+
raw = int(value if value is not None else fallback)
|
|
193
|
+
except (TypeError, ValueError):
|
|
194
|
+
raw = fallback
|
|
195
|
+
return max(0, min(100, raw))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _summarize_text(value: str | None, max_len: int = 46) -> str:
|
|
199
|
+
text = " ".join((value or "").strip().split())
|
|
200
|
+
if not text:
|
|
201
|
+
return ""
|
|
202
|
+
return text if len(text) <= max_len else text[: max_len - 1] + "…"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _preview_text(value: str | None, max_len: int = 180) -> str:
|
|
206
|
+
return _summarize_text(value, max_len)
|
|
207
|
+
|
|
208
|
+
|
|
151
209
|
def create_app(workspace: str | Path = ".") -> FastAPI:
|
|
152
210
|
workspace = Path(workspace).resolve()
|
|
153
211
|
ensure_workspace(workspace)
|
|
@@ -521,8 +579,8 @@ def create_app(workspace: str | Path = ".") -> FastAPI:
|
|
|
521
579
|
request: Request,
|
|
522
580
|
task_id: str,
|
|
523
581
|
title: str = Form(...),
|
|
524
|
-
status: str = Form("
|
|
525
|
-
priority: str = Form("
|
|
582
|
+
status: str = Form(""),
|
|
583
|
+
priority: str = Form(""),
|
|
526
584
|
project: str = Form(""),
|
|
527
585
|
summary: str = Form(""),
|
|
528
586
|
description: str = Form(""),
|
|
@@ -530,7 +588,7 @@ def create_app(workspace: str | Path = ".") -> FastAPI:
|
|
|
530
588
|
start_date: str = Form(""),
|
|
531
589
|
due_date: str = Form(""),
|
|
532
590
|
completed_date: str = Form(""),
|
|
533
|
-
percent_complete:
|
|
591
|
+
percent_complete: str = Form(""),
|
|
534
592
|
depends_on: str = Form(""),
|
|
535
593
|
checklist_text: str = Form(""),
|
|
536
594
|
f_project: list[str] = Form(default=[]),
|
|
@@ -544,8 +602,12 @@ def create_app(workspace: str | Path = ".") -> FastAPI:
|
|
|
544
602
|
) -> HTMLResponse:
|
|
545
603
|
task = load_task(workspace, task_id)
|
|
546
604
|
task.title = title
|
|
547
|
-
|
|
548
|
-
|
|
605
|
+
status_value = (status or "").strip().lower()
|
|
606
|
+
if status_value in set(STATUSES):
|
|
607
|
+
task.status = status_value
|
|
608
|
+
priority_value = (priority or "").strip().lower()
|
|
609
|
+
if priority_value in {"low", "normal", "high", "critical"}:
|
|
610
|
+
task.priority = priority_value
|
|
549
611
|
task.project = project.strip()
|
|
550
612
|
task.summary = summary
|
|
551
613
|
task.description = description
|
|
@@ -553,9 +615,15 @@ def create_app(workspace: str | Path = ".") -> FastAPI:
|
|
|
553
615
|
task.start_date = start_date or None
|
|
554
616
|
task.due_date = due_date or None
|
|
555
617
|
task.completed_date = completed_date or None
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
618
|
+
percent_raw = str(percent_complete or "").strip()
|
|
619
|
+
if percent_raw:
|
|
620
|
+
try:
|
|
621
|
+
new_progress = max(0, min(int(percent_raw), 100))
|
|
622
|
+
except ValueError:
|
|
623
|
+
new_progress = task.percent_complete
|
|
624
|
+
old_progress = task.percent_complete
|
|
625
|
+
task.percent_complete = new_progress
|
|
626
|
+
log_progress_change(workspace, task, old_progress, task.percent_complete)
|
|
559
627
|
task.depends_on = [x.strip() for x in depends_on.split(",") if x.strip()]
|
|
560
628
|
checklist = []
|
|
561
629
|
for line in checklist_text.splitlines():
|
|
@@ -585,6 +653,89 @@ def create_app(workspace: str | Path = ".") -> FastAPI:
|
|
|
585
653
|
),
|
|
586
654
|
)
|
|
587
655
|
|
|
656
|
+
@app.post("/tasks/{task_id}/update", response_class=HTMLResponse)
|
|
657
|
+
async def update_task_activity_route(
|
|
658
|
+
request: Request,
|
|
659
|
+
task_id: str,
|
|
660
|
+
progress_after: str = Form(""),
|
|
661
|
+
status: str = Form(""),
|
|
662
|
+
priority: str = Form(""),
|
|
663
|
+
body: str = Form(""),
|
|
664
|
+
attachment: UploadFile | None = File(None),
|
|
665
|
+
description: str = Form(""),
|
|
666
|
+
f_view: str = Form("board"),
|
|
667
|
+
f_milestone: str = Form(""),
|
|
668
|
+
f_show_closed: str = Form(""),
|
|
669
|
+
f_stale_days: str = Form(str(STALE_CLOSED_DAYS)),
|
|
670
|
+
) -> HTMLResponse:
|
|
671
|
+
task = load_task(workspace, task_id)
|
|
672
|
+
|
|
673
|
+
progress_raw = str(progress_after or "").strip()
|
|
674
|
+
if progress_raw:
|
|
675
|
+
try:
|
|
676
|
+
new_progress = max(0, min(int(progress_raw), 100))
|
|
677
|
+
except ValueError:
|
|
678
|
+
new_progress = task.percent_complete
|
|
679
|
+
old_progress = task.percent_complete
|
|
680
|
+
task.percent_complete = new_progress
|
|
681
|
+
log_progress_change(workspace, task, old_progress, task.percent_complete)
|
|
682
|
+
|
|
683
|
+
status_before = task.status
|
|
684
|
+
priority_before = task.priority
|
|
685
|
+
status_value = (status or "").strip().lower()
|
|
686
|
+
if status_value in set(STATUSES):
|
|
687
|
+
task.status = status_value
|
|
688
|
+
priority_value = (priority or "").strip().lower()
|
|
689
|
+
if priority_value in {"low", "normal", "high", "critical"}:
|
|
690
|
+
task.priority = priority_value
|
|
691
|
+
|
|
692
|
+
if task.status == "done" and not task.completed_date:
|
|
693
|
+
task.completed_date = date.today().isoformat()
|
|
694
|
+
elif status_before == "done" and task.status != "done":
|
|
695
|
+
task.completed_date = None
|
|
696
|
+
|
|
697
|
+
context_parts: list[str] = []
|
|
698
|
+
if status_before != task.status:
|
|
699
|
+
context_parts.append(f"Status {status_before} → {task.status}")
|
|
700
|
+
if priority_before != task.priority:
|
|
701
|
+
context_parts.append(f"Priority {priority_before} → {task.priority}")
|
|
702
|
+
if context_parts:
|
|
703
|
+
task.activity.append(
|
|
704
|
+
TaskActivityEvent(
|
|
705
|
+
event_type="note",
|
|
706
|
+
note_text=" · ".join(context_parts),
|
|
707
|
+
)
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
note_text = (body or "").strip()
|
|
711
|
+
if note_text:
|
|
712
|
+
task.activity.append(TaskActivityEvent(event_type="note", note_text=note_text))
|
|
713
|
+
|
|
714
|
+
save_task(workspace, task)
|
|
715
|
+
|
|
716
|
+
if attachment and (attachment.filename or "").strip():
|
|
717
|
+
task = add_task_activity_image(
|
|
718
|
+
workspace,
|
|
719
|
+
task_id,
|
|
720
|
+
attachment.filename or "attachment.bin",
|
|
721
|
+
await attachment.read(),
|
|
722
|
+
attachment.content_type,
|
|
723
|
+
description,
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
return templates.TemplateResponse(
|
|
727
|
+
request,
|
|
728
|
+
"partials/task_panel.html",
|
|
729
|
+
context(
|
|
730
|
+
request,
|
|
731
|
+
task,
|
|
732
|
+
view=f_view,
|
|
733
|
+
milestone=f_milestone,
|
|
734
|
+
show_closed=parse_toggle(f_show_closed),
|
|
735
|
+
stale_days=parse_stale_days(f_stale_days),
|
|
736
|
+
),
|
|
737
|
+
)
|
|
738
|
+
|
|
588
739
|
@app.post("/tasks/{task_id}/checklist/add", response_class=HTMLResponse)
|
|
589
740
|
async def checklist_add_route(
|
|
590
741
|
request: Request,
|
|
@@ -751,27 +902,148 @@ def create_app(workspace: str | Path = ".") -> FastAPI:
|
|
|
751
902
|
@app.get("/tasks/{task_id}/burndown.json")
|
|
752
903
|
def task_burndown_json(task_id: str) -> Response:
|
|
753
904
|
task = load_task(workspace, task_id)
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
905
|
+
fallback_iso = (
|
|
906
|
+
task.extra.get("created_at")
|
|
907
|
+
or task.start_date
|
|
908
|
+
or task.due_date
|
|
909
|
+
or task.completed_date
|
|
910
|
+
or datetime.now().isoformat(timespec="seconds")
|
|
911
|
+
)
|
|
912
|
+
|
|
913
|
+
progress_updates = [event for event in task.activity if event.event_type == "progress_update"]
|
|
914
|
+
progress_updates.sort(key=lambda item: item.created_at)
|
|
915
|
+
first_before = progress_updates[0].progress_before if progress_updates else None
|
|
916
|
+
current_progress = _clip_progress(first_before, task.percent_complete)
|
|
917
|
+
|
|
918
|
+
raw_events: list[dict[str, object]] = []
|
|
919
|
+
for note in task.notes:
|
|
920
|
+
summary = _summarize_text(note.body)
|
|
921
|
+
label = "Note added"
|
|
922
|
+
if summary:
|
|
923
|
+
label = f"Note: {summary}"
|
|
924
|
+
raw_events.append(
|
|
758
925
|
{
|
|
759
|
-
"
|
|
760
|
-
"
|
|
761
|
-
"label":
|
|
926
|
+
"created_at": note.created_at,
|
|
927
|
+
"event_type": "note",
|
|
928
|
+
"label": label,
|
|
929
|
+
"preview_title": "Note",
|
|
930
|
+
"preview_body": _preview_text(note.body),
|
|
931
|
+
"preview_path": "",
|
|
932
|
+
"is_image": False,
|
|
933
|
+
"progress_before": None,
|
|
934
|
+
"progress_after": None,
|
|
762
935
|
}
|
|
763
936
|
)
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
937
|
+
for attachment in task.attachments:
|
|
938
|
+
filename = (attachment.filename or "Attachment").strip() or "Attachment"
|
|
939
|
+
raw_events.append(
|
|
940
|
+
{
|
|
941
|
+
"created_at": attachment.uploaded_at,
|
|
942
|
+
"event_type": "attachment",
|
|
943
|
+
"label": f"Attachment: {filename}",
|
|
944
|
+
"preview_title": filename,
|
|
945
|
+
"preview_body": _preview_text(attachment.description),
|
|
946
|
+
"preview_path": attachment.path,
|
|
947
|
+
"is_image": attachment.kind == "image",
|
|
948
|
+
"progress_before": None,
|
|
949
|
+
"progress_after": None,
|
|
950
|
+
}
|
|
951
|
+
)
|
|
952
|
+
for event in task.activity:
|
|
953
|
+
if event.event_type == "progress_update":
|
|
954
|
+
before = _clip_progress(event.progress_before, current_progress)
|
|
955
|
+
after = _clip_progress(event.progress_after, before)
|
|
956
|
+
raw_events.append(
|
|
957
|
+
{
|
|
958
|
+
"created_at": event.created_at,
|
|
959
|
+
"event_type": "progress_update",
|
|
960
|
+
"label": f"Progress {before}% → {after}%",
|
|
961
|
+
"preview_title": "Progress update",
|
|
962
|
+
"preview_body": "",
|
|
963
|
+
"preview_path": "",
|
|
964
|
+
"is_image": False,
|
|
965
|
+
"progress_before": before,
|
|
966
|
+
"progress_after": after,
|
|
967
|
+
}
|
|
968
|
+
)
|
|
969
|
+
elif event.event_type == "image":
|
|
970
|
+
filename = (event.image_filename or "Attachment").strip() or "Attachment"
|
|
971
|
+
raw_events.append(
|
|
972
|
+
{
|
|
973
|
+
"created_at": event.created_at,
|
|
974
|
+
"event_type": "attachment",
|
|
975
|
+
"label": f"Attachment: {filename}",
|
|
976
|
+
"preview_title": filename,
|
|
977
|
+
"preview_body": _preview_text(event.note_text),
|
|
978
|
+
"preview_path": event.image_path or "",
|
|
979
|
+
"is_image": True,
|
|
980
|
+
"progress_before": None,
|
|
981
|
+
"progress_after": None,
|
|
982
|
+
}
|
|
983
|
+
)
|
|
984
|
+
else:
|
|
985
|
+
summary = _summarize_text(event.note_text)
|
|
986
|
+
label = "Note added"
|
|
987
|
+
if summary:
|
|
988
|
+
label = f"Note: {summary}"
|
|
989
|
+
raw_events.append(
|
|
767
990
|
{
|
|
768
|
-
"
|
|
769
|
-
"
|
|
770
|
-
"label":
|
|
991
|
+
"created_at": event.created_at,
|
|
992
|
+
"event_type": "note",
|
|
993
|
+
"label": label,
|
|
994
|
+
"preview_title": "Note",
|
|
995
|
+
"preview_body": _preview_text(event.note_text),
|
|
996
|
+
"preview_path": "",
|
|
997
|
+
"is_image": False,
|
|
998
|
+
"progress_before": None,
|
|
999
|
+
"progress_after": None,
|
|
771
1000
|
}
|
|
772
1001
|
)
|
|
1002
|
+
|
|
1003
|
+
raw_events.sort(
|
|
1004
|
+
key=lambda item: (
|
|
1005
|
+
_parse_event_datetime(str(item.get("created_at") or "")) or datetime.max,
|
|
1006
|
+
str(item.get("event_type") or ""),
|
|
1007
|
+
)
|
|
1008
|
+
)
|
|
1009
|
+
|
|
1010
|
+
points: list[dict[str, object]] = []
|
|
1011
|
+
for event in raw_events:
|
|
1012
|
+
if event.get("event_type") == "progress_update":
|
|
1013
|
+
current_progress = _clip_progress(
|
|
1014
|
+
event.get("progress_after") if isinstance(event.get("progress_after"), int) else None,
|
|
1015
|
+
current_progress,
|
|
1016
|
+
)
|
|
1017
|
+
points.append(
|
|
1018
|
+
{
|
|
1019
|
+
"created_at": str(event.get("created_at") or fallback_iso),
|
|
1020
|
+
"y": 100 - current_progress,
|
|
1021
|
+
"label": str(event.get("label") or "Update"),
|
|
1022
|
+
"event_type": str(event.get("event_type") or "update"),
|
|
1023
|
+
"preview_title": str(event.get("preview_title") or ""),
|
|
1024
|
+
"preview_body": str(event.get("preview_body") or ""),
|
|
1025
|
+
"preview_path": str(event.get("preview_path") or ""),
|
|
1026
|
+
"is_image": bool(event.get("is_image")),
|
|
1027
|
+
}
|
|
1028
|
+
)
|
|
1029
|
+
|
|
1030
|
+
if not points:
|
|
1031
|
+
points.append(
|
|
1032
|
+
{
|
|
1033
|
+
"created_at": fallback_iso,
|
|
1034
|
+
"y": 100 - _clip_progress(task.percent_complete),
|
|
1035
|
+
"label": f"Current progress: {_clip_progress(task.percent_complete)}%",
|
|
1036
|
+
"event_type": "snapshot",
|
|
1037
|
+
"preview_title": "Current snapshot",
|
|
1038
|
+
"preview_body": "",
|
|
1039
|
+
"preview_path": "",
|
|
1040
|
+
"is_image": False,
|
|
1041
|
+
}
|
|
1042
|
+
)
|
|
1043
|
+
|
|
1044
|
+
normalized_points = _normalize_event_points(points, fallback_iso)
|
|
773
1045
|
return Response(
|
|
774
|
-
json.dumps({"task_id": task_id, "title": task.title, "points":
|
|
1046
|
+
json.dumps({"task_id": task_id, "title": task.title, "points": normalized_points}),
|
|
775
1047
|
media_type="application/json",
|
|
776
1048
|
)
|
|
777
1049
|
|
|
@@ -913,42 +1185,196 @@ def create_app(workspace: str | Path = ".") -> FastAPI:
|
|
|
913
1185
|
tasks_by_id = {task.id: task for task in all_tasks}
|
|
914
1186
|
milestone_tasks = [tasks_by_id[task_id] for task_id in milestone.task_ids if task_id in tasks_by_id]
|
|
915
1187
|
|
|
916
|
-
|
|
1188
|
+
fallback_iso = (
|
|
1189
|
+
milestone.start_date
|
|
1190
|
+
or milestone.target_date
|
|
1191
|
+
or milestone.extra.get("created_at")
|
|
1192
|
+
or datetime.now().isoformat(timespec="seconds")
|
|
1193
|
+
)
|
|
1194
|
+
|
|
1195
|
+
task_progress: dict[str, int] = {}
|
|
917
1196
|
for task in milestone_tasks:
|
|
918
|
-
for event in task.activity
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
1197
|
+
updates = [event for event in task.activity if event.event_type == "progress_update"]
|
|
1198
|
+
updates.sort(key=lambda item: item.created_at)
|
|
1199
|
+
baseline = updates[0].progress_before if updates else task.percent_complete
|
|
1200
|
+
task_progress[task.id] = _clip_progress(baseline, task.percent_complete)
|
|
922
1201
|
|
|
923
|
-
|
|
1202
|
+
raw_events: list[dict[str, object]] = []
|
|
924
1203
|
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
1204
|
+
for note in milestone.notes:
|
|
1205
|
+
summary = _summarize_text(note.body)
|
|
1206
|
+
label = "Milestone note"
|
|
1207
|
+
if summary:
|
|
1208
|
+
label = f"Milestone note: {summary}"
|
|
1209
|
+
raw_events.append(
|
|
1210
|
+
{
|
|
1211
|
+
"created_at": note.created_at,
|
|
1212
|
+
"event_type": "note",
|
|
1213
|
+
"task_id": None,
|
|
1214
|
+
"label": label,
|
|
1215
|
+
"preview_title": "Milestone note",
|
|
1216
|
+
"preview_body": _preview_text(note.body),
|
|
1217
|
+
"preview_path": "",
|
|
1218
|
+
"is_image": False,
|
|
1219
|
+
"progress_after": None,
|
|
1220
|
+
}
|
|
1221
|
+
)
|
|
1222
|
+
for attachment in milestone.attachments:
|
|
1223
|
+
filename = (attachment.filename or "Attachment").strip() or "Attachment"
|
|
1224
|
+
raw_events.append(
|
|
1225
|
+
{
|
|
1226
|
+
"created_at": attachment.uploaded_at,
|
|
1227
|
+
"event_type": "attachment",
|
|
1228
|
+
"task_id": None,
|
|
1229
|
+
"label": f"Milestone attachment: {filename}",
|
|
1230
|
+
"preview_title": filename,
|
|
1231
|
+
"preview_body": _preview_text(attachment.description),
|
|
1232
|
+
"preview_path": attachment.path,
|
|
1233
|
+
"is_image": attachment.kind == "image",
|
|
1234
|
+
"progress_after": None,
|
|
1235
|
+
}
|
|
1236
|
+
)
|
|
1237
|
+
|
|
1238
|
+
for task in milestone_tasks:
|
|
1239
|
+
prefix = task.title.strip() or task.id
|
|
1240
|
+
for note in task.notes:
|
|
1241
|
+
summary = _summarize_text(note.body)
|
|
1242
|
+
label = f"{prefix}: note"
|
|
1243
|
+
if summary:
|
|
1244
|
+
label = f"{prefix}: {summary}"
|
|
1245
|
+
raw_events.append(
|
|
932
1246
|
{
|
|
933
|
-
"
|
|
934
|
-
"
|
|
935
|
-
"
|
|
1247
|
+
"created_at": note.created_at,
|
|
1248
|
+
"event_type": "note",
|
|
1249
|
+
"task_id": task.id,
|
|
1250
|
+
"label": label,
|
|
1251
|
+
"preview_title": f"{prefix} note",
|
|
1252
|
+
"preview_body": _preview_text(note.body),
|
|
1253
|
+
"preview_path": "",
|
|
1254
|
+
"is_image": False,
|
|
1255
|
+
"progress_after": None,
|
|
936
1256
|
}
|
|
937
1257
|
)
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
avg_remaining = round(sum(100 - progress for progress in task_progress.values()) / len(task_progress))
|
|
942
|
-
points.append(
|
|
1258
|
+
for attachment in task.attachments:
|
|
1259
|
+
filename = (attachment.filename or "Attachment").strip() or "Attachment"
|
|
1260
|
+
raw_events.append(
|
|
943
1261
|
{
|
|
944
|
-
"
|
|
945
|
-
"
|
|
946
|
-
"
|
|
1262
|
+
"created_at": attachment.uploaded_at,
|
|
1263
|
+
"event_type": "attachment",
|
|
1264
|
+
"task_id": task.id,
|
|
1265
|
+
"label": f"{prefix}: attachment {filename}",
|
|
1266
|
+
"preview_title": filename,
|
|
1267
|
+
"preview_body": _preview_text(attachment.description),
|
|
1268
|
+
"preview_path": attachment.path,
|
|
1269
|
+
"is_image": attachment.kind == "image",
|
|
1270
|
+
"progress_after": None,
|
|
947
1271
|
}
|
|
948
1272
|
)
|
|
1273
|
+
for event in task.activity:
|
|
1274
|
+
if event.event_type == "progress_update":
|
|
1275
|
+
before = _clip_progress(event.progress_before, task_progress.get(task.id, task.percent_complete))
|
|
1276
|
+
after = _clip_progress(event.progress_after, before)
|
|
1277
|
+
raw_events.append(
|
|
1278
|
+
{
|
|
1279
|
+
"created_at": event.created_at,
|
|
1280
|
+
"event_type": "progress_update",
|
|
1281
|
+
"task_id": task.id,
|
|
1282
|
+
"label": f"{prefix}: {before}% → {after}%",
|
|
1283
|
+
"preview_title": f"{prefix} progress",
|
|
1284
|
+
"preview_body": "",
|
|
1285
|
+
"preview_path": "",
|
|
1286
|
+
"is_image": False,
|
|
1287
|
+
"progress_after": after,
|
|
1288
|
+
}
|
|
1289
|
+
)
|
|
1290
|
+
elif event.event_type == "image":
|
|
1291
|
+
filename = (event.image_filename or "Attachment").strip() or "Attachment"
|
|
1292
|
+
raw_events.append(
|
|
1293
|
+
{
|
|
1294
|
+
"created_at": event.created_at,
|
|
1295
|
+
"event_type": "attachment",
|
|
1296
|
+
"task_id": task.id,
|
|
1297
|
+
"label": f"{prefix}: attachment {filename}",
|
|
1298
|
+
"preview_title": filename,
|
|
1299
|
+
"preview_body": _preview_text(event.note_text),
|
|
1300
|
+
"preview_path": event.image_path or "",
|
|
1301
|
+
"is_image": True,
|
|
1302
|
+
"progress_after": None,
|
|
1303
|
+
}
|
|
1304
|
+
)
|
|
1305
|
+
else:
|
|
1306
|
+
summary = _summarize_text(event.note_text)
|
|
1307
|
+
label = f"{prefix}: note"
|
|
1308
|
+
if summary:
|
|
1309
|
+
label = f"{prefix}: {summary}"
|
|
1310
|
+
raw_events.append(
|
|
1311
|
+
{
|
|
1312
|
+
"created_at": event.created_at,
|
|
1313
|
+
"event_type": "note",
|
|
1314
|
+
"task_id": task.id,
|
|
1315
|
+
"label": label,
|
|
1316
|
+
"preview_title": f"{prefix} note",
|
|
1317
|
+
"preview_body": _preview_text(event.note_text),
|
|
1318
|
+
"preview_path": "",
|
|
1319
|
+
"is_image": False,
|
|
1320
|
+
"progress_after": None,
|
|
1321
|
+
}
|
|
1322
|
+
)
|
|
1323
|
+
|
|
1324
|
+
raw_events.sort(
|
|
1325
|
+
key=lambda item: (
|
|
1326
|
+
_parse_event_datetime(str(item.get("created_at") or "")) or datetime.max,
|
|
1327
|
+
str(item.get("event_type") or ""),
|
|
1328
|
+
str(item.get("task_id") or ""),
|
|
1329
|
+
)
|
|
1330
|
+
)
|
|
1331
|
+
|
|
1332
|
+
def avg_remaining() -> int:
|
|
1333
|
+
if not task_progress:
|
|
1334
|
+
return 100
|
|
1335
|
+
return round(sum(100 - progress for progress in task_progress.values()) / len(task_progress))
|
|
1336
|
+
|
|
1337
|
+
points: list[dict[str, object]] = []
|
|
1338
|
+
for event in raw_events:
|
|
1339
|
+
if event.get("event_type") == "progress_update":
|
|
1340
|
+
task_id = str(event.get("task_id") or "")
|
|
1341
|
+
if task_id in task_progress:
|
|
1342
|
+
task_progress[task_id] = _clip_progress(
|
|
1343
|
+
event.get("progress_after") if isinstance(event.get("progress_after"), int) else None,
|
|
1344
|
+
task_progress[task_id],
|
|
1345
|
+
)
|
|
1346
|
+
points.append(
|
|
1347
|
+
{
|
|
1348
|
+
"created_at": str(event.get("created_at") or fallback_iso),
|
|
1349
|
+
"y": avg_remaining(),
|
|
1350
|
+
"label": str(event.get("label") or "Update"),
|
|
1351
|
+
"event_type": str(event.get("event_type") or "update"),
|
|
1352
|
+
"preview_title": str(event.get("preview_title") or ""),
|
|
1353
|
+
"preview_body": str(event.get("preview_body") or ""),
|
|
1354
|
+
"preview_path": str(event.get("preview_path") or ""),
|
|
1355
|
+
"is_image": bool(event.get("is_image")),
|
|
1356
|
+
}
|
|
1357
|
+
)
|
|
1358
|
+
|
|
1359
|
+
if not points and milestone_tasks:
|
|
1360
|
+
remaining = avg_remaining()
|
|
1361
|
+
points.append(
|
|
1362
|
+
{
|
|
1363
|
+
"created_at": fallback_iso,
|
|
1364
|
+
"y": remaining,
|
|
1365
|
+
"label": f"Current average remaining: {remaining}%",
|
|
1366
|
+
"event_type": "snapshot",
|
|
1367
|
+
"preview_title": "Current snapshot",
|
|
1368
|
+
"preview_body": "",
|
|
1369
|
+
"preview_path": "",
|
|
1370
|
+
"is_image": False,
|
|
1371
|
+
}
|
|
1372
|
+
)
|
|
1373
|
+
|
|
1374
|
+
normalized_points = _normalize_event_points(points, fallback_iso)
|
|
949
1375
|
|
|
950
1376
|
return Response(
|
|
951
|
-
json.dumps({"milestone_id": milestone_id, "title": milestone.title, "points":
|
|
1377
|
+
json.dumps({"milestone_id": milestone_id, "title": milestone.title, "points": normalized_points}),
|
|
952
1378
|
media_type="application/json",
|
|
953
1379
|
)
|
|
954
1380
|
|