taskunity 2026.1__tar.gz → 2026.3__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 (37) hide show
  1. {taskunity-2026.1/src/taskunity.egg-info → taskunity-2026.3}/PKG-INFO +1 -1
  2. {taskunity-2026.1 → taskunity-2026.3}/pyproject.toml +1 -1
  3. {taskunity-2026.1 → taskunity-2026.3}/src/taskunity/app.py +495 -49
  4. {taskunity-2026.1 → taskunity-2026.3}/src/taskunity/static/app.css +120 -2
  5. taskunity-2026.3/src/taskunity/templates/partials/milestone_banner.html +204 -0
  6. {taskunity-2026.1 → taskunity-2026.3}/src/taskunity/templates/partials/milestone_panel.html +0 -53
  7. {taskunity-2026.1 → taskunity-2026.3}/src/taskunity/templates/partials/projects.html +11 -0
  8. {taskunity-2026.1 → taskunity-2026.3}/src/taskunity/templates/partials/task_panel.html +176 -47
  9. {taskunity-2026.1 → taskunity-2026.3/src/taskunity.egg-info}/PKG-INFO +1 -1
  10. taskunity-2026.1/src/taskunity/templates/partials/milestone_banner.html +0 -34
  11. {taskunity-2026.1 → taskunity-2026.3}/MANIFEST.in +0 -0
  12. {taskunity-2026.1 → taskunity-2026.3}/README.md +0 -0
  13. {taskunity-2026.1 → taskunity-2026.3}/setup.cfg +0 -0
  14. {taskunity-2026.1 → taskunity-2026.3}/src/taskunity/__init__.py +0 -0
  15. {taskunity-2026.1 → taskunity-2026.3}/src/taskunity/cli.py +0 -0
  16. {taskunity-2026.1 → taskunity-2026.3}/src/taskunity/models.py +0 -0
  17. {taskunity-2026.1 → taskunity-2026.3}/src/taskunity/render.py +0 -0
  18. {taskunity-2026.1 → taskunity-2026.3}/src/taskunity/static/chart.umd.min.js +0 -0
  19. {taskunity-2026.1 → taskunity-2026.3}/src/taskunity/static/chartjs-adapter-date-fns.bundle.min.js +0 -0
  20. {taskunity-2026.1 → taskunity-2026.3}/src/taskunity/static/htmx.min.js +0 -0
  21. {taskunity-2026.1 → taskunity-2026.3}/src/taskunity/task_store.py +0 -0
  22. {taskunity-2026.1 → taskunity-2026.3}/src/taskunity/templates/base.html +0 -0
  23. {taskunity-2026.1 → taskunity-2026.3}/src/taskunity/templates/index.html +0 -0
  24. {taskunity-2026.1 → taskunity-2026.3}/src/taskunity/templates/partials/board.html +0 -0
  25. {taskunity-2026.1 → taskunity-2026.3}/src/taskunity/templates/partials/calendar.html +0 -0
  26. {taskunity-2026.1 → taskunity-2026.3}/src/taskunity/templates/partials/main.html +0 -0
  27. {taskunity-2026.1 → taskunity-2026.3}/src/taskunity/templates/partials/milestones.html +0 -0
  28. {taskunity-2026.1 → taskunity-2026.3}/src/taskunity/templates/partials/task_list.html +0 -0
  29. {taskunity-2026.1 → taskunity-2026.3}/src/taskunity/templates/partials/timeline.html +0 -0
  30. {taskunity-2026.1 → taskunity-2026.3}/src/taskunity.egg-info/SOURCES.txt +0 -0
  31. {taskunity-2026.1 → taskunity-2026.3}/src/taskunity.egg-info/dependency_links.txt +0 -0
  32. {taskunity-2026.1 → taskunity-2026.3}/src/taskunity.egg-info/entry_points.txt +0 -0
  33. {taskunity-2026.1 → taskunity-2026.3}/src/taskunity.egg-info/requires.txt +0 -0
  34. {taskunity-2026.1 → taskunity-2026.3}/src/taskunity.egg-info/top_level.txt +0 -0
  35. {taskunity-2026.1 → taskunity-2026.3}/tests/test_git_workspace_scope.py +0 -0
  36. {taskunity-2026.1 → taskunity-2026.3}/tests/test_render_jsonantt.py +0 -0
  37. {taskunity-2026.1 → taskunity-2026.3}/tests/test_workspace_config.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: taskunity
3
- Version: 2026.1
3
+ Version: 2026.3
4
4
  Summary: A local, file-backed productivity app for program/task tracking.
5
5
  Author: Taskunity Contributors
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "taskunity"
7
- version = "2026.1"
7
+ version = "2026.3"
8
8
  description = "A local, file-backed productivity app for program/task tracking."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -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("backlog"),
