dctracker 1.0.0__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.
Files changed (87) hide show
  1. dct/__init__.py +6 -0
  2. dct/cli.py +1659 -0
  3. dct/config.py +150 -0
  4. dct/core/__init__.py +0 -0
  5. dct/core/analytics.py +382 -0
  6. dct/core/changelog.py +112 -0
  7. dct/core/checkpoints.py +205 -0
  8. dct/core/decisions.py +238 -0
  9. dct/core/engine.py +32 -0
  10. dct/core/handoff.py +151 -0
  11. dct/core/ids.py +25 -0
  12. dct/core/items.py +382 -0
  13. dct/core/plans/__init__.py +6 -0
  14. dct/core/plans/classify.py +94 -0
  15. dct/core/plans/crud.py +680 -0
  16. dct/core/plans/ingest.py +408 -0
  17. dct/core/plans/parser.py +312 -0
  18. dct/core/projects.py +298 -0
  19. dct/core/sprint.py +315 -0
  20. dct/core/sprints.py +601 -0
  21. dct/core/version.py +116 -0
  22. dct/db.py +558 -0
  23. dct/export.py +107 -0
  24. dct/hooks/check-changelog-on-stop.sh +49 -0
  25. dct/hooks/check-changelog.sh +143 -0
  26. dct/hooks/dct-plan-autoscan.sh +54 -0
  27. dct/hooks/dct-task-mirror.sh +62 -0
  28. dct/server.py +1812 -0
  29. dct/setup.py +258 -0
  30. dct/skills/clog/SKILL.md +73 -0
  31. dct/skills/commit/SKILL.md +153 -0
  32. dct/skills/dct-import/SKILL.md +329 -0
  33. dct/skills/dct-init/SKILL.md +289 -0
  34. dct/skills/handoff/SKILL.md +136 -0
  35. dct/skills/pickup/SKILL.md +115 -0
  36. dct/skills/plan-resolve-uncertain/SKILL.md +82 -0
  37. dct/skills/plan-status/SKILL.md +79 -0
  38. dct/skills/release/SKILL.md +281 -0
  39. dct/skills/track/SKILL.md +121 -0
  40. dct/skills/track-list/SKILL.md +134 -0
  41. dct/skills/track-resolve/SKILL.md +53 -0
  42. dct/skills/track-update/SKILL.md +109 -0
  43. dct/web/__init__.py +1 -0
  44. dct/web/app.py +83 -0
  45. dct/web/blueprints/__init__.py +5 -0
  46. dct/web/blueprints/analytics.py +34 -0
  47. dct/web/blueprints/changelog.py +40 -0
  48. dct/web/blueprints/decisions.py +23 -0
  49. dct/web/blueprints/events.py +69 -0
  50. dct/web/blueprints/handoffs.py +26 -0
  51. dct/web/blueprints/home.py +15 -0
  52. dct/web/blueprints/items.py +128 -0
  53. dct/web/blueprints/plans.py +53 -0
  54. dct/web/blueprints/projects.py +23 -0
  55. dct/web/blueprints/sprints.py +71 -0
  56. dct/web/changes.py +77 -0
  57. dct/web/serializers.py +109 -0
  58. dct/web/static/app.css +609 -0
  59. dct/web/static/app.js +62 -0
  60. dct/web/static/vendor/SOURCES.txt +10 -0
  61. dct/web/static/vendor/htmx.min.js +1 -0
  62. dct/web/static/vendor/uPlot.iife.min.js +2 -0
  63. dct/web/static/vendor/uPlot.min.css +1 -0
  64. dct/web/templates/analytics.html +63 -0
  65. dct/web/templates/base.html +106 -0
  66. dct/web/templates/changelog/list.html +23 -0
  67. dct/web/templates/decisions/list.html +22 -0
  68. dct/web/templates/handoffs/list.html +45 -0
  69. dct/web/templates/home.html +20 -0
  70. dct/web/templates/item_detail.html +91 -0
  71. dct/web/templates/items_list.html +44 -0
  72. dct/web/templates/partials/_bars.html +15 -0
  73. dct/web/templates/partials/_checkpoint_steps.html +22 -0
  74. dct/web/templates/partials/_items_table.html +23 -0
  75. dct/web/templates/partials/_plan_section.html +24 -0
  76. dct/web/templates/partials/_project_card.html +24 -0
  77. dct/web/templates/plans/detail.html +16 -0
  78. dct/web/templates/plans/list.html +15 -0
  79. dct/web/templates/project_home.html +51 -0
  80. dct/web/templates/sprints/detail.html +37 -0
  81. dct/web/templates/sprints/list.html +35 -0
  82. dctracker-1.0.0.dist-info/METADATA +357 -0
  83. dctracker-1.0.0.dist-info/RECORD +87 -0
  84. dctracker-1.0.0.dist-info/WHEEL +5 -0
  85. dctracker-1.0.0.dist-info/entry_points.txt +2 -0
  86. dctracker-1.0.0.dist-info/licenses/LICENSE +21 -0
  87. dctracker-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,109 @@
