@polderlabs/openkan 0.4.0
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.
- package/CHANGELOG.md +226 -0
- package/LICENSE +21 -0
- package/README.md +318 -0
- package/agents/openkan.md +254 -0
- package/bin/install-agent.mjs +63 -0
- package/bin/ok.mjs +17 -0
- package/bin/openkan.mjs +10 -0
- package/dist/.claude/skills/ok-planning/SKILL.md +285 -0
- package/dist/.claude/skills/ok-planning/references/integration.md +153 -0
- package/dist/.claude/skills/ok-planning/references/schemas.md +270 -0
- package/dist/.claude/skills/ok-planning/references/workflows.md +185 -0
- package/dist/.claude/skills/ok-planning/scripts/ok-init.sh +14 -0
- package/dist/.claude/skills/ok-planning/scripts/ok-resume.sh +38 -0
- package/dist/.claude/skills/ok-planning/scripts/ok-status.sh +24 -0
- package/dist/agents/openkan.md +254 -0
- package/dist/bin/install-agent.mjs +76 -0
- package/dist/bin/ok-install.js +58 -0
- package/dist/bin/ok.js +138 -0
- package/dist/bin/openkan.js +804 -0
- package/dist/commands/organize.md +15 -0
- package/dist/kanban/agent-profile.js +8 -0
- package/dist/kanban/archive.js +49 -0
- package/dist/kanban/bizar.js +242 -0
- package/dist/kanban/board.js +367 -0
- package/dist/kanban/bulk.js +139 -0
- package/dist/kanban/changelog.js +186 -0
- package/dist/kanban/chat.js +1280 -0
- package/dist/kanban/claude-state.js +974 -0
- package/dist/kanban/comments.js +80 -0
- package/dist/kanban/docs.js +144 -0
- package/dist/kanban/fs.js +163 -0
- package/dist/kanban/git.js +196 -0
- package/dist/kanban/images.js +140 -0
- package/dist/kanban/import.js +295 -0
- package/dist/kanban/inputs.js +94 -0
- package/dist/kanban/insights.js +140 -0
- package/dist/kanban/io.js +75 -0
- package/dist/kanban/mdx-render.js +348 -0
- package/dist/kanban/mdx.js +231 -0
- package/dist/kanban/projects.js +545 -0
- package/dist/kanban/search.js +121 -0
- package/dist/kanban/server.js +3296 -0
- package/dist/kanban/tags.js +124 -0
- package/dist/kanban/template.js +145 -0
- package/dist/kanban/tsx-sandbox.js +187 -0
- package/dist/kanban/watcher.js +270 -0
- package/dist/ok/commands/goal.js +65 -0
- package/dist/ok/commands/index.js +87 -0
- package/dist/ok/commands/init.js +15 -0
- package/dist/ok/commands/plan.js +155 -0
- package/dist/ok/commands/prd.js +202 -0
- package/dist/ok/commands/progress.js +31 -0
- package/dist/ok/commands/task.js +377 -0
- package/dist/ok/ids.js +98 -0
- package/dist/ok/lock.js +156 -0
- package/dist/ok/migrate.js +197 -0
- package/dist/ok/schemas.js +402 -0
- package/dist/ok/storage.js +222 -0
- package/dist/skills/openkan/SKILL.md +111 -0
- package/dist/skills/openkan/agents/openai.yaml +4 -0
- package/dist/skills/openkan/examples/simple-task.mdx +34 -0
- package/dist/skills/openkan/examples/with-ask.mdx +32 -0
- package/dist/skills/openkan/examples/with-choice.mdx +51 -0
- package/dist/skills/openkan/examples/with-preview.mdx +54 -0
- package/dist/skills/openkan/references/api.md +169 -0
- package/dist/skills/openkan/templates/task.mdx +46 -0
- package/dist/web/api.js +257 -0
- package/dist/web/app.js +4251 -0
- package/dist/web/bizar.js +39 -0
- package/dist/web/brand/agent-activity-sprite.svg +1 -0
- package/dist/web/brand/banner-docs.svg +24 -0
- package/dist/web/brand/banner.svg +32 -0
- package/dist/web/brand/empty-sessions.svg +17 -0
- package/dist/web/brand/empty-tasks.svg +17 -0
- package/dist/web/brand/favicon.svg +9 -0
- package/dist/web/brand/infinity-loader-animated.svg +220 -0
- package/dist/web/brand/infinity-loader-spritesheet.svg +230 -0
- package/dist/web/brand/logo-wordmark.svg +10 -0
- package/dist/web/brand/logo.svg +9 -0
- package/dist/web/brand/pixel-infinity-track.svg +1 -0
- package/dist/web/brand/social-card.svg +26 -0
- package/dist/web/changelog-view.js +456 -0
- package/dist/web/charts.js +269 -0
- package/dist/web/chat-sidebar.js +2397 -0
- package/dist/web/chat-status-motion.js +154 -0
- package/dist/web/claude-pane.js +820 -0
- package/dist/web/command-palette.js +381 -0
- package/dist/web/contributors-view.js +317 -0
- package/dist/web/cross-tab.js +102 -0
- package/dist/web/docs-view.js +168 -0
- package/dist/web/experience.css +165 -0
- package/dist/web/goals-view.js +45 -0
- package/dist/web/home-view.js +113 -0
- package/dist/web/images.js +311 -0
- package/dist/web/index.html +485 -0
- package/dist/web/insights.js +217 -0
- package/dist/web/keyboard.js +446 -0
- package/dist/web/mdx-viewer.js +600 -0
- package/dist/web/path-picker.js +787 -0
- package/dist/web/preview-frame.html +187 -0
- package/dist/web/settings.js +582 -0
- package/dist/web/style.css +8545 -0
- package/dist/web/task-view.js +1759 -0
- package/dist/web/vendor/gsap.min.js +11 -0
- package/dist/web/workspace.css +1513 -0
- package/package.json +71 -0
- package/skills/openkan/SKILL.md +111 -0
- package/skills/openkan/agents/openai.yaml +4 -0
- package/skills/openkan/examples/simple-task.mdx +34 -0
- package/skills/openkan/examples/with-ask.mdx +32 -0
- package/skills/openkan/examples/with-choice.mdx +51 -0
- package/skills/openkan/examples/with-preview.mdx +54 -0
- package/skills/openkan/references/api.md +169 -0
- package/skills/openkan/templates/task.mdx +46 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
// OpenKan — Changelog tab (M10).
|
|
2
|
+
// window.OpenKanChangelog = { mount(rootEl), unmount() }
|
|
3
|
+
//
|
|
4
|
+
// Pulls events from /api/changelog and renders a vertical timeline, newest
|
|
5
|
+
// first. Filter by kind prefix, by date range. Infinite scroll loads 50 at
|
|
6
|
+
// a time. Clicking a taskId opens the task in the Tasks tab.
|
|
7
|
+
|
|
8
|
+
(() => {
|
|
9
|
+
"use strict";
|
|
10
|
+
|
|
11
|
+
const { api } = window.OpenKanAPI;
|
|
12
|
+
|
|
13
|
+
const PAGE_SIZE = 50;
|
|
14
|
+
const KIND_FILTERS = [
|
|
15
|
+
{ id: "all", label: "all" },
|
|
16
|
+
{ id: "task", label: "task.*" },
|
|
17
|
+
{ id: "agent", label: "agent.*" },
|
|
18
|
+
{ id: "settings", label: "settings.*" },
|
|
19
|
+
{ id: "git", label: "git.*" },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
// State per-mount
|
|
23
|
+
let state = null;
|
|
24
|
+
|
|
25
|
+
function el(tag, cls, props = {}) {
|
|
26
|
+
const e = document.createElement(tag);
|
|
27
|
+
if (cls) e.className = cls;
|
|
28
|
+
for (const [k, v] of Object.entries(props)) {
|
|
29
|
+
if (k === "text") e.textContent = v;
|
|
30
|
+
else if (k === "html") e.innerHTML = v;
|
|
31
|
+
else if (k.startsWith("on")) e.addEventListener(k.slice(2), v);
|
|
32
|
+
else e.setAttribute(k, v);
|
|
33
|
+
}
|
|
34
|
+
return e;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function relativeTime(iso) {
|
|
38
|
+
if (!iso) return "";
|
|
39
|
+
const t = new Date(iso).getTime();
|
|
40
|
+
if (isNaN(t)) return "";
|
|
41
|
+
const delta = Date.now() - t;
|
|
42
|
+
const sec = Math.floor(delta / 1000);
|
|
43
|
+
if (sec < 5) return "just now";
|
|
44
|
+
if (sec < 60) return `${sec}s ago`;
|
|
45
|
+
const min = Math.floor(sec / 60);
|
|
46
|
+
if (min < 60) return `${min} minute${min === 1 ? "" : "s"} ago`;
|
|
47
|
+
const hr = Math.floor(min / 60);
|
|
48
|
+
if (hr < 24) return `${hr} hour${hr === 1 ? "" : "s"} ago`;
|
|
49
|
+
const day = Math.floor(hr / 24);
|
|
50
|
+
if (day < 30) return `${day} day${day === 1 ? "" : "s"} ago`;
|
|
51
|
+
return new Date(iso).toLocaleDateString();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function kindToBucket(kind) {
|
|
55
|
+
if (!kind) return "other";
|
|
56
|
+
const k = String(kind).toLowerCase();
|
|
57
|
+
if (k.startsWith("task")) return "task";
|
|
58
|
+
if (k.startsWith("agent")) return "agent";
|
|
59
|
+
if (k.startsWith("settings") || k === "kanban.organized") return "settings";
|
|
60
|
+
if (k.startsWith("git")) return "git";
|
|
61
|
+
return "other";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The server only supports a single `kind=` filter, not prefix-based
|
|
65
|
+
// groups. For prefix filters (task.*, agent.*, ...) we fetch all events
|
|
66
|
+
// and filter client-side. For "all" we still ask the server for a
|
|
67
|
+
// paginated window.
|
|
68
|
+
function passesKindFilter(eventKind, filterKind) {
|
|
69
|
+
if (!filterKind || filterKind === "all") return true;
|
|
70
|
+
return kindToBucket(eventKind) === filterKind;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function initialsFor(name) {
|
|
74
|
+
if (!name) return "?";
|
|
75
|
+
const parts = String(name).trim().split(/[\s@]+/).filter(Boolean);
|
|
76
|
+
if (parts.length === 0) return "?";
|
|
77
|
+
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
|
78
|
+
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Deterministic color from a string. Same approach as in task-view.js.
|
|
82
|
+
function colorFor(seed) {
|
|
83
|
+
let h = 0;
|
|
84
|
+
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) | 0;
|
|
85
|
+
const hue = Math.abs(h) % 360;
|
|
86
|
+
return `hsl(${hue} 60% 45%)`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function buildSummary(events) {
|
|
90
|
+
const byKind = {};
|
|
91
|
+
for (const e of events) {
|
|
92
|
+
const bucket = kindToBucket(e.kind);
|
|
93
|
+
byKind[bucket] = (byKind[bucket] || 0) + 1;
|
|
94
|
+
}
|
|
95
|
+
const total = events.length;
|
|
96
|
+
const ordered = ["task", "agent", "settings", "git", "other"]
|
|
97
|
+
.map((k) => ({ k, n: byKind[k] || 0 }))
|
|
98
|
+
.filter((x) => x.n > 0)
|
|
99
|
+
.sort((a, b) => b.n - a.n);
|
|
100
|
+
const max = ordered.reduce((m, x) => Math.max(m, x.n), 0) || 1;
|
|
101
|
+
|
|
102
|
+
const wrap = el("div", "changelog-summary");
|
|
103
|
+
wrap.append(el("div", "changelog-summary-title", { text: `Last 30 days — ${total} event${total === 1 ? "" : "s"}` }));
|
|
104
|
+
|
|
105
|
+
const bars = el("div", "changelog-summary-bars");
|
|
106
|
+
if (total === 0) {
|
|
107
|
+
bars.append(el("div", "changelog-empty", { text: "No events recorded yet." }));
|
|
108
|
+
} else {
|
|
109
|
+
for (const { k, n } of ordered) {
|
|
110
|
+
const row = el("div", "changelog-summary-row");
|
|
111
|
+
row.append(
|
|
112
|
+
el("span", "bar-label", { text: k }),
|
|
113
|
+
(() => {
|
|
114
|
+
const track = el("div", "bar-track");
|
|
115
|
+
const fill = el("div", "bar-fill");
|
|
116
|
+
fill.style.width = `${(n / max) * 100}%`;
|
|
117
|
+
track.append(fill);
|
|
118
|
+
return track;
|
|
119
|
+
})(),
|
|
120
|
+
el("span", "bar-count", { text: String(n) }),
|
|
121
|
+
);
|
|
122
|
+
bars.append(row);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
wrap.append(bars);
|
|
126
|
+
return wrap;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function buildViewHero() {
|
|
130
|
+
const hero = el("header", "view-hero");
|
|
131
|
+
const copy = el("div");
|
|
132
|
+
copy.append(
|
|
133
|
+
el("span", "view-eyebrow", { text: "Project activity" }),
|
|
134
|
+
el("h2", null, { text: "Changelog" }),
|
|
135
|
+
el("p", null, { text: "Trace task, agent, settings, and source-control events over time." }),
|
|
136
|
+
);
|
|
137
|
+
hero.append(copy);
|
|
138
|
+
return hero;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function buildFilterRow() {
|
|
142
|
+
const row = el("div", "changelog-filter-row");
|
|
143
|
+
|
|
144
|
+
row.append(el("span", "filter-bar-label", { text: "kind" }));
|
|
145
|
+
for (const k of KIND_FILTERS) {
|
|
146
|
+
const btn = el("button", null, { type: "button", text: k.label });
|
|
147
|
+
if (state.filter.kind === k.id) btn.classList.add("active");
|
|
148
|
+
btn.addEventListener("click", () => {
|
|
149
|
+
if (state.filter.kind === k.id) return;
|
|
150
|
+
state.filter.kind = k.id;
|
|
151
|
+
// Re-mount: clear data and reload.
|
|
152
|
+
state.events = [];
|
|
153
|
+
state.offset = 0;
|
|
154
|
+
state.hasMore = true;
|
|
155
|
+
rebuildTimeline();
|
|
156
|
+
loadMore({ reset: true });
|
|
157
|
+
});
|
|
158
|
+
row.append(btn);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// "Completed only" toggle — server-side filter (?completedOnly=true).
|
|
162
|
+
// When active, only `task.completed`-type events come back; if the
|
|
163
|
+
// server doesn't recognize the flag we fall back to client-side
|
|
164
|
+
// filtering in loadMore().
|
|
165
|
+
const completedBtn = el("button", "completed-toggle", {
|
|
166
|
+
type: "button",
|
|
167
|
+
"aria-pressed": state.filter.completedOnly ? "true" : "false",
|
|
168
|
+
});
|
|
169
|
+
completedBtn.append(
|
|
170
|
+
el("span", "dot"),
|
|
171
|
+
document.createTextNode("Completed only"),
|
|
172
|
+
);
|
|
173
|
+
completedBtn.title = "Show only task events with a completion status";
|
|
174
|
+
completedBtn.addEventListener("click", () => {
|
|
175
|
+
state.filter.completedOnly = !state.filter.completedOnly;
|
|
176
|
+
completedBtn.setAttribute("aria-pressed", state.filter.completedOnly ? "true" : "false");
|
|
177
|
+
resetAndReload();
|
|
178
|
+
});
|
|
179
|
+
row.append(completedBtn);
|
|
180
|
+
|
|
181
|
+
// Date range
|
|
182
|
+
const wrap = el("span", "date-range");
|
|
183
|
+
const from = el("input", null, { type: "date", "aria-label": "From date" });
|
|
184
|
+
from.value = state.filter.from || "";
|
|
185
|
+
from.addEventListener("change", () => {
|
|
186
|
+
state.filter.from = from.value || "";
|
|
187
|
+
resetAndReload();
|
|
188
|
+
});
|
|
189
|
+
const to = el("input", null, { type: "date", "aria-label": "To date" });
|
|
190
|
+
to.value = state.filter.to || "";
|
|
191
|
+
to.addEventListener("change", () => {
|
|
192
|
+
state.filter.to = to.value || "";
|
|
193
|
+
resetAndReload();
|
|
194
|
+
});
|
|
195
|
+
wrap.append(" from ", from, " to ", to);
|
|
196
|
+
row.append(wrap);
|
|
197
|
+
|
|
198
|
+
const clearBtn = el("button", null, { type: "button", text: "Clear dates" });
|
|
199
|
+
clearBtn.addEventListener("click", () => {
|
|
200
|
+
state.filter.from = "";
|
|
201
|
+
state.filter.to = "";
|
|
202
|
+
from.value = "";
|
|
203
|
+
to.value = "";
|
|
204
|
+
resetAndReload();
|
|
205
|
+
});
|
|
206
|
+
row.append(clearBtn);
|
|
207
|
+
|
|
208
|
+
return row;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Module-scope helper: detach and forget the current "Loading…" placeholder
|
|
212
|
+
// so the next render starts clean. Both `loadMore()` and `resetAndReload()`
|
|
213
|
+
// call this before appending a new one, and `loadMore()` also calls it on
|
|
214
|
+
// success so the spinner disappears once we have events to show.
|
|
215
|
+
function removeLoading() {
|
|
216
|
+
if (state.loadingMsg && state.loadingMsg.parentNode) {
|
|
217
|
+
state.loadingMsg.parentNode.removeChild(state.loadingMsg);
|
|
218
|
+
}
|
|
219
|
+
state.loadingMsg = null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function resetAndReload() {
|
|
223
|
+
state.events = [];
|
|
224
|
+
state.offset = 0;
|
|
225
|
+
state.hasMore = true;
|
|
226
|
+
// CRITICAL: reset the timeline's count so the next render starts at 0.
|
|
227
|
+
// Without this, `state.timeline.dataset.count` still holds the previous
|
|
228
|
+
// render's count (e.g. 50) and the render loop at the bottom of
|
|
229
|
+
// `loadMore()` does `state.events.slice(rendered)` with a `rendered` of
|
|
230
|
+
// 50 against a freshly-empty `state.events` — yielding zero entries.
|
|
231
|
+
if (state.timeline) state.timeline.dataset.count = "0";
|
|
232
|
+
// Drop any in-flight "Loading…" element (on either rootEl or timeline)
|
|
233
|
+
// before we add a fresh one, otherwise we accumulate stale placeholders.
|
|
234
|
+
removeLoading();
|
|
235
|
+
state.timeline.innerHTML = "";
|
|
236
|
+
state.loadingMsg = el("div", "changelog-empty", { text: "Loading…" });
|
|
237
|
+
state.timeline.append(state.loadingMsg);
|
|
238
|
+
loadMore({ reset: true });
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function loadMore({ reset = false } = {}) {
|
|
242
|
+
if (!state || !state.hasMore || state.loading) return;
|
|
243
|
+
state.loading = true;
|
|
244
|
+
|
|
245
|
+
try {
|
|
246
|
+
const params = new URLSearchParams();
|
|
247
|
+
params.set("limit", String(PAGE_SIZE));
|
|
248
|
+
params.set("offset", String(state.offset));
|
|
249
|
+
if (state.filter.from) params.set("since", `${state.filter.from}T00:00:00`);
|
|
250
|
+
if (state.filter.to) params.set("until", `${state.filter.to}T23:59:59`);
|
|
251
|
+
if (state.filter.completedOnly) params.set("completedOnly", "true");
|
|
252
|
+
|
|
253
|
+
const data = await api("GET", `/api/changelog?${params.toString()}`);
|
|
254
|
+
const events = Array.isArray(data?.events) ? data.events : Array.isArray(data) ? data : [];
|
|
255
|
+
const total = typeof data?.total === "number" ? data.total : events.length;
|
|
256
|
+
|
|
257
|
+
// Apply client-side kind filter when filtering by prefix group.
|
|
258
|
+
const filteredEvents = passesKindFilter(null, "all") || state.filter.kind === "all"
|
|
259
|
+
? events
|
|
260
|
+
: events.filter((e) => passesKindFilter(e.kind, state.filter.kind));
|
|
261
|
+
|
|
262
|
+
if (reset) {
|
|
263
|
+
state.events = filteredEvents;
|
|
264
|
+
removeLoading();
|
|
265
|
+
state.timeline.innerHTML = "";
|
|
266
|
+
} else {
|
|
267
|
+
state.events.push(...filteredEvents);
|
|
268
|
+
// Drop the bottom-of-timeline "Loading more…" placeholder once we
|
|
269
|
+
// actually have new entries to show.
|
|
270
|
+
removeLoading();
|
|
271
|
+
}
|
|
272
|
+
state.offset += events.length;
|
|
273
|
+
state.hasMore = state.offset < total && events.length > 0;
|
|
274
|
+
|
|
275
|
+
// Append rendered entries
|
|
276
|
+
const rendered = Number(state.timeline.dataset.count || 0);
|
|
277
|
+
for (const e of state.events.slice(rendered)) {
|
|
278
|
+
state.timeline.append(renderEntry(e));
|
|
279
|
+
}
|
|
280
|
+
state.timeline.dataset.count = String(state.events.length);
|
|
281
|
+
|
|
282
|
+
// Toggle load-more button
|
|
283
|
+
if (state.loadMoreBtn) state.loadMoreBtn.remove();
|
|
284
|
+
if (state.hasMore) {
|
|
285
|
+
const btn = el("button", "changelog-load-more", { type: "button", text: "Load more" });
|
|
286
|
+
btn.addEventListener("click", () => loadMore());
|
|
287
|
+
state.loadMoreBtn = btn;
|
|
288
|
+
state.rootEl.append(btn);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Empty state
|
|
292
|
+
if (state.events.length === 0 && !state.hasMore) {
|
|
293
|
+
if (!state.timeline.querySelector(".changelog-empty")) {
|
|
294
|
+
state.timeline.append(el("div", "changelog-empty", { text: "No events match the current filters." }));
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
} catch (err) {
|
|
298
|
+
// Server may not implement the endpoint yet (M10 still landing).
|
|
299
|
+
// Show a friendly empty state so the tab still works.
|
|
300
|
+
if (state.timeline) {
|
|
301
|
+
state.timeline.innerHTML = "";
|
|
302
|
+
state.timeline.append(el("div", "changelog-empty", {
|
|
303
|
+
text: `Changelog unavailable: ${err.message}`,
|
|
304
|
+
}));
|
|
305
|
+
}
|
|
306
|
+
state.hasMore = false;
|
|
307
|
+
} finally {
|
|
308
|
+
state.loading = false;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function renderEntry(e) {
|
|
313
|
+
const entry = el("article", "changelog-entry");
|
|
314
|
+
|
|
315
|
+
const head = el("div", "changelog-entry-head");
|
|
316
|
+
head.append(el("span", "changelog-time", {
|
|
317
|
+
text: relativeTime(e.ts),
|
|
318
|
+
title: e.ts,
|
|
319
|
+
}));
|
|
320
|
+
head.append(el("span", `changelog-kind-chip kind-${kindToBucket(e.kind)}`, {
|
|
321
|
+
text: e.kind || "?",
|
|
322
|
+
title: e.kind,
|
|
323
|
+
}));
|
|
324
|
+
|
|
325
|
+
if (e.author) {
|
|
326
|
+
const author = el("span", "changelog-author");
|
|
327
|
+
const av = el("span", "avatar-circle avatar-sm");
|
|
328
|
+
const seed = String(e.author);
|
|
329
|
+
av.style.setProperty("--avatar-bg", colorFor(seed));
|
|
330
|
+
av.textContent = initialsFor(seed);
|
|
331
|
+
author.append(av, e.author);
|
|
332
|
+
head.append(author);
|
|
333
|
+
}
|
|
334
|
+
entry.append(head);
|
|
335
|
+
|
|
336
|
+
entry.append(el("div", "changelog-summary-line", { text: e.summary || "" }));
|
|
337
|
+
|
|
338
|
+
if (e.payload && Object.keys(e.payload).length > 0) {
|
|
339
|
+
const toggle = el("button", "changelog-details-toggle", { type: "button", text: "▸ payload" });
|
|
340
|
+
const pre = el("pre", "changelog-payload", { text: JSON.stringify(e.payload, null, 2) });
|
|
341
|
+
pre.hidden = true;
|
|
342
|
+
toggle.addEventListener("click", () => {
|
|
343
|
+
pre.hidden = !pre.hidden;
|
|
344
|
+
toggle.textContent = pre.hidden ? "▸ payload" : "▾ payload";
|
|
345
|
+
});
|
|
346
|
+
entry.append(toggle, pre);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (e.taskId) {
|
|
350
|
+
const open = el("button", "changelog-open-task", { type: "button", text: "open task →" });
|
|
351
|
+
open.addEventListener("click", () => {
|
|
352
|
+
// Switch to Tasks tab, then open the task view.
|
|
353
|
+
if (window.OpenKanTabs) window.OpenKanTabs.activate("tasks");
|
|
354
|
+
// Defer slightly so the task view is visible before we mount it.
|
|
355
|
+
setTimeout(() => window.OpenKanTaskView?.open(e.taskId), 30);
|
|
356
|
+
});
|
|
357
|
+
entry.append(open);
|
|
358
|
+
}
|
|
359
|
+
return entry;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function loadSummary() {
|
|
363
|
+
if (!state) return;
|
|
364
|
+
try {
|
|
365
|
+
const data = await api("GET", "/api/changelog/summary?days=30");
|
|
366
|
+
const byKind = data?.byKind || {};
|
|
367
|
+
// Convert byKind map (real kind names) into our 5 buckets for the bars.
|
|
368
|
+
const buckets = {};
|
|
369
|
+
for (const [k, v] of Object.entries(byKind)) {
|
|
370
|
+
const b = kindToBucket(k);
|
|
371
|
+
buckets[b] = (buckets[b] || 0) + v;
|
|
372
|
+
}
|
|
373
|
+
const total = Object.values(byKind).reduce((a, b) => a + b, 0);
|
|
374
|
+
const fake = [];
|
|
375
|
+
const ordered = ["task", "agent", "settings", "git", "other"]
|
|
376
|
+
.map((k) => ({ k, n: buckets[k] || 0 }))
|
|
377
|
+
.filter((x) => x.n > 0)
|
|
378
|
+
.sort((a, b) => b.n - a.n);
|
|
379
|
+
const max = ordered.reduce((m, x) => Math.max(m, x.n), 0) || 1;
|
|
380
|
+
const wrap = el("div", "changelog-summary");
|
|
381
|
+
wrap.append(el("div", "changelog-summary-title", { text: `Last 30 days — ${total} event${total === 1 ? "" : "s"}` }));
|
|
382
|
+
if (total === 0) {
|
|
383
|
+
wrap.append(el("div", "changelog-empty", { text: "No events recorded yet." }));
|
|
384
|
+
} else {
|
|
385
|
+
const bars = el("div", "changelog-summary-bars");
|
|
386
|
+
for (const { k, n } of ordered) {
|
|
387
|
+
const row = el("div", "changelog-summary-row");
|
|
388
|
+
row.append(
|
|
389
|
+
el("span", "bar-label", { text: k }),
|
|
390
|
+
(() => {
|
|
391
|
+
const track = el("div", "bar-track");
|
|
392
|
+
const fill = el("div", "bar-fill");
|
|
393
|
+
fill.style.width = `${(n / max) * 100}%`;
|
|
394
|
+
track.append(fill);
|
|
395
|
+
return track;
|
|
396
|
+
})(),
|
|
397
|
+
el("span", "bar-count", { text: String(n) }),
|
|
398
|
+
);
|
|
399
|
+
bars.append(row);
|
|
400
|
+
}
|
|
401
|
+
wrap.append(bars);
|
|
402
|
+
}
|
|
403
|
+
state.summaryEl.replaceWith(wrap);
|
|
404
|
+
state.summaryEl = wrap;
|
|
405
|
+
} catch {
|
|
406
|
+
// Fallback: try to derive from the events we already loaded.
|
|
407
|
+
const wrap = buildSummary(state.events);
|
|
408
|
+
state.summaryEl.replaceWith(wrap);
|
|
409
|
+
state.summaryEl = wrap;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function rebuildTimeline() {
|
|
414
|
+
state.rootEl.innerHTML = "";
|
|
415
|
+
state.rootEl.append(buildViewHero());
|
|
416
|
+
state.summaryEl = el("div", "changelog-summary");
|
|
417
|
+
state.rootEl.append(state.summaryEl);
|
|
418
|
+
state.rootEl.append(buildFilterRow());
|
|
419
|
+
state.timeline = el("div", "changelog-timeline");
|
|
420
|
+
state.rootEl.append(state.timeline);
|
|
421
|
+
// Track the loading message in module-scope state so a successful
|
|
422
|
+
// `loadMore()` (or the next `resetAndReload()`) can find and remove it.
|
|
423
|
+
// Previously this element was orphaned at the bottom of rootEl because
|
|
424
|
+
// `loadMore()` only cleaned up the timeline-scoped loadingMsg.
|
|
425
|
+
if (!state.loadingMsg || !state.loadingMsg.parentNode) {
|
|
426
|
+
state.loadingMsg = el("div", "changelog-empty", { text: "Loading…" });
|
|
427
|
+
}
|
|
428
|
+
state.rootEl.append(state.loadingMsg);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function mount(rootEl) {
|
|
432
|
+
if (!rootEl) throw new Error("changelog-view: rootEl is required");
|
|
433
|
+
unmount();
|
|
434
|
+
state = {
|
|
435
|
+
rootEl,
|
|
436
|
+
events: [],
|
|
437
|
+
offset: 0,
|
|
438
|
+
hasMore: true,
|
|
439
|
+
loading: false,
|
|
440
|
+
filter: { kind: "all", from: "", to: "", completedOnly: false },
|
|
441
|
+
timeline: null,
|
|
442
|
+
summaryEl: null,
|
|
443
|
+
loadMoreBtn: null,
|
|
444
|
+
loadingMsg: null,
|
|
445
|
+
};
|
|
446
|
+
rebuildTimeline();
|
|
447
|
+
loadSummary();
|
|
448
|
+
loadMore({ reset: true });
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function unmount() {
|
|
452
|
+
state = null;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
window.OpenKanChangelog = { mount, unmount };
|
|
456
|
+
})();
|