525
- priority: str = Form("normal"),
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: int = Form(0),
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
- task.status = status # pydantic validation occurs at save roundtrip in raw mode; keep simple for forms
548
- task.priority = priority
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
- old_progress = task.percent_complete
557
- task.percent_complete = max(0, min(int(percent_complete), 100))
558
- log_progress_change(workspace, task, old_progress, task.percent_complete)
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,158 @@ 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
- points: list[dict[str, object]] = []
755
- progress_events = [event for event in task.activity if event.event_type == "progress_update"]
756
- if not progress_events:
757
- points.append(
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
- "x": task.extra.get("created_at", ""),
760
- "y": 100 - task.percent_complete,
761
- "label": f"Current: {task.percent_complete}%",
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
- else:
765
- for event in sorted(progress_events, key=lambda item: item.created_at):
766
- points.append(
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(
767
957
  {
768
- "x": event.created_at,
769
- "y": 100 - (event.progress_after or 0),
770
- "label": f"{event.progress_before}% → {event.progress_after}%",
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,
771
967
  }
772
968
  )
969
+ elif event.event_type == "image":
970
+ filename = (event.image_filename or "Attachment").strip() or "Attachment"
971
+ image_name = event.image_filename or event.image_path or ""
972
+ is_image = Path(image_name).suffix.lower() in {
973
+ ".png",
974
+ ".jpg",
975
+ ".jpeg",
976
+ ".gif",
977
+ ".webp",
978
+ ".bmp",
979
+ ".svg",
980
+ }
981
+ raw_events.append(
982
+ {
983
+ "created_at": event.created_at,
984
+ "event_type": "attachment",
985
+ "label": f"Attachment: {filename}",
986
+ "preview_title": filename,
987
+ "preview_body": _preview_text(event.note_text),
988
+ "preview_path": event.image_path or "",
989
+ "is_image": is_image,
990
+ "progress_before": None,
991
+ "progress_after": None,
992
+ }
993
+ )
994
+ else:
995
+ summary = _summarize_text(event.note_text)
996
+ label = "Note added"
997
+ if summary:
998
+ label = f"Note: {summary}"
999
+ raw_events.append(
1000
+ {
1001
+ "created_at": event.created_at,
1002
+ "event_type": "note",
1003
+ "label": label,
1004
+ "preview_title": "Note",
1005
+ "preview_body": _preview_text(event.note_text),
1006
+ "preview_path": "",
1007
+ "is_image": False,
1008
+ "progress_before": None,
1009
+ "progress_after": None,
1010
+ }
1011
+ )
1012
+
1013
+ raw_events.sort(
1014
+ key=lambda item: (
1015
+ _parse_event_datetime(str(item.get("created_at") or "")) or datetime.max,
1016
+ str(item.get("event_type") or ""),
1017
+ )
1018
+ )
1019
+
1020
+ points: list[dict[str, object]] = []
1021
+ for event in raw_events:
1022
+ if event.get("event_type") == "progress_update":
1023
+ current_progress = _clip_progress(
1024
+ event.get("progress_after") if isinstance(event.get("progress_after"), int) else None,
1025
+ current_progress,
1026
+ )
1027
+ points.append(
1028
+ {
1029
+ "created_at": str(event.get("created_at") or fallback_iso),
1030
+ "y": 100 - current_progress,
1031
+ "label": str(event.get("label") or "Update"),
1032
+ "event_type": str(event.get("event_type") or "update"),
1033
+ "preview_title": str(event.get("preview_title") or ""),
1034
+ "preview_body": str(event.get("preview_body") or ""),
1035
+ "preview_path": str(event.get("preview_path") or ""),
1036
+ "is_image": bool(event.get("is_image")),
1037
+ }
1038
+ )
1039
+
1040
+ if not points:
1041
+ points.append(
1042
+ {
1043
+ "created_at": fallback_iso,
1044
+ "y": 100 - _clip_progress(task.percent_complete),
1045
+ "label": f"Current progress: {_clip_progress(task.percent_complete)}%",
1046
+ "event_type": "snapshot",
1047
+ "preview_title": "Current snapshot",
1048
+ "preview_body": "",
1049
+ "preview_path": "",
1050
+ "is_image": False,
1051
+ }
1052
+ )
1053
+
1054
+ normalized_points = _normalize_event_points(points, fallback_iso)
773
1055
  return Response(
774
- json.dumps({"task_id": task_id, "title": task.title, "points": points}),
1056
+ json.dumps({"task_id": task_id, "title": task.title, "points": normalized_points}),
775
1057
  media_type="application/json",
776
1058
  )
777
1059
 
@@ -913,42 +1195,206 @@ def create_app(workspace: str | Path = ".") -> FastAPI:
913
1195
  tasks_by_id = {task.id: task for task in all_tasks}
914
1196
  milestone_tasks = [tasks_by_id[task_id] for task_id in milestone.task_ids if task_id in tasks_by_id]
915
1197
 
916
- all_events: list[tuple[str, Task, object]] = []
1198
+ fallback_iso = (
1199
+ milestone.start_date
1200
+ or milestone.target_date
1201
+ or milestone.extra.get("created_at")
1202
+ or datetime.now().isoformat(timespec="seconds")
1203
+ )
1204
+
1205
+ task_progress: dict[str, int] = {}
917
1206
  for task in milestone_tasks:
918
- for event in task.activity:
919
- if event.event_type == "progress_update":
920
- all_events.append((event.created_at, task, event))
921
- all_events.sort(key=lambda item: item[0])
1207
+ updates = [event for event in task.activity if event.event_type == "progress_update"]
1208
+ updates.sort(key=lambda item: item.created_at)
1209
+ baseline = updates[0].progress_before if updates else task.percent_complete
1210
+ task_progress[task.id] = _clip_progress(baseline, task.percent_complete)
922
1211
 
923
- task_progress = {task.id: 0 for task in milestone_tasks}
1212
+ raw_events: list[dict[str, object]] = []
924
1213
 
925
- points: list[dict[str, object]] = []
926
- if not all_events:
927
- if milestone_tasks:
928
- avg_remaining = round(
929
- sum(100 - task.percent_complete for task in milestone_tasks) / len(milestone_tasks)
930
- )
931
- points.append(
1214
+ for note in milestone.notes:
1215
+ summary = _summarize_text(note.body)
1216
+ label = "Milestone note"
1217
+ if summary:
1218
+ label = f"Milestone note: {summary}"
1219
+ raw_events.append(
1220
+ {
1221
+ "created_at": note.created_at,
1222
+ "event_type": "note",
1223
+ "task_id": None,
1224
+ "label": label,
1225
+ "preview_title": "Milestone note",
1226
+ "preview_body": _preview_text(note.body),
1227
+ "preview_path": "",
1228
+ "is_image": False,
1229
+ "progress_after": None,
1230
+ }
1231
+ )
1232
+ for attachment in milestone.attachments:
1233
+ filename = (attachment.filename or "Attachment").strip() or "Attachment"
1234
+ raw_events.append(
1235
+ {
1236
+ "created_at": attachment.uploaded_at,
1237
+ "event_type": "attachment",
1238
+ "task_id": None,
1239
+ "label": f"Milestone attachment: {filename}",
1240
+ "preview_title": filename,
1241
+ "preview_body": _preview_text(attachment.description),
1242
+ "preview_path": attachment.path,
1243
+ "is_image": attachment.kind == "image",
1244
+ "progress_after": None,
1245
+ }
1246
+ )
1247
+
1248
+ for task in milestone_tasks:
1249
+ prefix = task.title.strip() or task.id
1250
+ for note in task.notes:
1251
+ summary = _summarize_text(note.body)
1252
+ label = f"{prefix}: note"
1253
+ if summary:
1254
+ label = f"{prefix}: {summary}"
1255
+ raw_events.append(
932
1256
  {
933
- "x": milestone.start_date or "",
934
- "y": avg_remaining,
935
- "label": f"{len(milestone_tasks)} tasks, avg remaining: {avg_remaining}%",
1257
+ "created_at": note.created_at,
1258
+ "event_type": "note",
1259
+ "task_id": task.id,
1260
+ "label": label,
1261
+ "preview_title": f"{prefix} note",
1262
+ "preview_body": _preview_text(note.body),
1263
+ "preview_path": "",
1264
+ "is_image": False,
1265
+ "progress_after": None,
936
1266
  }
937
1267
  )
938
- else:
939
- for created_at, task, event in all_events:
940
- task_progress[task.id] = event.progress_after
941
- avg_remaining = round(sum(100 - progress for progress in task_progress.values()) / len(task_progress))
942
- points.append(
1268
+ for attachment in task.attachments:
1269
+ filename = (attachment.filename or "Attachment").strip() or "Attachment"
1270
+ raw_events.append(
943
1271
  {
944
- "x": created_at,
945
- "y": avg_remaining,
946
- "label": f"{task.title}: {event.progress_before}% → {event.progress_after}%",
1272
+ "created_at": attachment.uploaded_at,
1273
+ "event_type": "attachment",
1274
+ "task_id": task.id,
1275
+ "label": f"{prefix}: attachment {filename}",
1276
+ "preview_title": filename,
1277
+ "preview_body": _preview_text(attachment.description),
1278
+ "preview_path": attachment.path,
1279
+ "is_image": attachment.kind == "image",
1280
+ "progress_after": None,
947
1281
  }
948
1282
  )
1283
+ for event in task.activity:
1284
+ if event.event_type == "progress_update":
1285
+ before = _clip_progress(event.progress_before, task_progress.get(task.id, task.percent_complete))
1286
+ after = _clip_progress(event.progress_after, before)
1287
+ raw_events.append(
1288
+ {
1289
+ "created_at": event.created_at,
1290
+ "event_type": "progress_update",
1291
+ "task_id": task.id,
1292
+ "label": f"{prefix}: {before}% → {after}%",
1293
+ "preview_title": f"{prefix} progress",
1294
+ "preview_body": "",
1295
+ "preview_path": "",
1296
+ "is_image": False,
1297
+ "progress_after": after,
1298
+ }
1299
+ )
1300
+ elif event.event_type == "image":
1301
+ filename = (event.image_filename or "Attachment").strip() or "Attachment"
1302
+ image_name = event.image_filename or event.image_path or ""
1303
+ is_image = Path(image_name).suffix.lower() in {
1304
+ ".png",
1305
+ ".jpg",
1306
+ ".jpeg",
1307
+ ".gif",
1308
+ ".webp",
1309
+ ".bmp",
1310
+ ".svg",
1311
+ }
1312
+ raw_events.append(
1313
+ {
1314
+ "created_at": event.created_at,
1315
+ "event_type": "attachment",
1316
+ "task_id": task.id,
1317
+ "label": f"{prefix}: attachment {filename}",
1318
+ "preview_title": filename,
1319
+ "preview_body": _preview_text(event.note_text),
1320
+ "preview_path": event.image_path or "",
1321
+ "is_image": is_image,
1322
+ "progress_after": None,
1323
+ }
1324
+ )
1325
+ else:
1326
+ summary = _summarize_text(event.note_text)
1327
+ label = f"{prefix}: note"
1328
+ if summary:
1329
+ label = f"{prefix}: {summary}"
1330
+ raw_events.append(
1331
+ {
1332
+ "created_at": event.created_at,
1333
+ "event_type": "note",
1334
+ "task_id": task.id,
1335
+ "label": label,
1336
+ "preview_title": f"{prefix} note",
1337
+ "preview_body": _preview_text(event.note_text),
1338
+ "preview_path": "",
1339
+ "is_image": False,
1340
+ "progress_after": None,
1341
+ }
1342
+ )
1343
+
1344
+ raw_events.sort(
1345
+ key=lambda item: (
1346
+ _parse_event_datetime(str(item.get("created_at") or "")) or datetime.max,
1347
+ str(item.get("event_type") or ""),
1348
+ str(item.get("task_id") or ""),
1349
+ )
1350
+ )
1351
+
1352
+ def avg_remaining() -> int:
1353
+ if not task_progress:
1354
+ return 100
1355
+ return round(sum(100 - progress for progress in task_progress.values()) / len(task_progress))
1356
+
1357
+ points: list[dict[str, object]] = []
1358
+ for event in raw_events:
1359
+ if event.get("event_type") == "progress_update":
1360
+ task_id = str(event.get("task_id") or "")
1361
+ if task_id in task_progress:
1362
+ task_progress[task_id] = _clip_progress(
1363
+ event.get("progress_after") if isinstance(event.get("progress_after"), int) else None,
1364
+ task_progress[task_id],
1365
+ )
1366
+ points.append(
1367
+ {
1368
+ "created_at": str(event.get("created_at") or fallback_iso),
1369
+ "y": avg_remaining(),
1370
+ "label": str(event.get("label") or "Update"),
1371
+ "event_type": str(event.get("event_type") or "update"),
1372
+ "preview_title": str(event.get("preview_title") or ""),
1373
+ "preview_body": str(event.get("preview_body") or ""),
1374
+ "preview_path": str(event.get("preview_path") or ""),
1375
+ "is_image": bool(event.get("is_image")),
1376
+ }
1377
+ )
1378
+
1379
+ if not points and milestone_tasks:
1380
+ remaining = avg_remaining()
1381
+ points.append(
1382
+ {
1383
+ "created_at": fallback_iso,
1384
+ "y": remaining,
1385
+ "label": f"Current average remaining: {remaining}%",
1386
+ "event_type": "snapshot",
1387
+ "preview_title": "Current snapshot",
1388
+ "preview_body": "",
1389
+ "preview_path": "",
1390
+ "is_image": False,
1391
+ }
1392
+ )
1393
+
1394
+ normalized_points = _normalize_event_points(points, fallback_iso)
949
1395
 
950
1396
  return Response(
951
- json.dumps({"milestone_id": milestone_id, "title": milestone.title, "points": points}),
1397
+ json.dumps({"milestone_id": milestone_id, "title": milestone.title, "points": normalized_points}),
952
1398
  media_type="application/json",
953
1399
  )
954
1400