personal-backlog 0.3.3__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.
- personal_backlog/__init__.py +3 -0
- personal_backlog/__main__.py +5 -0
- personal_backlog/assistant.py +163 -0
- personal_backlog/assistant_assets/__init__.py +1 -0
- personal_backlog/assistant_assets/apple_intelligence.swift +84 -0
- personal_backlog/board.py +19 -0
- personal_backlog/board_web/__init__.py +1 -0
- personal_backlog/board_web/app.js +155 -0
- personal_backlog/board_web/index.html +102 -0
- personal_backlog/board_web/styles.css +326 -0
- personal_backlog/cli.py +417 -0
- personal_backlog/model.py +134 -0
- personal_backlog/ports.py +41 -0
- personal_backlog/server.py +271 -0
- personal_backlog/storage.py +745 -0
- personal_backlog/task_web/__init__.py +1 -0
- personal_backlog/task_web/task-detail.css +26 -0
- personal_backlog/task_web/task-detail.js +90 -0
- personal_backlog/toon.py +151 -0
- personal_backlog/web/__init__.py +1 -0
- personal_backlog/web/app.js +460 -0
- personal_backlog/web/index.html +211 -0
- personal_backlog/web/styles.css +600 -0
- personal_backlog-0.3.3.dist-info/METADATA +303 -0
- personal_backlog-0.3.3.dist-info/RECORD +29 -0
- personal_backlog-0.3.3.dist-info/WHEEL +5 -0
- personal_backlog-0.3.3.dist-info/entry_points.txt +2 -0
- personal_backlog-0.3.3.dist-info/licenses/LICENSE +21 -0
- personal_backlog-0.3.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextlib import ExitStack
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
import hashlib
|
|
6
|
+
from importlib.resources import as_file, files
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
from typing import Protocol
|
|
13
|
+
|
|
14
|
+
from personal_backlog.model import BacklogError, Task, ValidationError
|
|
15
|
+
from personal_backlog.storage import current_release, list_tasks, now_iso, release_sort_key
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AssistantError(BacklogError):
|
|
19
|
+
"""A local AI provider could not produce a safe task proposal."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class AssistantContext:
|
|
24
|
+
request: str
|
|
25
|
+
current_release: str | None
|
|
26
|
+
releases: tuple[str, ...]
|
|
27
|
+
existing_titles: tuple[str, ...]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class TaskProposal:
|
|
32
|
+
title: str
|
|
33
|
+
release: str | None
|
|
34
|
+
priority: int
|
|
35
|
+
size: str | None
|
|
36
|
+
body: str
|
|
37
|
+
|
|
38
|
+
def validate(self, context: AssistantContext) -> None:
|
|
39
|
+
if self.release is not None and self.release not in context.releases:
|
|
40
|
+
raise AssistantError(
|
|
41
|
+
f"provider proposed unknown release {self.release!r}; choose one of: "
|
|
42
|
+
f"{', '.join(context.releases) or 'none'}"
|
|
43
|
+
)
|
|
44
|
+
task = Task(
|
|
45
|
+
id="T0000",
|
|
46
|
+
title=self.title.strip(),
|
|
47
|
+
release=self.release,
|
|
48
|
+
priority=self.priority,
|
|
49
|
+
size=self.size,
|
|
50
|
+
state="todo",
|
|
51
|
+
created=now_iso(),
|
|
52
|
+
done=None,
|
|
53
|
+
body=self.body.strip("\n"),
|
|
54
|
+
)
|
|
55
|
+
try:
|
|
56
|
+
task.validate()
|
|
57
|
+
except ValidationError as exc:
|
|
58
|
+
raise AssistantError(f"provider returned an invalid task: {exc}") from exc
|
|
59
|
+
|
|
60
|
+
def as_dict(self) -> dict[str, object]:
|
|
61
|
+
return {
|
|
62
|
+
"title": self.title.strip(),
|
|
63
|
+
"release": self.release,
|
|
64
|
+
"priority": self.priority,
|
|
65
|
+
"size": self.size,
|
|
66
|
+
"state": "todo",
|
|
67
|
+
"body": self.body.strip("\n"),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class AssistantProvider(Protocol):
|
|
72
|
+
name: str
|
|
73
|
+
|
|
74
|
+
def propose(self, context: AssistantContext) -> TaskProposal:
|
|
75
|
+
"""Produce one proposal without writing to the backlog."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def context_for(backlog_dir: Path, request: str) -> AssistantContext:
|
|
79
|
+
tasks = list_tasks(backlog_dir)
|
|
80
|
+
releases = tuple(sorted({task.release for task in tasks if task.release}, key=release_sort_key))
|
|
81
|
+
return AssistantContext(
|
|
82
|
+
request=request,
|
|
83
|
+
current_release=current_release(backlog_dir),
|
|
84
|
+
releases=releases,
|
|
85
|
+
existing_titles=tuple(task.title for task in tasks),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class AppleIntelligenceProvider:
|
|
90
|
+
"""Foundation Models bridge for the on-device Apple Intelligence model."""
|
|
91
|
+
|
|
92
|
+
name = "apple-intelligence"
|
|
93
|
+
|
|
94
|
+
def _helper_path(self) -> Path:
|
|
95
|
+
source = files("personal_backlog.assistant_assets").joinpath("apple_intelligence.swift")
|
|
96
|
+
with ExitStack() as stack:
|
|
97
|
+
source_path = stack.enter_context(as_file(source))
|
|
98
|
+
digest = hashlib.sha256(source_path.read_bytes()).hexdigest()[:16]
|
|
99
|
+
helper = Path(tempfile.gettempdir()) / f"personal-backlog-apple-intelligence-{digest}"
|
|
100
|
+
if helper.is_file() and os.access(helper, os.X_OK):
|
|
101
|
+
return helper
|
|
102
|
+
temporary = helper.with_name(f".{helper.name}-{os.getpid()}")
|
|
103
|
+
result = subprocess.run(
|
|
104
|
+
["xcrun", "swiftc", "-parse-as-library", str(source_path), "-o", str(temporary)],
|
|
105
|
+
capture_output=True,
|
|
106
|
+
check=False,
|
|
107
|
+
text=True,
|
|
108
|
+
)
|
|
109
|
+
if result.returncode:
|
|
110
|
+
temporary.unlink(missing_ok=True)
|
|
111
|
+
message = result.stderr.strip() or result.stdout.strip() or "unknown compiler error"
|
|
112
|
+
raise AssistantError(f"could not prepare Apple Intelligence provider: {message}")
|
|
113
|
+
os.replace(temporary, helper)
|
|
114
|
+
helper.chmod(0o700)
|
|
115
|
+
return helper
|
|
116
|
+
|
|
117
|
+
def propose(self, context: AssistantContext) -> TaskProposal:
|
|
118
|
+
payload = {
|
|
119
|
+
"request": context.request,
|
|
120
|
+
"currentRelease": context.current_release,
|
|
121
|
+
"releases": list(context.releases),
|
|
122
|
+
"existingTitles": list(context.existing_titles),
|
|
123
|
+
}
|
|
124
|
+
try:
|
|
125
|
+
result = subprocess.run(
|
|
126
|
+
[str(self._helper_path())],
|
|
127
|
+
input=json.dumps(payload),
|
|
128
|
+
capture_output=True,
|
|
129
|
+
check=False,
|
|
130
|
+
text=True,
|
|
131
|
+
timeout=90,
|
|
132
|
+
)
|
|
133
|
+
except OSError as exc:
|
|
134
|
+
raise AssistantError(f"could not run Apple Intelligence provider: {exc}") from exc
|
|
135
|
+
except subprocess.TimeoutExpired as exc:
|
|
136
|
+
raise AssistantError("Apple Intelligence provider timed out") from exc
|
|
137
|
+
if result.returncode:
|
|
138
|
+
message = result.stderr.strip() or result.stdout.strip() or "unknown provider error"
|
|
139
|
+
raise AssistantError(message.removeprefix("Apple Intelligence provider error: ").strip())
|
|
140
|
+
try:
|
|
141
|
+
generated = json.loads(result.stdout)
|
|
142
|
+
release = generated["release"].strip()
|
|
143
|
+
proposal = TaskProposal(
|
|
144
|
+
title=generated["title"],
|
|
145
|
+
release=None if release.casefold() in {"none", "null", "-"} else release,
|
|
146
|
+
priority=generated["priority"],
|
|
147
|
+
size=generated["size"],
|
|
148
|
+
body=generated["body"],
|
|
149
|
+
)
|
|
150
|
+
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
|
151
|
+
raise AssistantError("Apple Intelligence provider returned an invalid structured response") from exc
|
|
152
|
+
proposal.validate(context)
|
|
153
|
+
return proposal
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def get_provider(name: str) -> AssistantProvider:
|
|
157
|
+
providers: dict[str, AssistantProvider] = {AppleIntelligenceProvider.name: AppleIntelligenceProvider()}
|
|
158
|
+
try:
|
|
159
|
+
return providers[name]
|
|
160
|
+
except KeyError as exc:
|
|
161
|
+
raise AssistantError(
|
|
162
|
+
f"unknown assistant provider {name!r}; available providers: {', '.join(sorted(providers))}"
|
|
163
|
+
) from exc
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Local AI provider helper assets."""
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import FoundationModels
|
|
3
|
+
|
|
4
|
+
struct AssistantInput: Decodable {
|
|
5
|
+
let request: String
|
|
6
|
+
let currentRelease: String?
|
|
7
|
+
let releases: [String]
|
|
8
|
+
let existingTitles: [String]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
@Generable
|
|
12
|
+
struct GeneratedTask {
|
|
13
|
+
@Guide(description: "A concise, imperative, single-line task title.")
|
|
14
|
+
var title: String
|
|
15
|
+
|
|
16
|
+
@Guide(description: "One of the available release strings, or the literal word none when unassigned.")
|
|
17
|
+
var release: String
|
|
18
|
+
|
|
19
|
+
@Guide(description: "A priority from 1 (highest) through 5 (parked).")
|
|
20
|
+
var priority: Int
|
|
21
|
+
|
|
22
|
+
@Guide(description: "Exactly one of S, M, or L. S is isolated work, M is related work in one layer, and L is cross-cutting work.")
|
|
23
|
+
var size: String
|
|
24
|
+
|
|
25
|
+
@Guide(description: "A useful Markdown task body with Context, Desired outcome, Decisions and constraints, and Acceptance criteria headings.")
|
|
26
|
+
var body: String
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
struct Output: Encodable {
|
|
30
|
+
let title: String
|
|
31
|
+
let release: String
|
|
32
|
+
let priority: Int
|
|
33
|
+
let size: String
|
|
34
|
+
let body: String
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
@main
|
|
38
|
+
struct BacklogAppleIntelligence {
|
|
39
|
+
static func main() async {
|
|
40
|
+
do {
|
|
41
|
+
let inputData = FileHandle.standardInput.readDataToEndOfFile()
|
|
42
|
+
let input = try JSONDecoder().decode(AssistantInput.self, from: inputData)
|
|
43
|
+
let model = SystemLanguageModel.default
|
|
44
|
+
guard case .available = model.availability else {
|
|
45
|
+
throw NSError(domain: "PersonalBacklog", code: 1, userInfo: [
|
|
46
|
+
NSLocalizedDescriptionKey: "Apple Intelligence is unavailable on this Mac. Enable it and wait for the on-device model to finish downloading."
|
|
47
|
+
])
|
|
48
|
+
}
|
|
49
|
+
let releaseList = input.releases.isEmpty ? "none" : input.releases.joined(separator: ", ")
|
|
50
|
+
let currentRelease = input.currentRelease ?? "none"
|
|
51
|
+
let knownTasks = input.existingTitles.prefix(60).joined(separator: " | ")
|
|
52
|
+
let instructions = """
|
|
53
|
+
You turn a person's natural-language request into exactly one Personal Backlog task.
|
|
54
|
+
Generate a proposal only; never claim that you created, changed, or completed a task.
|
|
55
|
+
Use priority 1 for top priority. Choose a release from the supplied available releases; use none only when the request does not relate to a release.
|
|
56
|
+
The body must be concise Markdown. Do not include secrets, credentials, or personal data.
|
|
57
|
+
"""
|
|
58
|
+
let prompt = """
|
|
59
|
+
Person's request:
|
|
60
|
+
\(input.request)
|
|
61
|
+
|
|
62
|
+
Current release: \(currentRelease)
|
|
63
|
+
Available releases: \(releaseList)
|
|
64
|
+
Existing task titles, for avoiding duplicates: \(knownTasks)
|
|
65
|
+
"""
|
|
66
|
+
let session = LanguageModelSession(model: model, instructions: instructions)
|
|
67
|
+
let response = try await session.respond(to: prompt, generating: GeneratedTask.self)
|
|
68
|
+
let proposal = response.content
|
|
69
|
+
let output = Output(
|
|
70
|
+
title: proposal.title,
|
|
71
|
+
release: proposal.release,
|
|
72
|
+
priority: proposal.priority,
|
|
73
|
+
size: proposal.size,
|
|
74
|
+
body: proposal.body
|
|
75
|
+
)
|
|
76
|
+
let encoder = JSONEncoder()
|
|
77
|
+
encoder.outputFormatting = [.sortedKeys]
|
|
78
|
+
print(String(data: try encoder.encode(output), encoding: .utf8)!)
|
|
79
|
+
} catch {
|
|
80
|
+
FileHandle.standardError.write(Data("Apple Intelligence provider error: \(error.localizedDescription)\n".utf8))
|
|
81
|
+
exit(2)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Compatibility adapter for the backlog board."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from http.server import ThreadingHTTPServer
|
|
7
|
+
|
|
8
|
+
from personal_backlog.server import create_server, serve
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def create_board_server(backlog_dir: Path, port: int = 8766) -> ThreadingHTTPServer:
|
|
12
|
+
"""Create a server in read-only mode for backward compatibility."""
|
|
13
|
+
return create_server(backlog_dir, port=port, read_only=True)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def serve_board(backlog_dir: Path, *, port: int = 8766, open_browser: bool = True) -> None:
|
|
17
|
+
"""Serve the backlog in read-only mode for backward compatibility."""
|
|
18
|
+
serve(backlog_dir, port=port, open_browser=open_browser, read_only=True)
|
|
19
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Static assets for the read-only task board."""
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
const states = [
|
|
2
|
+
{ key: "todo", cards: "todo-cards", count: "todo-count" },
|
|
3
|
+
{ key: "in_progress", cards: "in-progress-cards", count: "in-progress-count" },
|
|
4
|
+
{ key: "done", cards: "done-cards", count: "done-count" },
|
|
5
|
+
];
|
|
6
|
+
|
|
7
|
+
const elements = {
|
|
8
|
+
project: document.querySelector("#project-name"),
|
|
9
|
+
path: document.querySelector("#backlog-path"),
|
|
10
|
+
release: document.querySelector("#release-filter"),
|
|
11
|
+
refresh: document.querySelector("#refresh"),
|
|
12
|
+
summary: document.querySelector("#summary"),
|
|
13
|
+
version: document.querySelector("#version"),
|
|
14
|
+
template: document.querySelector("#task-card-template"),
|
|
15
|
+
dialog: document.querySelector("#task-dialog"),
|
|
16
|
+
detail: document.querySelector("#task-detail"),
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
let tasks = [];
|
|
20
|
+
let defaultRelease = null;
|
|
21
|
+
let defaultReleaseApplied = false;
|
|
22
|
+
|
|
23
|
+
async function api(path) {
|
|
24
|
+
const response = await fetch(path);
|
|
25
|
+
const payload = await response.json();
|
|
26
|
+
if (!response.ok) throw new Error(payload.error || `Request failed (${response.status})`);
|
|
27
|
+
return payload;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function releaseKey(task) {
|
|
31
|
+
return task.release ?? "__none__";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function releaseLabel(release) {
|
|
35
|
+
return release === "__none__" ? "Unassigned" : release;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function compareReleases(a, b) {
|
|
39
|
+
if (a === "__none__") return 1;
|
|
40
|
+
if (b === "__none__") return -1;
|
|
41
|
+
if (a === "next") return 1;
|
|
42
|
+
if (b === "next") return -1;
|
|
43
|
+
return a.localeCompare(b, undefined, { numeric: true });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function renderReleaseFilter() {
|
|
47
|
+
const selected = elements.release.value;
|
|
48
|
+
const releases = [...new Set(tasks.map(releaseKey))].sort(compareReleases);
|
|
49
|
+
elements.release.replaceChildren(new Option("All releases", ""));
|
|
50
|
+
releases.forEach((release) => {
|
|
51
|
+
elements.release.add(new Option(releaseLabel(release), release));
|
|
52
|
+
});
|
|
53
|
+
if (releases.includes(selected)) {
|
|
54
|
+
elements.release.value = selected;
|
|
55
|
+
} else if (!defaultReleaseApplied && defaultRelease && releases.includes(defaultRelease)) {
|
|
56
|
+
elements.release.value = defaultRelease;
|
|
57
|
+
}
|
|
58
|
+
defaultReleaseApplied = true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function taskCard(task) {
|
|
62
|
+
const card = elements.template.content.firstElementChild.cloneNode(true);
|
|
63
|
+
card.dataset.priority = task.priority;
|
|
64
|
+
card.tabIndex = 0;
|
|
65
|
+
card.setAttribute("role", "button");
|
|
66
|
+
card.setAttribute("aria-label", `Show details for ${task.title}`);
|
|
67
|
+
card.addEventListener("click", () => openTask(task.id));
|
|
68
|
+
card.addEventListener("keydown", (event) => {
|
|
69
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
70
|
+
event.preventDefault();
|
|
71
|
+
openTask(task.id);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
card.querySelector(".task-id").textContent = task.id;
|
|
75
|
+
card.querySelector(".priority").textContent = `P${task.priority}`;
|
|
76
|
+
card.querySelector("h3").textContent = task.title;
|
|
77
|
+
card.querySelector(".release").textContent = task.release || "Unassigned";
|
|
78
|
+
card.querySelector(".size").textContent = task.size || "—";
|
|
79
|
+
if (task.tags?.length) {
|
|
80
|
+
const tags = card.querySelector(".tags");
|
|
81
|
+
tags.hidden = false;
|
|
82
|
+
tags.querySelector("dd").textContent = task.tags.join(", ");
|
|
83
|
+
}
|
|
84
|
+
return card;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function openTask(id) {
|
|
88
|
+
try {
|
|
89
|
+
const task = await api(`/api/tasks/${encodeURIComponent(id)}`);
|
|
90
|
+
window.PersonalBacklogTaskDetail.render(elements.detail, task, {
|
|
91
|
+
onClose: () => elements.dialog.close(),
|
|
92
|
+
});
|
|
93
|
+
elements.dialog.showModal();
|
|
94
|
+
} catch (error) {
|
|
95
|
+
elements.summary.textContent = error.message;
|
|
96
|
+
elements.summary.classList.add("error");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function emptyLane() {
|
|
101
|
+
const empty = document.createElement("p");
|
|
102
|
+
empty.className = "empty-lane";
|
|
103
|
+
empty.textContent = "No tasks here.";
|
|
104
|
+
return empty;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function renderBoard() {
|
|
108
|
+
const selectedRelease = elements.release.value;
|
|
109
|
+
const visible = tasks.filter((task) => !selectedRelease || releaseKey(task) === selectedRelease);
|
|
110
|
+
|
|
111
|
+
states.forEach((state) => {
|
|
112
|
+
const laneTasks = visible.filter((task) => task.state === state.key);
|
|
113
|
+
const container = document.querySelector(`#${state.cards}`);
|
|
114
|
+
document.querySelector(`#${state.count}`).textContent = laneTasks.length;
|
|
115
|
+
container.replaceChildren(...(laneTasks.length ? laneTasks.map(taskCard) : [emptyLane()]));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const releaseText = selectedRelease ? ` in ${releaseLabel(selectedRelease)}` : "";
|
|
119
|
+
elements.summary.textContent = `${visible.length} task${visible.length === 1 ? "" : "s"}${releaseText} · 3 states`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function refresh() {
|
|
123
|
+
elements.refresh.disabled = true;
|
|
124
|
+
elements.refresh.textContent = "Refreshing…";
|
|
125
|
+
try {
|
|
126
|
+
tasks = await api("/api/tasks");
|
|
127
|
+
renderReleaseFilter();
|
|
128
|
+
renderBoard();
|
|
129
|
+
} catch (error) {
|
|
130
|
+
elements.summary.textContent = error.message;
|
|
131
|
+
elements.summary.classList.add("error");
|
|
132
|
+
} finally {
|
|
133
|
+
elements.refresh.disabled = false;
|
|
134
|
+
elements.refresh.textContent = "Refresh";
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
elements.release.addEventListener("change", renderBoard);
|
|
139
|
+
elements.refresh.addEventListener("click", refresh);
|
|
140
|
+
|
|
141
|
+
Promise.all([api("/api/meta"), refresh()])
|
|
142
|
+
.then(([meta]) => {
|
|
143
|
+
defaultRelease = meta.current_release || null;
|
|
144
|
+
defaultReleaseApplied = false;
|
|
145
|
+
renderReleaseFilter();
|
|
146
|
+
renderBoard();
|
|
147
|
+
elements.project.textContent = meta.project || "Project";
|
|
148
|
+
elements.path.textContent = meta.backlog;
|
|
149
|
+
elements.version.textContent = `· v${meta.version}`;
|
|
150
|
+
document.title = `${meta.project || "Backlog"} board`;
|
|
151
|
+
})
|
|
152
|
+
.catch((error) => {
|
|
153
|
+
elements.summary.textContent = error.message;
|
|
154
|
+
elements.summary.classList.add("error");
|
|
155
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta name="description" content="A read-only three-column view of a Personal Backlog.">
|
|
7
|
+
<title>Backlog board</title>
|
|
8
|
+
<link rel="stylesheet" href="/styles.css">
|
|
9
|
+
<link rel="stylesheet" href="/task-detail.css">
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<header class="masthead">
|
|
13
|
+
<div class="identity">
|
|
14
|
+
<p class="eyebrow">PERSONAL BACKLOG</p>
|
|
15
|
+
<h1><span id="project-name">Project</span> board</h1>
|
|
16
|
+
<p id="backlog-path" class="path">Loading backlog…</p>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<div class="controls" aria-label="Board controls">
|
|
20
|
+
<label for="release-filter">Release</label>
|
|
21
|
+
<div class="control-row">
|
|
22
|
+
<select id="release-filter">
|
|
23
|
+
<option value="">All releases</option>
|
|
24
|
+
</select>
|
|
25
|
+
<button id="refresh" type="button">Refresh</button>
|
|
26
|
+
</div>
|
|
27
|
+
<p id="summary" class="summary" aria-live="polite">Loading tasks…</p>
|
|
28
|
+
</div>
|
|
29
|
+
</header>
|
|
30
|
+
|
|
31
|
+
<main class="board" aria-label="Task board">
|
|
32
|
+
<section class="lane lane-todo" aria-labelledby="todo-heading">
|
|
33
|
+
<header class="lane-heading">
|
|
34
|
+
<div>
|
|
35
|
+
<p class="lane-kicker"><span class="status-dot"></span>Queue</p>
|
|
36
|
+
<h2 id="todo-heading">Todo</h2>
|
|
37
|
+
</div>
|
|
38
|
+
<span id="todo-count" class="lane-count">0</span>
|
|
39
|
+
</header>
|
|
40
|
+
<div id="todo-cards" class="cards"></div>
|
|
41
|
+
</section>
|
|
42
|
+
|
|
43
|
+
<section class="lane lane-in-progress" aria-labelledby="in-progress-heading">
|
|
44
|
+
<header class="lane-heading">
|
|
45
|
+
<div>
|
|
46
|
+
<p class="lane-kicker"><span class="status-dot"></span>Active</p>
|
|
47
|
+
<h2 id="in-progress-heading">In progress</h2>
|
|
48
|
+
</div>
|
|
49
|
+
<span id="in-progress-count" class="lane-count">0</span>
|
|
50
|
+
</header>
|
|
51
|
+
<div id="in-progress-cards" class="cards"></div>
|
|
52
|
+
</section>
|
|
53
|
+
|
|
54
|
+
<section class="lane lane-done" aria-labelledby="done-heading">
|
|
55
|
+
<header class="lane-heading">
|
|
56
|
+
<div>
|
|
57
|
+
<p class="lane-kicker"><span class="status-dot"></span>Complete</p>
|
|
58
|
+
<h2 id="done-heading">Done</h2>
|
|
59
|
+
</div>
|
|
60
|
+
<span id="done-count" class="lane-count">0</span>
|
|
61
|
+
</header>
|
|
62
|
+
<div id="done-cards" class="cards"></div>
|
|
63
|
+
</section>
|
|
64
|
+
</main>
|
|
65
|
+
|
|
66
|
+
<footer>
|
|
67
|
+
<p><span class="read-only-mark" aria-hidden="true"></span>Read only</p>
|
|
68
|
+
<p>Markdown remains the source of truth <span id="version"></span></p>
|
|
69
|
+
</footer>
|
|
70
|
+
|
|
71
|
+
<template id="task-card-template">
|
|
72
|
+
<article class="task-card">
|
|
73
|
+
<div class="card-topline">
|
|
74
|
+
<span class="task-id"></span>
|
|
75
|
+
<span class="priority"></span>
|
|
76
|
+
</div>
|
|
77
|
+
<h3></h3>
|
|
78
|
+
<dl class="task-meta">
|
|
79
|
+
<div>
|
|
80
|
+
<dt>Release</dt>
|
|
81
|
+
<dd class="release"></dd>
|
|
82
|
+
</div>
|
|
83
|
+
<div>
|
|
84
|
+
<dt>Size</dt>
|
|
85
|
+
<dd class="size"></dd>
|
|
86
|
+
</div>
|
|
87
|
+
<div class="tags" hidden>
|
|
88
|
+
<dt>Tags</dt>
|
|
89
|
+
<dd></dd>
|
|
90
|
+
</div>
|
|
91
|
+
</dl>
|
|
92
|
+
</article>
|
|
93
|
+
</template>
|
|
94
|
+
|
|
95
|
+
<dialog id="task-dialog" class="task-dialog" aria-label="Task details">
|
|
96
|
+
<div id="task-detail"></div>
|
|
97
|
+
</dialog>
|
|
98
|
+
|
|
99
|
+
<script src="/task-detail.js" defer></script>
|
|
100
|
+
<script src="/app.js" defer></script>
|
|
101
|
+
</body>
|
|
102
|
+
</html>
|