@mgeri1993/claude-task-manager 1.0.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.
@@ -0,0 +1,205 @@
1
+ import { Utils, COLUMNS, COLOR, ICONS } from "./Utils.js";
2
+ import { I18n } from "./i18n.js";
3
+
4
+ /**
5
+ * Renders the board: stats bar, agent chips (load bar), and the Kanban/Swimlane columns
6
+ * with cards. Writes into the #board, #stats, #agents elements.
7
+ */
8
+ export class BoardView {
9
+ constructor(dom) {
10
+ this.dom = dom; // { board, stats, agents, agentsCount }
11
+ this.teamIndex = new Map(); // teamNo -> task (from the CURRENTLY loaded full task list, before filtering)
12
+ }
13
+
14
+ visibleTasks(allTasks, { q, agentFilter, quickFilter, moduleFilter }) {
15
+ const qq = (q || "").trim().toLowerCase();
16
+ const passQuick = t => !quickFilter || (quickFilter === "active" ? t.status !== "done" : t.status === quickFilter);
17
+ return allTasks.filter(t => !t.isArchived).filter(passQuick).filter(t => {
18
+ if (agentFilter && !agentFilter.has(Utils.agentKey(t))) return false;
19
+ if (moduleFilter && (t.module || "") !== moduleFilter) return false;
20
+ if (!qq) return true;
21
+ return [t.title, t.description, t.id, t.assignedAgentId, t.module, ...Utils.norm(t.notes)].join(" ").toLowerCase().includes(qq);
22
+ });
23
+ }
24
+
25
+ /** Activity feed: every visible task's note+history entries, in reverse chronological order. */
26
+ feedHTML(tasks) {
27
+ const items = Utils.activityFeed(tasks);
28
+ if (!items.length) return `<div class="empty">— ${Utils.esc(I18n.t("board.noActivity"))} —</div>`;
29
+ return items.slice(0, 300).map(it => {
30
+ const when = `<span class="fi-when js-rel" data-ts="${Utils.esc(it.at)}" title="${Utils.esc(Utils.absTime(it.at))}">${Utils.esc(Utils.relTime(it.at))}</span>`;
31
+ let head;
32
+ if (it.kindType === "history") {
33
+ const from = it.from ? `<span class="pill${Utils.pillClass(it.from)}" style="background:${COLOR[it.from] || "var(--muted)"}">${Utils.esc(it.from)}</span>` : "";
34
+ const to = it.to ? `<span class="pill${Utils.pillClass(it.to)}" style="background:${COLOR[it.to] || "var(--muted)"}">${Utils.esc(it.to)}</span>` : "";
35
+ head = `<span class="fi-type">${Utils.esc(it.type || "event")}</span> ${from}${(from && to) ? ` ${ICONS.arrow} ` : ""}${to}`;
36
+ } else {
37
+ head = it.kind ? `<span class="n-kind ${it.kind.cls}">${Utils.esc(it.kind.label)}</span>` : `<span class="n-kind k-other">note</span>`;
38
+ }
39
+ return `<div class="feed-item" data-id="${Utils.esc(it.taskId)}">
40
+ <div class="fi-top">${head}<span class="fi-task">${Utils.esc(it.taskTitle)}</span>${when}</div>
41
+ ${it.text ? `<div class="fi-text">${Utils.esc(it.text)}</div>` : ""}
42
+ </div>`;
43
+ }).join("");
44
+ }
45
+
46
+ sorter(sort) {
47
+ const dt = x => new Date(x || 0);
48
+ if (sort === "created") return (a, b) => dt(b.createdAt) - dt(a.createdAt);
49
+ if (sort === "title") return (a, b) => (a.title || "").localeCompare(b.title || "");
50
+ if (sort === "team") return (a, b) => (Utils.parseTeam(a) ?? 999) - (Utils.parseTeam(b) ?? 999);
51
+ return (a, b) => dt(b.lastActivityAt || b.updatedAt) - dt(a.lastActivityAt || a.updatedAt);
52
+ }
53
+
54
+ card(t, changeInfo) {
55
+ const ci = changeInfo.get(t.id);
56
+ const team = Utils.parseTeam(t);
57
+ const deps = Utils.parseDeps(t);
58
+ const chBadges = ci ? [
59
+ ci.isNew ? `<span class="badge ch">${Utils.esc(I18n.t("badge.new"))}</span>` : "",
60
+ ci.status ? `<span class="badge ch">⇄ ${Utils.esc(ci.status)}</span>` : "",
61
+ ci.notes ? `<span class="badge ch">${Utils.esc(I18n.t("badge.notes"))}</span>` : "",
62
+ ci.history ? `<span class="badge ch">${Utils.esc(I18n.t("badge.history"))}</span>` : "",
63
+ (ci.updated && !ci.status && !ci.notes && !ci.history) ? `<span class="badge ch">${Utils.esc(I18n.t("badge.updated"))}</span>` : "",
64
+ ].join("") : "";
65
+ const depBadges = deps.blockedBy.map(n => {
66
+ const blk = this.teamIndex.get(n);
67
+ const cls = deps.active ? "dep-active" : "dep-done";
68
+ return `<span class="badge ${cls}" data-team="${n}" title="${blk ? Utils.esc(blk.title) : "?"}">${deps.active ? "⛔" : "✅"} #${n}</span>`;
69
+ }).join("");
70
+ // #1 "Awaiting you" age badge on a review-status card (escalating emphasis).
71
+ let waitBadge = "";
72
+ if (t.status === "review") {
73
+ const since = Utils.statusSince(t);
74
+ const lvl = Utils.waitLevel(Utils.ageMs(since));
75
+ waitBadge = `<span class="badge await await-${lvl} js-wait" data-ts="${Utils.esc(since || "")}" title="${Utils.esc(I18n.t("board.waitingSince"))}">⏳ ${Utils.esc(Utils.relTime(since))}</span>`;
76
+ }
77
+ // #4 Latest-note phase badge (RESEARCH/PLAN/DECISION/IMPLEMENTATION), if recognized.
78
+ let kindBadge = "";
79
+ const lastNote = Utils.normDetailed(t.notes).slice(-1)[0];
80
+ const lk = lastNote ? Utils.noteKind(lastNote.text) : null;
81
+ if (lk) kindBadge = `<span class="kind-badge ${lk.cls}" title="latest note's phase">${Utils.esc(lk.label)}</span>`;
82
+ const moduleColor = t.module ? Utils.agentColor(t.module) : null;
83
+ const moduleBadge = t.module ? `<span class="badge module" style="border-color:${Utils.hexA(moduleColor, .4)};color:${moduleColor}">${Utils.esc(t.module)}</span>` : "";
84
+ const badges = (team ? `<span class="badge team">Team #${team}</span>` : "") + moduleBadge + waitBadge + kindBadge + chBadges + depBadges;
85
+ const upd = t.lastActivityAt || t.updatedAt;
86
+ const who = t.assignedAgentId || null;
87
+ const whoColor = Utils.agentColor(who);
88
+ const whoAvatar = `<span class="who-av" style="background:${Utils.hexA(whoColor, .16)};color:${whoColor}">${Utils.esc(Utils.agentShort(who))}</span>`;
89
+
90
+ // Done tasks: per the mockup, a slim row with a checkmark avatar — no description/badges, just the essentials.
91
+ if (t.status === "done") {
92
+ return `<div class="card card-done${ci ? " changed" : ""}" data-id="${Utils.esc(t.id)}">
93
+ <span class="done-check">${ICONS.check}</span>
94
+ <div class="card-done-main">
95
+ <div class="card-done-title">${badges ? `<span class="badges-inline">${badges}</span>` : ""}<span class="title-txt">${Utils.esc(t.title || t.id || "(untitled)")}</span></div>
96
+ <div class="card-done-meta">${whoAvatar}<span class="who-name">${Utils.esc(who || I18n.t("board.noOwner"))}</span>${upd ? `<span class="dot-sep"></span><span class="js-rel" data-ts="${Utils.esc(upd)}" title="${Utils.esc(Utils.absTime(upd))}">${Utils.esc(Utils.relTime(upd))}</span>` : ""}</div>
97
+ </div>
98
+ </div>`;
99
+ }
100
+
101
+ return `<div class="card${ci ? " changed" : ""}" data-id="${Utils.esc(t.id)}" style="border-left-color:${COLOR[t.status] || "var(--border)"}">
102
+ ${badges ? `<div class="badges">${badges}</div>` : ""}
103
+ <h3>${Utils.esc(t.title || t.id || "(untitled)")}</h3>
104
+ ${t.description ? `<p class="desc">${Utils.esc(t.description)}</p>` : ""}
105
+ <div class="meta">
106
+ ${who ? `<span class="tag agent">${whoAvatar}${Utils.esc(who)}</span>` : ""}
107
+ ${upd ? `<span class="tag" title="${Utils.esc(Utils.absTime(upd))}">${ICONS.clock} <span class="js-rel" data-ts="${Utils.esc(upd)}">${Utils.esc(Utils.relTime(upd))}</span></span>` : ""}
108
+ </div>
109
+ ${(t.status === "blocked" && Utils.norm(t.notes).length) ? `<div class="note">${ICONS.block} ${Utils.esc(Utils.norm(t.notes).slice(-1)[0])}</div>` : ""}
110
+ </div>`;
111
+ }
112
+
113
+ columnsHTML(tasks, { sort, collapsedCols, changeInfo }) {
114
+ const byStatus = {}; COLUMNS.forEach(c => byStatus[c.key] = []); const unknown = [];
115
+ tasks.forEach(t => (byStatus[t.status] || unknown).push(t));
116
+ const sf = this.sorter(sort);
117
+ let html = COLUMNS.map(c => {
118
+ const items = (byStatus[c.key] || []).sort(sf);
119
+ const col = collapsedCols.has(c.key);
120
+ return `<section class="col${col ? " collapsed" : ""}">
121
+ <div class="col-head" data-col="${c.key}">
122
+ <span class="swatch" style="background:${c.color}"></span>
123
+ <span class="col-title">${c.label}</span>
124
+ <span class="count">${items.length}</span>
125
+ </div>
126
+ <div class="col-body">${items.length ? items.map(t => this.card(t, changeInfo)).join("") : `<div class="empty">— ${Utils.esc(I18n.t("board.empty"))} —</div>`}</div>
127
+ </section>`;
128
+ }).join("");
129
+ if (unknown.length) html += `<section class="col"><div class="col-head"><span class="swatch" style="background:var(--border)"></span><span class="col-title">${Utils.esc(I18n.t("board.unknown"))}</span><span class="count">${unknown.length}</span></div><div class="col-body">${unknown.map(t => this.card(t, changeInfo)).join("")}</div></section>`;
130
+ return html;
131
+ }
132
+
133
+ /**
134
+ * @param allTasks - the full (unfiltered) task list from the last poll
135
+ * @param state - { q, agentFilter, moduleFilter, sort, view, compact, collapsedCols, changeInfo }
136
+ */
137
+ render(allTasks, state) {
138
+ const { sort, view, compact, collapsedCols, changeInfo } = state;
139
+ const tasks = this.visibleTasks(allTasks, state);
140
+
141
+ this.teamIndex = new Map();
142
+ allTasks.forEach(t => { const n = Utils.parseTeam(t); if (n != null) this.teamIndex.set(n, t); });
143
+
144
+ // Stats + cycle time
145
+ const counts = Object.fromEntries(COLUMNS.map(c => [c.key, 0]));
146
+ tasks.forEach(t => { if (counts[t.status] != null) counts[t.status]++; });
147
+ const total = tasks.length, done = counts.done || 0, pct = total ? Math.round(done / total * 100) : 0;
148
+ const cyc = tasks.map(Utils.cycle);
149
+ const leads = cyc.filter(c => c.leadMs != null).map(c => c.leadMs);
150
+ const ips = cyc.filter(c => c.inProgressMs > 0).map(c => c.inProgressMs);
151
+ const avg = a => a.length ? a.reduce((x, y) => x + y, 0) / a.length : null;
152
+ // Distribution bar: every status as its own colored segment, proportionally (the mockup's
153
+ // "dist" bar) instead of a plain "% done" bar — the whole status distribution is visible
154
+ // at a glance.
155
+ const distSegs = COLUMNS.filter(c => counts[c.key] > 0);
156
+ const distHTML = distSegs.map((c, i) => {
157
+ const isFirst = i === 0, isLast = i === distSegs.length - 1;
158
+ const r = isFirst && isLast ? "4px" : isFirst ? "4px 0 0 4px" : isLast ? "0 4px 4px 0" : "0";
159
+ return `<i style="flex:${counts[c.key]} 1 0;background:${c.color};border-radius:${r}"></i>`;
160
+ }).join("");
161
+ this.dom.stats.innerHTML =
162
+ `<span class="stat">${I18n.t("board.tasks", { n: `<b>${total}</b>` })}</span>` +
163
+ COLUMNS.map(c => `<span class="stat"><span class="swatch" style="background:${c.color}"></span>${c.label}: <b>${counts[c.key]}</b></span>`).join("") +
164
+ `<span class="sep"></span>` +
165
+ `<span class="stat">${ICONS.clock} ${Utils.esc(I18n.t("board.leadAvg"))}: <b>${Utils.dur(avg(leads))}</b></span>` +
166
+ `<span class="stat">${ICONS.activity} ${Utils.esc(I18n.t("board.inProgressAvg"))}: <b>${Utils.dur(avg(ips))}</b></span>` +
167
+ `<div class="progress" title="${done}/${total}">${distHTML}</div><span class="progress-label">${Utils.esc(I18n.t("board.percentDone", { p: pct }))}</span>`;
168
+
169
+ // Agent chips + load bar (stable, per-agent color + avatar abbreviation, per the mockup)
170
+ const totalBy = {}, activeBy = {};
171
+ allTasks.filter(t => !t.isArchived).forEach(t => { const a = Utils.agentKey(t); totalBy[a] = (totalBy[a] || 0) + 1; if (t.status !== "done") activeBy[a] = (activeBy[a] || 0) + 1; });
172
+ const agents = Object.keys(totalBy).sort();
173
+ const maxA = Math.max(1, ...agents.map(a => activeBy[a] || 0));
174
+ if (this.dom.agentsCount) this.dom.agentsCount.textContent = I18n.t("agents.active", { n: agents.filter(a => activeBy[a] > 0).length });
175
+ this.dom.agents.innerHTML = agents.map(a => {
176
+ const on = !state.agentFilter || state.agentFilter.has(a); const act = activeBy[a] || 0;
177
+ const c = Utils.agentColor(a);
178
+ const borderA = on ? (act ? .4 : .15) : .07;
179
+ return `<button class="agent-chip${on ? " on" : ""}" data-agent="${Utils.esc(a)}" style="border-color:${Utils.hexA(c, borderA)}">
180
+ <span class="ac-avatar" style="background:${Utils.hexA(c, .16)};color:${c}">${Utils.esc(Utils.agentShort(a))}</span>
181
+ <span class="ac-info">
182
+ <span class="ac-top">${Utils.esc(a)}<span class="ac-count">${act}/${totalBy[a]}</span></span>
183
+ <span class="ac-bar"><i style="width:${Math.round(act / maxA * 100)}%;background:${c}"></i></span>
184
+ </span>
185
+ </button>`;
186
+ }).join("") + (state.agentFilter ? `<button class="agent-chip" data-agent="__all__"><span class="ac-avatar">•</span><span class="ac-info"><span class="ac-top">${Utils.esc(I18n.t("agents.all"))}</span></span></button>` : "");
187
+
188
+ // Board / Swimlane / Feed
189
+ if (view === "feed") {
190
+ this.dom.board.className = "board feed";
191
+ this.dom.board.innerHTML = this.feedHTML(tasks);
192
+ } else if (view === "swim") {
193
+ this.dom.board.className = "board swim" + (compact ? " compact" : "");
194
+ const laneAgents = [...new Set(tasks.map(Utils.agentKey))].sort();
195
+ this.dom.board.innerHTML = laneAgents.map(a => {
196
+ const lt = tasks.filter(t => Utils.agentKey(t) === a);
197
+ const act = lt.filter(t => t.status !== "done").length;
198
+ return `<div class="lane"><div class="lane-head">${ICONS.user} ${Utils.esc(a)} <span class="mut">· ${Utils.esc(I18n.t("board.tasks", { n: lt.length }))} · ${act} ${Utils.esc(I18n.t("ctrl.quick.active")).toLowerCase()}</span></div><div class="lane-cols">${this.columnsHTML(lt, state)}</div></div>`;
199
+ }).join("") || `<div class="empty">— ${Utils.esc(I18n.t("board.noTasks"))} —</div>`;
200
+ } else {
201
+ this.dom.board.className = "board" + (compact ? " compact" : "");
202
+ this.dom.board.innerHTML = this.columnsHTML(tasks, state);
203
+ }
204
+ }
205
+ }
@@ -0,0 +1,47 @@
1
+ import { Utils } from "./Utils.js";
2
+ import { I18n } from "./i18n.js";
3
+
4
+ /**
5
+ * User context panel ("📌 Context"): renders context.json's contents (goal, focus, init
6
+ * prompt, constraints, decision timeline, open questions, notes), and badges the opening
7
+ * button with the decision count.
8
+ */
9
+ export class ContextPanel {
10
+ constructor(dom) {
11
+ this.dom = dom; // { ctxBtn, ctxOverlay, ctxClose, ctxBody, ctxUpdated }
12
+ }
13
+
14
+ renderButton(context) {
15
+ const n = context && Array.isArray(context.decisions) ? context.decisions.length : 0;
16
+ this.dom.ctxBtn.classList.toggle("has", n > 0);
17
+ if (n > 0) this.dom.ctxBtn.dataset.n = n; else delete this.dom.ctxBtn.dataset.n;
18
+ }
19
+
20
+ renderBody(context) {
21
+ const c = context;
22
+ if (!c) {
23
+ this.dom.ctxUpdated.textContent = "";
24
+ this.dom.ctxBody.innerHTML = `<p class="mut">${I18n.t("ctx.none")}</p>`;
25
+ return;
26
+ }
27
+ this.dom.ctxUpdated.textContent = c.updatedAt ? I18n.t("ctx.updated", { t: Utils.relTime(c.updatedAt) }) : "";
28
+ const decisions = (Array.isArray(c.decisions) ? c.decisions : []).slice().sort((a, b) => new Date(b.at || 0) - new Date(a.at || 0));
29
+ const list = (arr, empty) => (Array.isArray(arr) && arr.length) ? `<ul class="ctx-list">${arr.map(x => `<li>${Utils.esc(x)}</li>`).join("")}</ul>` : `<p class="mut">${empty}</p>`;
30
+ this.dom.ctxBody.innerHTML =
31
+ (c.goal ? `<h4>${Utils.esc(I18n.t("ctx.goal"))}</h4><p>${Utils.esc(c.goal)}</p>` : "") +
32
+ (c.currentFocus ? `<h4>${Utils.esc(I18n.t("ctx.currentFocus"))}</h4><p>${Utils.esc(c.currentFocus)}</p>` : "") +
33
+ `<h4>${Utils.esc(I18n.t("ctx.initPrompt"))}</h4>` + (c.initPrompt ? `<div class="ctx-quote">${Utils.esc(c.initPrompt)}</div>` : `<p class="mut">—</p>`) +
34
+ `<h4>${Utils.esc(I18n.t("ctx.constraints"))}</h4>` + list(c.constraints, I18n.t("ctx.noConstraints")) +
35
+ `<h4>${Utils.esc(I18n.t("ctx.decisions", { n: decisions.length }))}</h4>` +
36
+ (decisions.length ? decisions.map(d => `<div class="decision">
37
+ <div class="d-top"><span class="d-topic">${Utils.esc(d.topic || "decision")}</span><span class="d-when" title="${Utils.esc(Utils.absTime(d.at))}">${Utils.esc(Utils.relTime(d.at))}</span></div>
38
+ <div>${Utils.esc(d.decision || "")}</div>
39
+ ${d.rationale ? `<div class="d-rat">↳ ${Utils.esc(d.rationale)}</div>` : ""}
40
+ </div>`).join("") : `<p class="mut">${Utils.esc(I18n.t("ctx.noDecisions"))}</p>`) +
41
+ `<h4>${Utils.esc(I18n.t("ctx.openQuestions"))}</h4>` + list(c.openQuestions, I18n.t("ctx.noOpenQuestions")) +
42
+ (c.notes ? `<h4>${Utils.esc(I18n.t("ctx.notes"))}</h4><p>${Utils.esc(c.notes)}</p>` : "");
43
+ }
44
+
45
+ show() { this.dom.ctxOverlay.classList.add("show"); }
46
+ hide() { this.dom.ctxOverlay.classList.remove("show"); }
47
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Polls context.json (conditional GET, Last-Modified based) — the source of the user
3
+ * session context (goal, focus, decisions, open questions).
4
+ * context.json lives alongside tasks.json (derived by ctxUrl()).
5
+ */
6
+ export class ContextStore {
7
+ constructor(getSrcUrl) {
8
+ this.getSrcUrl = getSrcUrl; // () => string – the tasks.json source URL (input#src value)
9
+ this.lastModified = null;
10
+ this.context = null;
11
+ }
12
+
13
+ reset() {
14
+ this.lastModified = null;
15
+ this.context = null;
16
+ }
17
+
18
+ ctxUrl() {
19
+ const s = this.getSrcUrl().trim();
20
+ if (/tasks\.json(\?.*)?$/.test(s)) return s.replace(/tasks\.json(\?.*)?$/, "context.json");
21
+ return s.replace(/[^\/?#]*([?#].*)?$/, "context.json");
22
+ }
23
+
24
+ /** The context is optional – silently swallows errors/404. Returns { changed }. */
25
+ async poll() {
26
+ try {
27
+ const headers = {}; if (this.lastModified) headers["If-Modified-Since"] = this.lastModified;
28
+ const res = await fetch(this.ctxUrl(), { cache: "no-store", headers });
29
+ if (res.status === 304) return { changed: false };
30
+ if (res.status === 404) { this.context = null; return { changed: true }; }
31
+ if (!res.ok) return { changed: false };
32
+ this.lastModified = res.headers.get("Last-Modified") || this.lastModified;
33
+ this.context = JSON.parse(await res.text());
34
+ return { changed: true };
35
+ } catch (e) {
36
+ return { changed: false };
37
+ }
38
+ }
39
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * ProjectStore — a one-shot load and cache of data/projects.json (registered projects).
3
+ * Registration itself happens on the host, via `engine/projects.sh add`; the board only
4
+ * DISPLAYS the already-registered projects (Source selector + wrapper copying).
5
+ */
6
+ export class ProjectStore {
7
+ constructor() {
8
+ this.projects = [];
9
+ }
10
+
11
+ /** Loads data/projects.json. Returns an empty list on error/absence (the board reports this). */
12
+ async load() {
13
+ try {
14
+ const res = await fetch("data/projects.json", { cache: "no-store" });
15
+ if (!res.ok) throw new Error("HTTP " + res.status);
16
+ const data = await res.json();
17
+ this.projects = Array.isArray(data) ? data : [];
18
+ } catch (e) {
19
+ this.projects = [];
20
+ }
21
+ return this.projects;
22
+ }
23
+
24
+ get(id) {
25
+ return this.projects.find(p => p.id === id) || null;
26
+ }
27
+ }
@@ -0,0 +1,112 @@
1
+ import { Utils, COLOR } from "./Utils.js";
2
+ import { I18n } from "./i18n.js";
3
+
4
+ /**
5
+ * Task detail modal: description, cycle-time metrics, dependencies (both directions),
6
+ * data (kv list), notes, and the history timeline.
7
+ */
8
+ export class TaskModal {
9
+ constructor(dom) {
10
+ this.dom = dom; // { overlay, mTitle, mBody, mClose }
11
+ }
12
+
13
+ /**
14
+ * Interactive action bar: primary actions matching the task's status (on review:
15
+ * Approved / Changes needed / Block), quick status buttons, priority/module/assignment,
16
+ * and a feedback field. Markup only carries data attributes — clicks are handled by App,
17
+ * and task.sh writes the JSON via api/index.php (the browser never writes directly).
18
+ */
19
+ actionsHTML(t, opts) {
20
+ if (!opts || !opts.writeEnabled) return "";
21
+ const s = t.status;
22
+ const btn = (act, label, cls = "") => `<button type="button" class="act-btn ${cls}" data-act="${act}">${label}</button>`;
23
+ let primary = "";
24
+ if (s === "review") {
25
+ primary =
26
+ btn("approve", I18n.t("modal.approve"), "act-approve") +
27
+ btn("changes", I18n.t("modal.changes"), "act-changes") +
28
+ btn("block", I18n.t("modal.block"), "act-block");
29
+ } else if (s === "done") {
30
+ primary = btn("reopen", I18n.t("modal.reopen"), "act-reopen");
31
+ } else {
32
+ primary =
33
+ (s !== "in_progress" ? btn("start", I18n.t("modal.start")) : "") +
34
+ btn("review", I18n.t("modal.toReview")) +
35
+ btn("done", I18n.t("modal.done"), "act-approve") +
36
+ btn("block", I18n.t("modal.block"), "act-block");
37
+ }
38
+ const prios = ["low", "normal", "high", "urgent"];
39
+ const cur = t.priority || "normal";
40
+ const agents = (opts.agents || []).filter(a => a && a !== "—");
41
+ const agentOpts = agents.map(a => `<option value="${Utils.esc(a)}"></option>`).join("");
42
+ const modules = (opts.modules || []).filter(Boolean);
43
+ const moduleOpts = modules.map(m => `<option value="${Utils.esc(m)}"></option>`).join("");
44
+ return `<div class="actions" data-id="${Utils.esc(t.id)}">
45
+ <div class="act-row act-primary">${primary}</div>
46
+ <textarea class="act-note" id="actNote" rows="2" placeholder="${Utils.esc(I18n.t("modal.notePlaceholder"))}"></textarea>
47
+ <div class="act-row act-fields">
48
+ <button type="button" class="act-btn act-note-add" data-act="note">${Utils.esc(I18n.t("modal.addNote"))}</button>
49
+ <label class="act-field">${Utils.esc(I18n.t("modal.priority"))}
50
+ <select class="act-input" data-field="priority">${prios.map(p => `<option value="${p}"${p === cur ? " selected" : ""}>${p}</option>`).join("")}</select>
51
+ </label>
52
+ <label class="act-field">${Utils.esc(I18n.t("modal.module"))}
53
+ <input class="act-input" data-field="module" list="modalModuleList" value="${Utils.esc(t.module || "")}" placeholder="${Utils.esc(I18n.t("modal.modulePlaceholder"))}" spellcheck="false" autocomplete="off">
54
+ <datalist id="modalModuleList">${moduleOpts}</datalist>
55
+ </label>
56
+ <label class="act-field">${Utils.esc(I18n.t("modal.agent"))}
57
+ <input class="act-input" data-field="assign" list="modalAgentList" value="${Utils.esc(t.assignedAgentId || "")}" placeholder="${Utils.esc(I18n.t("modal.agentPlaceholder"))}" spellcheck="false" autocomplete="off">
58
+ <datalist id="modalAgentList">${agentOpts}</datalist>
59
+ </label>
60
+ </div>
61
+ </div>`;
62
+ }
63
+
64
+ /**
65
+ * @param t - the task to display
66
+ * @param teamIndex - Map<teamNo, task> (BoardView.teamIndex) for dep links
67
+ * @param allTasks - the full task list (to look up the "blocks this" relation)
68
+ * @param opts - { writeEnabled, agents, modules } : for the interactive action bar
69
+ */
70
+ render(t, teamIndex, allTasks, opts = {}) {
71
+ this.dom.mTitle.innerHTML = `${Utils.esc(t.title || t.id)} <span class="pill${Utils.pillClass(t.status)}" style="background:${COLOR[t.status] || "var(--muted)"}">${Utils.esc(t.status)}</span>`;
72
+ const notes = Utils.normDetailed(t.notes), c = Utils.cycle(t), team = Utils.parseTeam(t), deps = Utils.parseDeps(t);
73
+ const blocks = []; allTasks.forEach(x => { const d = Utils.parseDeps(x); if (team != null && d.blockedBy.includes(team)) blocks.push(x); });
74
+ const kv = [
75
+ [I18n.t("modal.kv.id"), t.id], [I18n.t("modal.kv.team"), team != null ? "#" + team : null],
76
+ [I18n.t("modal.kv.agent"), t.assignedAgentId], [I18n.t("modal.kv.module"), t.module],
77
+ [I18n.t("modal.kv.channel"), t.channel],
78
+ [I18n.t("modal.kv.source"), t.source], [I18n.t("modal.kv.thread"), t.externalThreadId],
79
+ [I18n.t("modal.kv.created"), t.createdAt ? `${Utils.absTime(t.createdAt)} (${Utils.relTime(t.createdAt)})` : null],
80
+ [I18n.t("modal.kv.updated"), t.updatedAt ? `${Utils.absTime(t.updatedAt)} (${Utils.relTime(t.updatedAt)})` : null],
81
+ [I18n.t("modal.kv.lastActivity"), t.lastActivityAt ? `${Utils.absTime(t.lastActivityAt)} (${Utils.relTime(t.lastActivityAt)})` : null],
82
+ ].filter(r => r[1] != null && r[1] !== "");
83
+ const depLink = n => { const b = teamIndex.get(n); return `<span class="badge dep-active deplink" data-team="${n}">#${n}${b ? " " + Utils.esc((b.title || "").slice(0, 28)) : ""}</span>`; };
84
+ const history = Array.isArray(t.history) ? t.history.slice().sort((a, b) => new Date(a.at) - new Date(b.at)) : [];
85
+
86
+ this.dom.mBody.innerHTML =
87
+ this.actionsHTML(t, opts) +
88
+ (t.description ? `<p class="desc-full">${Utils.esc(t.description)}</p>` : "") +
89
+ `<div class="metrics">
90
+ <div class="metric">⏱ ${Utils.esc(I18n.t("modal.metric.lead"))}<b>${Utils.dur(c.leadMs)}</b></div>
91
+ <div class="metric">🔧 ${Utils.esc(I18n.t("modal.metric.inProgress"))}<b>${Utils.dur(c.inProgressMs || null)}</b></div>
92
+ <div class="metric">📌 ${Utils.esc(I18n.t("modal.metric.status"))}<b>${Utils.esc(t.status)}</b></div>
93
+ </div>` +
94
+ ((deps.blockedBy.length || blocks.length) ? `<h4>${Utils.esc(I18n.t("modal.dependencies"))}</h4>` : "") +
95
+ (deps.blockedBy.length ? `<div class="row"><span class="lbl">${Utils.esc(deps.active ? I18n.t("modal.blockedBy") : I18n.t("modal.wasBlockedBy"))}</span> <span class="deplinks">${deps.blockedBy.map(depLink).join("")}</span></div>` : "") +
96
+ (blocks.length ? `<div class="row"><span class="lbl">${Utils.esc(I18n.t("modal.blocks"))}</span> <span class="deplinks">${blocks.map(b => `<span class="badge dep-active deplink" data-task="${Utils.esc(b.id)}">${Utils.esc(b.title.slice(0, 32))}</span>`).join("")}</span></div>` : "") +
97
+ `<h4>${Utils.esc(I18n.t("modal.data"))}</h4><dl class="kv">${kv.map(([k, v]) => `<dt>${Utils.esc(k)}</dt><dd>${Utils.esc(v)}${k === I18n.t("modal.kv.id") ? ` <button type="button" class="copy-id" data-copy="${Utils.esc(v)}" title="${Utils.esc(I18n.t("modal.copyIdTitle"))}">${Utils.esc(I18n.t("modal.copyId"))}</button>` : ""}</dd>`).join("")}</dl>` +
98
+ (notes.length ? `<h4>${Utils.esc(I18n.t("modal.notes", { n: notes.length }))}</h4><ul class="notes">${notes.map(n => {
99
+ const k = Utils.noteKind(n.text);
100
+ const top = (k || n.at) ? `<div class="n-top">${k ? `<span class="n-kind ${k.cls}">${Utils.esc(k.label)}</span>` : ""}${n.at ? `<span class="n-when" title="${Utils.esc(Utils.absTime(n.at))}">${Utils.esc(Utils.relTime(n.at))}</span>` : ""}</div>` : "";
101
+ return `<li class="note-item">${top}<div class="n-text">${Utils.esc(n.text)}</div></li>`;
102
+ }).join("")}</ul>` : "") +
103
+ (history.length ? `<h4>${Utils.esc(I18n.t("modal.history", { n: history.length }))}</h4><ul class="timeline">${history.map(h => {
104
+ const from = h.fromStatus ? `<span class="pill${Utils.pillClass(h.fromStatus)}" style="background:${COLOR[h.fromStatus] || "var(--muted)"}">${Utils.esc(h.fromStatus)}</span> → ` : "";
105
+ const to = h.toStatus ? `<span class="pill${Utils.pillClass(h.toStatus)}" style="background:${COLOR[h.toStatus] || "var(--muted)"}">${Utils.esc(h.toStatus)}</span>` : "";
106
+ return `<li><div class="when">${Utils.esc(Utils.absTime(h.at))} · ${Utils.esc(Utils.relTime(h.at))}</div><div class="trans">${Utils.esc(h.type || "")} ${from}${to}</div>${h.note ? `<div class="note2">${Utils.esc(h.note)}</div>` : ""}</li>`;
107
+ }).join("")}</ul>` : "");
108
+ }
109
+
110
+ show() { this.dom.overlay.classList.add("show"); }
111
+ hide() { this.dom.overlay.classList.remove("show"); }
112
+ }
@@ -0,0 +1,73 @@
1
+ import { Utils } from "./Utils.js";
2
+
3
+ /**
4
+ * Polls tasks.json with conditional GET (ETag / Last-Modified), and computes the diff
5
+ * between two polls (status change / +note / +history / updated / new).
6
+ */
7
+ export class TaskStore {
8
+ constructor(getUrl) {
9
+ this.getUrl = getUrl; // () => string – the current source URL
10
+ this.etag = null;
11
+ this.lastModified = null;
12
+ this.currentTasks = [];
13
+ this.prevTasksById = new Map(); // full tasks from the previous poll (for diffing)
14
+ this.changeInfo = new Map(); // id -> {status,notes,history,updated,isNew}
15
+ this.renderedOnce = false;
16
+ }
17
+
18
+ reset() {
19
+ this.etag = null;
20
+ this.lastModified = null;
21
+ this.prevTasksById = new Map();
22
+ this.currentTasks = [];
23
+ this.renderedOnce = false;
24
+ this.changeInfo = new Map();
25
+ }
26
+
27
+ static diff(prev, cur) {
28
+ if (!prev) return { isNew: true };
29
+ const ch = {};
30
+ if (prev.status !== cur.status) ch.status = `${prev.status}→${cur.status}`;
31
+ if (Utils.norm(prev.notes).length !== Utils.norm(cur.notes).length) ch.notes = true;
32
+ if ((prev.history || []).length !== (cur.history || []).length) ch.history = true;
33
+ if (prev.updatedAt !== cur.updatedAt) ch.updated = true;
34
+ return Object.keys(ch).length ? ch : null;
35
+ }
36
+
37
+ /** One poll cycle. Returns { notModified } or { tasks, shouldRender, changeCount }. Throws on error. */
38
+ async poll() {
39
+ const url = this.getUrl();
40
+ const headers = {};
41
+ if (this.etag) headers["If-None-Match"] = this.etag;
42
+ if (this.lastModified) headers["If-Modified-Since"] = this.lastModified;
43
+
44
+ const res = await fetch(url, { cache: "no-store", headers });
45
+ if (res.status === 304) return { notModified: true };
46
+ if (!res.ok) throw new Error("HTTP " + res.status);
47
+
48
+ this.etag = res.headers.get("ETag") || this.etag;
49
+ this.lastModified = res.headers.get("Last-Modified") || this.lastModified;
50
+
51
+ const text = await res.text();
52
+ let data;
53
+ try { data = JSON.parse(text); } catch (e) { throw new Error("Invalid JSON in the file"); }
54
+
55
+ const tasks = Array.isArray(data.tasks) ? data.tasks : [];
56
+ this.changeInfo = new Map();
57
+ if (this.prevTasksById.size) {
58
+ tasks.forEach(t => {
59
+ const c = TaskStore.diff(this.prevTasksById.get(t.id), t);
60
+ if (c) this.changeInfo.set(t.id, c);
61
+ });
62
+ }
63
+ const countChanged = tasks.length !== this.currentTasks.length;
64
+ this.currentTasks = tasks;
65
+ this.prevTasksById = new Map(tasks.map(t => [t.id, JSON.parse(JSON.stringify(t))]));
66
+
67
+ const shouldRender = !this.renderedOnce || this.changeInfo.size > 0 || countChanged;
68
+ return { notModified: false, tasks, shouldRender, changeCount: this.changeInfo.size };
69
+ }
70
+
71
+ /** Marks that at least one render has happened, so subsequent diffs are computed against it. */
72
+ markRendered() { this.renderedOnce = true; }
73
+ }
package/js/UrlState.js ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Reads/writes URL query-string + localStorage state.
3
+ * Deep-link parameters: ?project=&lang=&task=<id>&agent=&module=&q=&sort=&view=&compact=1
4
+ * project/lang make the board's URL directly shareable/bookmarkable to a specific project
5
+ * in a specific language — the initial state is read from the URL first, falling back to
6
+ * localStorage. localStorage keys preserve the view across sessions (tm.src, tm.project,
7
+ * tm.lang, tm.interval, tm.sort, tm.view, tm.compact, tm.collapsed).
8
+ */
9
+ export class UrlState {
10
+ /** Reads the initial state from the URL + localStorage. */
11
+ static read() {
12
+ const p = new URL(location.href).searchParams;
13
+ return {
14
+ project: p.get("project") || null,
15
+ lang: p.get("lang") || null,
16
+ q: p.get("q") || "",
17
+ sort: p.get("sort") || localStorage.getItem("tm.sort") || "activity",
18
+ view: p.get("view") || localStorage.getItem("tm.view") || "board",
19
+ compact: p.get("compact") === "1" || (!p.has("compact") && localStorage.getItem("tm.compact") === "1"),
20
+ agentFilter: p.has("agent") ? new Set(p.get("agent").split(",").filter(Boolean)) : null,
21
+ moduleFilter: p.get("module") || null,
22
+ quickFilter: p.get("quick") || null,
23
+ pendingTask: p.get("task") || null,
24
+ collapsedCols: new Set(JSON.parse(localStorage.getItem("tm.collapsed") || "[]")),
25
+ src: localStorage.getItem("tm.src"),
26
+ interval: localStorage.getItem("tm.interval"),
27
+ };
28
+ }
29
+
30
+ /** Writes the current filter/view state back into the URL (history.replaceState). */
31
+ static sync({ q, agentFilter, moduleFilter, quickFilter, sort, view, compact, openTaskId, project, lang }) {
32
+ const p = new URLSearchParams();
33
+ if (project) p.set("project", project);
34
+ if (lang && lang !== "en") p.set("lang", lang);
35
+ if (q && q.trim()) p.set("q", q.trim());
36
+ if (agentFilter) p.set("agent", [...agentFilter].join(","));
37
+ if (moduleFilter) p.set("module", moduleFilter);
38
+ if (quickFilter) p.set("quick", quickFilter);
39
+ if (sort !== "activity") p.set("sort", sort);
40
+ if (view !== "board") p.set("view", view);
41
+ if (compact) p.set("compact", "1");
42
+ if (openTaskId) p.set("task", openTaskId);
43
+ const qs = p.toString();
44
+ history.replaceState(null, "", location.pathname + (qs ? "?" + qs : ""));
45
+ }
46
+
47
+ static setSrc(v) { localStorage.setItem("tm.src", v); }
48
+ static setInterval(v) { localStorage.setItem("tm.interval", v); }
49
+ static setSort(v) { localStorage.setItem("tm.sort", v); }
50
+ static setView(v) { localStorage.setItem("tm.view", v); }
51
+ static setCompact(v) { localStorage.setItem("tm.compact", v ? "1" : "0"); }
52
+ static setCollapsed(set) { localStorage.setItem("tm.collapsed", JSON.stringify([...set])); }
53
+ }