1
+ ---
2
+ name: track-update
3
+ description: |
4
+ Update an existing tracked item in dct.
5
+ Use when: "update issue", "edit item", "change priority", "zmień issue",
6
+ "track-update", "set status", "reassign priority",
7
+ "w toku", "zacznij", "rozpocznij", "start working on", "in progress",
8
+ "wip", "mark as wip", "assign priority", "flag as sprint", "add to sprint",
9
+ "remove from sprint", "wstrzymaj", "pause".
10
+ ---
11
+
12
+ # /track-update — Update Tracked Item
13
+
14
+ ## Step 1: Identify item(s)
15
+
16
+ **ID normalization** — accept all of these forms from user:
17
+ - `#42`, `42`, `ITEM-42`, `item-42`, `0042`
18
+
19
+ Parse by: strip leading `#`, strip `ITEM-`/`item-`/`ITEM_`/`item_` prefix
20
+ (case-insensitive), strip leading zeros, cast to integer.
21
+ Regex: `^(#|ITEM-|item-|ITEM_|item_)?0*(\d+)$` — capture group 2 is the numeric id.
22
+
23
+ **Batch form** — user may give multiple ids for the SAME change, e.g.
24
+ "set #42 #45 to P1" or "w toku: 7, 12, 19". Parse each id separately
25
+ (whitespace or comma separated) and apply the same update to all. Ask
26
+ confirmation ONCE for the batch, not per-item.
27
+
28
+ Invalid inputs (non-digit body, empty, negative) — reject with clear message.
29
+
30
+ For each id, call `mcp__dct__get_item(item_id)` to fetch current state.
31
+
32
+ ## Step 2: Determine changes
33
+
34
+ From user request, identify which fields to change:
35
+ - `title`, `status`, `priority`, `component`, `description`, `resolution`
36
+
37
+ `update_item` does NOT change sprint membership — `in_current_sprint` is a
38
+ derived, read-only field. Sprint changes go through dedicated tools
39
+ (see "Sprint membership" below).
40
+
41
+ Status transitions:
42
+ - "start" / "zacznij" / "rozpocznij" / "w toku" / "wip" / "in progress" → `status="in_progress"`
43
+ - "pause" / "wstrzymaj" / "block" / "zablokuj" → keep status, add note explaining pause
44
+ - "open" / "reopen" / "wznów" → `status="open"` (also clears `resolved_at` automatically — see CLAUDE.md terminal invariant)
45
+
46
+ Sprint membership (NOT via update_item):
47
+ - "add to sprint" / "flag for sprint" / "na sprint" / "dodaj do sprintu" →
48
+ `mcp__dct__add_item_to_current_sprint(item_id, project)` — auto-creates the
49
+ current sprint; additive, keeps existing members.
50
+ - "remove from sprint" / "out of sprint" / "zdejmij ze sprintu" →
51
+ `mcp__dct__remove_item_from_current_sprint(item_id, project)`.
52
+ - To replace the WHOLE current sprint at once: `mcp__dct__set_sprint(project, item_ids)`.
53
+
54
+ ## Step 3: Confirm
55
+
56
+ Show before/after (batch shows list):
57
+ ```
58
+ #42 — Parser crashes on empty files
59
+ Priority: P2 → P1
60
+ Status: open → in_progress
61
+ + Note: "Reproducible on 0-byte input, blocking v0.3"
62
+ ```
63
+
64
+ Wait for confirmation.
65
+
66
+ ## Step 4: Execute
67
+
68
+ ### When add_note is MANDATORY
69
+
70
+ Call `mcp__dct__add_note(item_id, note)` BEFORE the `update_item` call when the
71
+ transition is one of:
72
+ - **status** change (especially to `in_progress` or any terminal) — note the reason / link to commit
73
+ - **priority** change — note what prompted the bump or demotion
74
+ - **resolution** set — note the commit hash or decision rationale
75
+
76
+ This is the audit trail — future /pickup will read these notes via `get_item`.
77
+
78
+ ### When add_note is OPTIONAL
79
+
80
+ Title, component, description, tags edits — notes welcome but not required.
81
+
82
+ Then call `mcp__dct__update_item(item_id, ...)` with changed fields.
83
+ For batch updates, loop per-id with the same kwargs.
84
+
85
+ For sprint membership changes, call `add_item_to_current_sprint` /
86
+ `remove_item_from_current_sprint` (or `set_sprint` for a full replace) — NOT
87
+ `update_item`.
88
+
89
+ ## Step 5: Report
90
+
91
+ Single:
92
+ ```
93
+ ✓ #42 updated — priority P1, status in_progress
94
+ ```
95
+
96
+ Batch:
97
+ ```
98
+ ✓ 3 items updated — priority P1, status in_progress
99
+ #7, #12, #19
100
+ ```
101
+
102
+ ## Rules
103
+
104
+ - **Every status/priority change gets a note** — no "I'll explain later" allowed.
105
+ - **Batch confirmations are one-shot** — do NOT ask per-item if user clearly grouped them.
106
+ - **ID normalization is uniform** — use the same regex as /track-resolve and /track-list.
107
+ - **Terminal transitions auto-remove the item from the current sprint** (resolve
108
+ soft-removes its item_sprints links); sprint membership is never set via
109
+ `update_item` — `in_current_sprint` is derived and read-only.
dct/web/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """dct web viewer — v1 read-only Flask adapter over dct.core."""
dct/web/app.py ADDED
@@ -0,0 +1,83 @@
1
+ """Flask application factory for the dct web viewer (v1, read-only).
2
+
3
+ Third adapter over ``dct.core`` — symmetric to the CLI and MCP server. Read-only
4
+ means the web layer NEVER calls ``create_tables()``: when it has to open its own
5
+ engine it uses ``bootstrap_engine(create=False)``. Schema ownership stays with
6
+ the CLI / MCP writers.
7
+ """
8
+
9
+ import flask
10
+
11
+ from dct.core.engine import bootstrap_engine
12
+ from dct.web.serializers import parse_tags, render_markdown, to_iso
13
+
14
+ _BLUEPRINT_MODULES = (
15
+ "home",
16
+ "projects",
17
+ "items",
18
+ "sprints",
19
+ "plans",
20
+ "changelog",
21
+ "decisions",
22
+ "handoffs",
23
+ "analytics",
24
+ "events",
25
+ )
26
+
27
+
28
+ def create_app(engine=None, *, read_only: bool = True) -> flask.Flask:
29
+ """Build the Flask app.
30
+
31
+ When `engine` is None, open a fresh read-only engine via
32
+ ``bootstrap_engine(create=False)`` — never creating tables. The engine is
33
+ stashed on ``app.config["DCT_ENGINE"]`` for blueprints to read via
34
+ :func:`current_engine`.
35
+ """
36
+ if engine is None:
37
+ engine = bootstrap_engine(create=False)
38
+
39
+ app = flask.Flask(__name__)
40
+ app.config["DCT_ENGINE"] = engine
41
+ app.config["DCT_READ_ONLY"] = read_only
42
+
43
+ # Jinja filters — registered HERE only. Templates across all tasks rely on
44
+ # these; no other task registers filters or adds a conditional fallback.
45
+ app.jinja_env.filters["render_markdown"] = render_markdown
46
+ app.jinja_env.filters["to_iso"] = to_iso
47
+ app.jinja_env.filters["parse_tags"] = parse_tags
48
+
49
+ from importlib import import_module
50
+
51
+ for name in _BLUEPRINT_MODULES:
52
+ module = import_module(f"dct.web.blueprints.{name}")
53
+ app.register_blueprint(module.bp)
54
+
55
+ @app.context_processor
56
+ def _inject_nav():
57
+ """Expose the project list + current location to every template, so the
58
+ sidebar can render the project switcher, highlight the active project,
59
+ and show a per-project section sub-nav."""
60
+ from dct.core.projects import list_projects
61
+
62
+ view_args = (flask.request.view_args or {}) if flask.request else {}
63
+ endpoint = (flask.request.endpoint or "") if flask.request else ""
64
+ try:
65
+ projects = list_projects(engine)
66
+ except Exception:
67
+ projects = []
68
+ # Detail routes (/sprints/<id>, /plans/<id>) have no slug in the URL but
69
+ # set flask.g.current_slug/section so the sidebar + breadcrumb still scope
70
+ # to the owning project. g wins; URL/endpoint are the fallback.
71
+ return {
72
+ "projects": projects,
73
+ "current_slug": getattr(flask.g, "current_slug", None) or view_args.get("slug"),
74
+ "current_section": getattr(flask.g, "current_section", None)
75
+ or (endpoint.split(".")[0] if endpoint else ""),
76
+ }
77
+
78
+ return app
79
+
80
+
81
+ def current_engine():
82
+ """Return the shared engine bound to the active Flask app."""
83
+ return flask.current_app.config["DCT_ENGINE"]
@@ -0,0 +1,5 @@
1
+ """Route blueprints for the dct web viewer.
2
+
3
+ Each module exposes a module-level ``bp`` Flask Blueprint. v1 stubs are filled
4
+ in by later tasks; ``events`` already owns ``/healthz``.
5
+ """
@@ -0,0 +1,34 @@
1
+ """Analytics dashboard blueprint: Backlog Health + Velocity (read-only)."""
2
+ from __future__ import annotations
3
+
4
+ from flask import Blueprint, abort, render_template
5
+
6
+ from dct.core.analytics import backlog_health, velocity
7
+ from dct.core.projects import get_project
8
+ from dct.web.app import current_engine
9
+
10
+ bp = Blueprint("analytics", __name__)
11
+
12
+
13
+ @bp.route("/p/<slug>/analytics")
14
+ def dashboard(slug: str):
15
+ """Render the two-tab BI dashboard for a single project.
16
+
17
+ Tab 1 (Backlog Health) uses CSS/SVG bars over ``backlog_health`` dicts.
18
+ Tab 2 (Velocity) emits the ``velocity`` dict as an embedded JSON payload
19
+ that the vendored uPlot chart-init (static/app.js) reads on load.
20
+ """
21
+ engine = current_engine()
22
+ project = get_project(engine, slug)
23
+ if project is None:
24
+ abort(404)
25
+
26
+ health = backlog_health(engine, slug)
27
+ vel = velocity(engine, slug)
28
+ return render_template(
29
+ "analytics.html",
30
+ project=project,
31
+ slug=slug,
32
+ health=health,
33
+ velocity=vel,
34
+ )
@@ -0,0 +1,40 @@
1
+ """Changelog view — grouped by version, unreleased bucket first."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from flask import Blueprint, render_template
6
+
7
+ from dct.web.app import current_engine
8
+ from dct.core import changelog as changelog_core
9
+
10
+ bp = Blueprint("changelog", __name__)
11
+
12
+
13
+ @bp.route("/p/<slug>/changelog")
14
+ def list(slug): # noqa: A001 — endpoint name changelog.list is part of the contract
15
+ engine = current_engine()
16
+ entries = changelog_core.list_entries(engine, slug)
17
+ unreleased_count = changelog_core.count_unreleased(engine, slug)
18
+
19
+ # Group by version, preserving created_at-desc order. version=None => unreleased.
20
+ groups: dict = {}
21
+ order: list = []
22
+ for entry in entries:
23
+ key = entry.get("version") # None for unreleased
24
+ if key not in groups:
25
+ groups[key] = []
26
+ order.append(key)
27
+ groups[key].append(entry)
28
+
29
+ # Unreleased bucket (None) always rendered first, then versions in encounter order.
30
+ version_groups = []
31
+ if None in groups:
32
+ version_groups.append((None, groups[None]))
33
+ version_groups.extend((k, groups[k]) for k in order if k is not None)
34
+
35
+ return render_template(
36
+ "changelog/list.html",
37
+ slug=slug,
38
+ version_groups=version_groups,
39
+ unreleased_count=unreleased_count,
40
+ )
@@ -0,0 +1,23 @@
1
+ """Decision views — ADR list with supersede chains + status."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from flask import Blueprint, render_template
6
+
7
+ from dct.web.app import current_engine
8
+ from dct.core import decisions as decisions_core
9
+
10
+ bp = Blueprint("decisions", __name__)
11
+
12
+
13
+ @bp.route("/p/<slug>/decisions")
14
+ def list(slug): # noqa: A001 — endpoint name decisions.list is part of the contract
15
+ engine = current_engine()
16
+ decisions = decisions_core.list_decisions(engine, project_slug=slug)
17
+ by_id = {d["id"]: d for d in decisions}
18
+ return render_template(
19
+ "decisions/list.html",
20
+ slug=slug,
21
+ decisions=decisions,
22
+ by_id=by_id,
23
+ )
@@ -0,0 +1,69 @@
1
+ """SSE live-refresh stream and liveness probe (read-only)."""
2
+
3
+ import time
4
+
5
+ from flask import Blueprint, Response
6
+
7
+ bp = Blueprint("events", __name__)
8
+
9
+
10
+ def _sse_events(token_source, *, poll_interval, sleep=time.sleep, should_continue=None):
11
+ """Yield SSE frames; emit an `event: stale` frame whenever the token changes.
12
+
13
+ token_source: zero-arg callable returning the current change token (str).
14
+ poll_interval: seconds to sleep between polls.
15
+ sleep: injectable sleep (tests pass a no-op).
16
+ should_continue: zero-arg predicate gating the loop. Defaults to an infinite
17
+ stream (real request); tests pass a bounded predicate.
18
+
19
+ Pure: no Flask / DB imports, so it is unit-testable with a fake token source.
20
+ """
21
+ if should_continue is None:
22
+ should_continue = lambda: True # noqa: E731 — infinite stream for a live request
23
+ last = token_source()
24
+ # Opening comment defeats proxy buffering and confirms the stream is live.
25
+ yield ": connected\n\n"
26
+ while should_continue():
27
+ sleep(poll_interval)
28
+ current = token_source()
29
+ if current != last:
30
+ last = current
31
+ yield f"event: stale\ndata: {current}\n\n"
32
+ else:
33
+ yield ": keep-alive\n\n"
34
+
35
+
36
+ @bp.route("/events")
37
+ def stream():
38
+ """text/event-stream: poll change_token, push a 'stale' event on change."""
39
+ from dct.config import load_config
40
+ from dct.web.app import current_engine
41
+ from dct.web.changes import change_token
42
+
43
+ engine = current_engine() # capture in request context, before the generator runs
44
+ web_cfg = getattr(load_config(), "web", None)
45
+ poll_ms = getattr(web_cfg, "refresh_poll_ms", 2000)
46
+ poll = max(int(poll_ms), 250) / 1000.0 # floor avoids hammering the DB
47
+
48
+ # NOTE: this is an unbounded generator — with Flask threaded=True each open
49
+ # tab pins one worker thread polling change_token() every `poll` seconds for
50
+ # the life of the connection. Acceptable for the localhost single-user v1;
51
+ # revisit (max lifetime / backoff) before any multi-client exposure.
52
+ def gen():
53
+ yield from _sse_events(lambda: change_token(engine), poll_interval=poll)
54
+
55
+ return Response(
56
+ gen(),
57
+ mimetype="text/event-stream",
58
+ headers={
59
+ "Cache-Control": "no-cache",
60
+ "X-Accel-Buffering": "no", # disable nginx buffering if proxied
61
+ "Connection": "keep-alive",
62
+ },
63
+ )
64
+
65
+
66
+ @bp.route("/healthz")
67
+ def healthz():
68
+ """Liveness probe."""
69
+ return {"status": "ok"}
@@ -0,0 +1,26 @@
1
+ """Handoff views — ledger split into unconsumed inbox + consumed history."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from flask import Blueprint, render_template
6
+
7
+ from dct.web.app import current_engine
8
+ from dct.core import handoff as handoff_core
9
+
10
+ bp = Blueprint("handoffs", __name__)
11
+
12
+
13
+ @bp.route("/p/<slug>/handoffs")
14
+ def list(slug): # noqa: A001 — endpoint name handoffs.list is part of the contract
15
+ engine = current_engine()
16
+ all_handoffs = handoff_core.list_handoffs(
17
+ engine, project_slug=slug, unconsumed_only=False
18
+ )
19
+ unconsumed = [h for h in all_handoffs if h.get("consumed_at") is None]
20
+ consumed = [h for h in all_handoffs if h.get("consumed_at") is not None]
21
+ return render_template(
22
+ "handoffs/list.html",
23
+ slug=slug,
24
+ unconsumed=unconsumed,
25
+ consumed=consumed,
26
+ )
@@ -0,0 +1,15 @@
1
+ """Home view — cross-project Radar rollup cards (GET /)."""
2
+
3
+ from flask import Blueprint, render_template
4
+
5
+ from dct.core.analytics import radar as compute_radar
6
+ from dct.web.app import current_engine
7
+
8
+ bp = Blueprint("home", __name__)
9
+
10
+
11
+ @bp.route("/")
12
+ def radar():
13
+ """Cross-project operational rollup: open/priority/in-progress/stale/handoffs."""
14
+ data = compute_radar(current_engine())
15
+ return render_template("home.html", radar=data)
@@ -0,0 +1,128 @@
1
+ """Items views — filterable list table + detail page (read-only).
2
+
3
+ GET /p/<slug>/items filter form + htmx-swappable table
4
+ GET /p/<slug>/items/<int:id> detail: markdown description, notes, checkpoints
5
+ """
6
+
7
+ from flask import Blueprint, abort, render_template, request
8
+
9
+ from dct.web.app import current_engine
10
+ from dct.core.items import list_items, get_item
11
+
12
+ bp = Blueprint("items", __name__)
13
+
14
+ # Checkpoint kinds that are NOT gates. Anything else is a gate (conservative
15
+ # default — mirrors dct.core.checkpoints gate semantics).
16
+ GATE_NONKINDS = {"task", "cc-task"}
17
+
18
+
19
+ def is_gate(kind: str | None) -> bool:
20
+ return (kind or "task") not in GATE_NONKINDS
21
+
22
+
23
+ # Filter values offered in the list form. Kept here (not in templates) so the
24
+ # allowed set is one source of truth.
25
+ TYPE_OPTIONS = ["issue", "todo", "feature", "idea", "improvement"]
26
+ PRIORITY_OPTIONS = ["P1", "P2", "P3", "future"]
27
+ STATUS_OPTIONS = ["open", "in_progress", "resolved", "wont_fix"]
28
+ SORT_OPTIONS = [
29
+ ("status", "status"),
30
+ ("priority", "priority"),
31
+ ("recent", "recently updated"),
32
+ ("created", "newest"),
33
+ ("title", "title"),
34
+ ]
35
+ _PRI_RANK = {"P1": 1, "P2": 2, "P3": 3, "future": 4}
36
+ # Active work first, terminal states sink to the bottom: in_progress, open,
37
+ # then wont_fix, resolved. Priority is the secondary key within each status.
38
+ _STATUS_RANK = {"in_progress": 0, "open": 1, "wont_fix": 2, "resolved": 3}
39
+
40
+
41
+ def _clean(value: str | None) -> str | None:
42
+ """Empty querystring value -> None so it is not passed as a filter."""
43
+ if value is None:
44
+ return None
45
+ value = value.strip()
46
+ return value or None
47
+
48
+
49
+ @bp.route("/p/<slug>/items")
50
+ def list(slug):
51
+ engine = current_engine()
52
+ filters = {
53
+ "item_type": _clean(request.args.get("type")),
54
+ "priority": _clean(request.args.get("priority")),
55
+ "status": _clean(request.args.get("status")),
56
+ "component": _clean(request.args.get("component")),
57
+ "search": _clean(request.args.get("q")),
58
+ }
59
+ rows = list_items(
60
+ engine,
61
+ project_slug=slug,
62
+ item_type=filters["item_type"],
63
+ priority=filters["priority"],
64
+ status=filters["status"],
65
+ component=filters["component"],
66
+ search=filters["search"],
67
+ limit=500,
68
+ )
69
+
70
+ # Optional tag filter — applied in Python because list_items has no tag
71
+ # parameter; tags are already parsed to list[str] in the returned dicts.
72
+ tag = _clean(request.args.get("tag"))
73
+ if tag:
74
+ rows = [r for r in rows if tag in (r.get("tags") or [])]
75
+
76
+ # Sorting — list_items returns priority-then-newest; re-sort in Python for
77
+ # the other modes (no extra query). "priority" keeps the core order.
78
+ sort = _clean(request.args.get("sort")) or "status"
79
+ if sort == "status":
80
+ rows.sort(key=lambda r: (_STATUS_RANK.get(r["status"], 9), _PRI_RANK.get(r["priority"], 9), -r["id"]))
81
+ elif sort == "recent":
82
+ rows.sort(key=lambda r: (r.get("updated_at") or r.get("created_at")), reverse=True)
83
+ elif sort == "created":
84
+ rows.sort(key=lambda r: r.get("created_at"), reverse=True)
85
+ elif sort == "title":
86
+ rows.sort(key=lambda r: (r.get("title") or "").lower())
87
+
88
+ ctx = {
89
+ "slug": slug,
90
+ "items": rows,
91
+ "filters": filters,
92
+ "tag": tag or "",
93
+ "sort": sort,
94
+ "type_options": TYPE_OPTIONS,
95
+ "priority_options": PRIORITY_OPTIONS,
96
+ "status_options": STATUS_OPTIONS,
97
+ "sort_options": SORT_OPTIONS,
98
+ }
99
+
100
+ # Return ONLY the table fragment when the filter form swaps it
101
+ # (hx-target="#items-table" → HX-Target: items-table). Other htmx requests
102
+ # — notably the SSE live-refresh which re-fetches #main (HX-Target: main) —
103
+ # must receive the full page so `select:"#main"` finds the chrome.
104
+ if request.headers.get("HX-Target") == "items-table":
105
+ return render_template("partials/_items_table.html", **ctx)
106
+ return render_template("items_list.html", **ctx)
107
+
108
+
109
+ @bp.route("/p/<slug>/items/<int:id>")
110
+ def detail(slug, id):
111
+ engine = current_engine()
112
+ item = get_item(engine, id)
113
+ if item is None:
114
+ abort(404)
115
+ # Prev/next within the project, in the default list order (priority, newest).
116
+ siblings = list_items(engine, project_slug=slug, limit=1000)
117
+ ids = [r["id"] for r in siblings]
118
+ prev_item = next_item = None
119
+ if id in ids:
120
+ i = ids.index(id)
121
+ if i > 0:
122
+ prev_item = siblings[i - 1]
123
+ if i < len(ids) - 1:
124
+ next_item = siblings[i + 1]
125
+ return render_template(
126
+ "item_detail.html", slug=slug, item=item, is_gate=is_gate,
127
+ prev_item=prev_item, next_item=next_item,
128
+ )
@@ -0,0 +1,53 @@
1
+ """Plan views — list + detail rendering the nested section tree recursively."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from flask import Blueprint, abort, g, render_template
6
+
7
+ from dct.web.app import current_engine
8
+ from dct.core.plans import crud as plans_core
9
+ from dct.core import projects as projects_core
10
+
11
+ bp = Blueprint("plans", __name__)
12
+
13
+
14
+ @bp.route("/p/<slug>/plans")
15
+ def list(slug): # noqa: A001 — endpoint name plans.list is part of the contract
16
+ engine = current_engine()
17
+ plans = plans_core.list_plans(engine, project_slug=slug)
18
+ return render_template("plans/list.html", slug=slug, plans=plans)
19
+
20
+
21
+ @bp.route("/plans/<int:plan_id>")
22
+ def detail(plan_id):
23
+ engine = current_engine()
24
+ plan = plans_core.get_plan(engine, plan_id)
25
+ if plan is None:
26
+ abort(404)
27
+ # /plans/<id> carries no slug; resolve the owning project so the sidebar +
28
+ # breadcrumb scope to it (projects core has no get-by-id → map from list).
29
+ slug = next(
30
+ (p["slug"] for p in projects_core.list_projects(engine)
31
+ if p["id"] == plan.get("project_id")),
32
+ None,
33
+ )
34
+ g.current_slug = slug
35
+ g.current_section = "plans"
36
+ # get_plan returns a FLAT `sections` list (each with parent_section_id +
37
+ # embedded `checkpoints`). Rebuild parent->children adjacency for the
38
+ # recursive template; sections are already sort_order-ordered.
39
+ children_map: dict[int, list] = {}
40
+ roots = []
41
+ for section in plan["sections"]:
42
+ parent_id = section.get("parent_section_id")
43
+ if parent_id is None:
44
+ roots.append(section)
45
+ else:
46
+ children_map.setdefault(parent_id, []).append(section)
47
+ return render_template(
48
+ "plans/detail.html",
49
+ plan=plan,
50
+ roots=roots,
51
+ children_map=children_map,
52
+ progress=plan["progress"],
53
+ )
@@ -0,0 +1,23 @@
1
+ """Project home view — ready-made sprint-review dashboard (GET /p/<slug>)."""
2
+
3
+ from flask import Blueprint, abort, render_template
4
+
5
+ from dct.core.projects import get_project
6
+ from dct.core.sprint import get_sprint_review
7
+ from dct.web.app import current_engine
8
+
9
+ bp = Blueprint("projects", __name__)
10
+
11
+
12
+ @bp.route("/p/<slug>")
13
+ def home(slug):
14
+ """Per-project dashboard backed by sprint.get_sprint_review.
15
+
16
+ A missing/soft-deleted slug yields 404 (distinct from an empty project).
17
+ """
18
+ engine = current_engine()
19
+ project = get_project(engine, slug)
20
+ if project is None:
21
+ abort(404)
22
+ review = get_sprint_review(engine, slug)
23
+ return render_template("project_home.html", project=project, review=review)