storymapper 0.1.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/ARCHITECTURE.md +334 -0
- package/LICENSE +201 -0
- package/NOTICE +6 -0
- package/README.md +239 -0
- package/frontend/css/storymap.css +4637 -0
- package/frontend/js/adapters.js +423 -0
- package/frontend/js/card-animate.js +384 -0
- package/frontend/js/core/graph.js +825 -0
- package/frontend/js/core.js +3908 -0
- package/frontend/js/dialog-ticket-import.js +506 -0
- package/frontend/js/dnd.js +322 -0
- package/frontend/js/filter.js +215 -0
- package/frontend/js/main.js +2499 -0
- package/frontend/js/project-io.js +109 -0
- package/frontend/js/query-autocomplete.js +196 -0
- package/frontend/js/query-engine.js +339 -0
- package/frontend/js/query.js +280 -0
- package/frontend/js/renderer-card.js +639 -0
- package/frontend/js/renderer-dependencies.js +974 -0
- package/frontend/js/renderer-kanban.js +505 -0
- package/frontend/js/renderer-storymap.js +2530 -0
- package/frontend/js/renderer-ticket-editor.js +455 -0
- package/frontend/js/renderer-ticket-modal.js +758 -0
- package/frontend/js/save-pipeline.js +170 -0
- package/frontend/js/smartbar-autocomplete.js +162 -0
- package/frontend/js/store.js +197 -0
- package/frontend/js/ticket-editor-boot.js +24 -0
- package/frontend/js/ticket-form.js +2095 -0
- package/frontend/js/ticket-import.js +477 -0
- package/frontend/js/ui-shell.js +441 -0
- package/frontend/js/view-process-steps.js +233 -0
- package/frontend/js/view-requirements.js +361 -0
- package/frontend/js/view-settings.js +1864 -0
- package/frontend/js/view-table.js +659 -0
- package/frontend/js/wheel-pan.js +65 -0
- package/frontend/storymap.html +87 -0
- package/frontend/ticket-editor.html +29 -0
- package/package.json +76 -0
- package/server/bus.js +16 -0
- package/server/core/graph.js +10 -0
- package/server/core.js +10 -0
- package/server/identity.js +134 -0
- package/server/index.js +283 -0
- package/server/ingest.js +212 -0
- package/server/mcp.js +2510 -0
- package/server/server.js +1599 -0
- package/server/slice.js +103 -0
- package/server/storage.js +571 -0
- package/server/validation.js +225 -0
- package/shared/core/graph.js +825 -0
- package/shared/core.js +3908 -0
- package/shared/project-io.js +109 -0
- package/shared/query-autocomplete.js +196 -0
- package/shared/query-engine.js +339 -0
- package/shared/query.js +280 -0
- package/shared/ticket-import.js +477 -0
- package/skill/SKILL.md +458 -0
- package/skill/reference/anatomy.md +196 -0
- package/skill/reference/spec-evolution.md +52 -0
- package/skill/reference/tools.md +156 -0
- package/skill/reference/workflows.md +203 -0
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Geteilte Card-Komponente (E11.1 — DRY-Refactor).
|
|
3
|
+
*
|
|
4
|
+
* Story-Map UND Kanban rendern dasselbe Ticket-Card-Element via diese
|
|
5
|
+
* Funktion. Konsistentes Aussehen + Verhalten, keine Verdopplung von
|
|
6
|
+
* DOM-Struktur, CSS-Klassen oder DnD-Verkabelung. CSS-Klassen-Namespace
|
|
7
|
+
* bleibt `.sm-story-card` (das war zuerst da, hat schon `user-select:none`
|
|
8
|
+
* und ist überall durchgestylt).
|
|
9
|
+
*
|
|
10
|
+
* API:
|
|
11
|
+
* renderTicketCard(ticket, opts) → HTMLElement
|
|
12
|
+
*
|
|
13
|
+
* opts.onTicketClick(id) — Doppelklick öffnet Detail-Modal
|
|
14
|
+
* opts.dragType?: string — wenn gesetzt, registriert dnd.enableDraggable
|
|
15
|
+
* opts.dragId?: string — überschreibt ticket.id (selten nötig)
|
|
16
|
+
* opts.dragOnEnd?: () => void — z.B. um Drag-Projection des Aufrufers zu clearen
|
|
17
|
+
* opts.isShadow?: boolean — Shadow-Variante (gestrichelte Border, kein DnD)
|
|
18
|
+
* opts.width?: number — explizite Breite (Story-Map setzt das,
|
|
19
|
+
* Kanban lässt's auf flexibel)
|
|
20
|
+
*
|
|
21
|
+
* Click stoppt propagation, damit ein Click auf einer Karte nicht an
|
|
22
|
+
* darüberliegende Drop-Targets durchschlägt.
|
|
23
|
+
*/
|
|
24
|
+
(function (root, factory) {
|
|
25
|
+
if (typeof module === "object" && module.exports) module.exports = factory(require("./dnd.js"));
|
|
26
|
+
else (root.STORYMAP = root.STORYMAP || {}).rendererCard = factory(root.STORYMAP && root.STORYMAP.dnd);
|
|
27
|
+
}(typeof self !== "undefined" ? self : this, function (dnd) {
|
|
28
|
+
"use strict";
|
|
29
|
+
if (!dnd) throw new Error("renderer-card: dnd module missing");
|
|
30
|
+
|
|
31
|
+
// The canonical story-card width (px). SINGLE source of truth for every view
|
|
32
|
+
// that renders the shared .sm-story-card — the story map AND the dependency
|
|
33
|
+
// graph reference this, so widening the card can't drift between views. The
|
|
34
|
+
// story-map cell/backbone math and the deps node pitch derive from it; the
|
|
35
|
+
// card HEIGHT lives separately in the CSS var --sm-card-h.
|
|
36
|
+
const CARD_WIDTH_PX = 280;
|
|
37
|
+
|
|
38
|
+
// Type → Cluster mapping (cmapper-Palette, von Story-Map übernommen).
|
|
39
|
+
const TYPE_TO_CLUSTER = {
|
|
40
|
+
"epic": "plum",
|
|
41
|
+
"user-story": "green",
|
|
42
|
+
"technical-task": "blue",
|
|
43
|
+
"technical-task-backend": "blue",
|
|
44
|
+
"technical-task-ui": "teal",
|
|
45
|
+
"bug": "rust",
|
|
46
|
+
"spike": "amber",
|
|
47
|
+
"_default": "slate"
|
|
48
|
+
};
|
|
49
|
+
function clusterForType(t) { return TYPE_TO_CLUSTER[t] || TYPE_TO_CLUSTER._default; }
|
|
50
|
+
|
|
51
|
+
// ---- SM-170: Card-Aging ---------------------------------------------
|
|
52
|
+
// How long a card has sat in its current status. The badge only appears
|
|
53
|
+
// once a card crosses WARN (it's a flag for liegenbleiber, not a clock on
|
|
54
|
+
// every card), and the caller decides WHICH cards age (the Kanban only
|
|
55
|
+
// flags `doing`/`blocked` columns — see renderer-kanban). Thresholds in ms.
|
|
56
|
+
const CARD_AGE = {
|
|
57
|
+
WARN_MS: 3 * 24 * 60 * 60 * 1000, // 3 days → amber
|
|
58
|
+
STALE_MS: 7 * 24 * 60 * 60 * 1000 // 7 days → red
|
|
59
|
+
};
|
|
60
|
+
function humanizeAge(ms) {
|
|
61
|
+
const hours = ms / (60 * 60 * 1000);
|
|
62
|
+
if (hours < 24) return Math.max(1, Math.round(hours)) + "h";
|
|
63
|
+
const days = hours / 24;
|
|
64
|
+
if (days < 7) return Math.round(days) + "d";
|
|
65
|
+
return Math.round(days / 7) + "w";
|
|
66
|
+
}
|
|
67
|
+
// Returns the age-badge element, or null when the card is still fresh.
|
|
68
|
+
function buildAgeBadge(ticket, ageRef) {
|
|
69
|
+
const entered = typeof ticket.statusEnteredAt === "number"
|
|
70
|
+
? ticket.statusEnteredAt
|
|
71
|
+
: (typeof ticket.createdAt === "number" ? ticket.createdAt : null);
|
|
72
|
+
if (entered == null) return null;
|
|
73
|
+
const age = Math.max(0, ageRef - entered);
|
|
74
|
+
if (age < CARD_AGE.WARN_MS) return null;
|
|
75
|
+
const tier = age >= CARD_AGE.STALE_MS ? "stale" : "warn";
|
|
76
|
+
return el("span", {
|
|
77
|
+
class: "sm-badge sm-card-age sm-card-age-" + tier,
|
|
78
|
+
title: "In this status for " + humanizeAge(age),
|
|
79
|
+
text: humanizeAge(age)
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function el(tag, props, children) {
|
|
84
|
+
const e = document.createElement(tag);
|
|
85
|
+
if (props) {
|
|
86
|
+
for (const k of Object.keys(props)) {
|
|
87
|
+
if (k === "class") e.className = props[k];
|
|
88
|
+
else if (k === "dataset") for (const d of Object.keys(props[k])) e.dataset[d] = props[k][d];
|
|
89
|
+
else if (k === "style") Object.assign(e.style, props[k]);
|
|
90
|
+
else if (k === "text") e.textContent = props[k];
|
|
91
|
+
else if (k === "html") e.innerHTML = props[k];
|
|
92
|
+
else if (k.startsWith("on") && typeof props[k] === "function") e.addEventListener(k.slice(2).toLowerCase(), props[k]);
|
|
93
|
+
else e.setAttribute(k, props[k]);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (children) for (const c of children) if (c != null) e.appendChild(typeof c === "string" ? document.createTextNode(c) : c);
|
|
97
|
+
return e;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function checklistBadge(items) {
|
|
101
|
+
if (!items || items.length === 0) return "";
|
|
102
|
+
const done = items.filter(i => i.checked).length;
|
|
103
|
+
return done + "/" + items.length;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---- SM-49: link-badges (per-card dependency indicators) -----------
|
|
107
|
+
//
|
|
108
|
+
// computeLinkIndex(snapshot) → Map<ticketId, {
|
|
109
|
+
// forward, backward,
|
|
110
|
+
// forwardSemantics:Set, backwardSemantics:Set,
|
|
111
|
+
// forwardTargetIds:[], backwardSourceIds:[],
|
|
112
|
+
// forwardTargetKeys:[], backwardSourceKeys:[] }>
|
|
113
|
+
//
|
|
114
|
+
// O(N + sum(links)). Renderers call this once per mount-cycle, then pass
|
|
115
|
+
// the per-ticket entry via opts.linkInfo into renderTicketCard — keeps
|
|
116
|
+
// the card-level lookup O(1) and avoids quadratic re-scan-per-card.
|
|
117
|
+
function _ensureLinkEntry(idx, id) {
|
|
118
|
+
let v = idx.get(id);
|
|
119
|
+
if (!v) {
|
|
120
|
+
v = {
|
|
121
|
+
forward: 0, backward: 0,
|
|
122
|
+
forwardSemantics: new Set(), backwardSemantics: new Set(),
|
|
123
|
+
forwardTargetIds: [], backwardSourceIds: [],
|
|
124
|
+
forwardTargetKeys: [], backwardSourceKeys: []
|
|
125
|
+
};
|
|
126
|
+
idx.set(id, v);
|
|
127
|
+
}
|
|
128
|
+
return v;
|
|
129
|
+
}
|
|
130
|
+
function computeLinkIndex(snapshot) {
|
|
131
|
+
const idx = new Map();
|
|
132
|
+
const tickets = (snapshot && snapshot.tickets) || [];
|
|
133
|
+
const project = (snapshot && snapshot.project) || {};
|
|
134
|
+
const linkTypeById = new Map();
|
|
135
|
+
for (const lt of (project.linkTypes || [])) linkTypeById.set(lt.id, lt);
|
|
136
|
+
const ticketById = new Map();
|
|
137
|
+
for (const t of tickets) ticketById.set(t.id, t);
|
|
138
|
+
for (const t of tickets) {
|
|
139
|
+
if (!t.links || !t.links.length) continue;
|
|
140
|
+
for (const l of t.links) {
|
|
141
|
+
const lt = linkTypeById.get(l.linkTypeId);
|
|
142
|
+
const sem = (lt && lt.semantic) || "freeform";
|
|
143
|
+
const src = _ensureLinkEntry(idx, t.id);
|
|
144
|
+
src.forward++;
|
|
145
|
+
src.forwardSemantics.add(sem);
|
|
146
|
+
src.forwardTargetIds.push(l.targetTicketId);
|
|
147
|
+
const tgtTicket = ticketById.get(l.targetTicketId);
|
|
148
|
+
src.forwardTargetKeys.push(tgtTicket ? (tgtTicket.ticketKey || tgtTicket.id) : l.targetTicketId);
|
|
149
|
+
const tgt = _ensureLinkEntry(idx, l.targetTicketId);
|
|
150
|
+
tgt.backward++;
|
|
151
|
+
tgt.backwardSemantics.add(sem);
|
|
152
|
+
tgt.backwardSourceIds.push(t.id);
|
|
153
|
+
tgt.backwardSourceKeys.push(t.ticketKey || t.id);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return idx;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Pick a single semantic for the badge color. If multiple semantics are
|
|
160
|
+
// mixed under the same direction, fall back to "mixed".
|
|
161
|
+
function dominantSemantic(semSet) {
|
|
162
|
+
if (!semSet || semSet.size === 0) return "freeform";
|
|
163
|
+
if (semSet.size === 1) return semSet.values().next().value;
|
|
164
|
+
return "mixed";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function buildLinkBadge(direction, count, semantic, keysList, hostRef) {
|
|
168
|
+
const arrow = direction === "backward" ? "←" : "→";
|
|
169
|
+
const badge = el("span", {
|
|
170
|
+
class: "sm-badge sm-link-badge sm-link-badge-" + direction + " sm-link-badge-sem-" + semantic,
|
|
171
|
+
dataset: { linkDirection: direction },
|
|
172
|
+
title: (direction === "backward" ? "Referenced by: " : "Links to: ") + keysList.join(", ")
|
|
173
|
+
});
|
|
174
|
+
badge.textContent = arrow + " " + count;
|
|
175
|
+
// Click pulses every card in the same host whose ticket-id is in the
|
|
176
|
+
// related-set. Host lookup walks up: card → host (sm-grid or km-board).
|
|
177
|
+
badge.addEventListener("click", (ev) => {
|
|
178
|
+
ev.stopPropagation();
|
|
179
|
+
const card = badge.closest(".sm-story-card");
|
|
180
|
+
const host = card && (card.closest(".sm-grid") || card.closest(".km-board") || card.parentNode);
|
|
181
|
+
if (!host) return;
|
|
182
|
+
const ids = direction === "backward" ? hostRef.backwardSourceIds : hostRef.forwardTargetIds;
|
|
183
|
+
for (const tid of ids) {
|
|
184
|
+
const targets = host.querySelectorAll('.sm-story-card[data-ticket-id="' + tid + '"]');
|
|
185
|
+
for (const t of targets) {
|
|
186
|
+
t.classList.remove("sm-card-flash");
|
|
187
|
+
// eslint-disable-next-line no-unused-expressions
|
|
188
|
+
void t.offsetWidth;
|
|
189
|
+
t.classList.add("sm-card-flash");
|
|
190
|
+
setTimeout(((node) => () => node.classList.remove("sm-card-flash"))(t), 900);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
return badge;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ---- SM-60: last-execution-outcome badge -----------------------------
|
|
198
|
+
//
|
|
199
|
+
// For type='test-definition' cards we surface the latest execution-run's
|
|
200
|
+
// outcome as a colour-coded pill in the card-meta row. Computation is
|
|
201
|
+
// index-based so renderers can build it once per mount and pass the
|
|
202
|
+
// per-card entry, the same way linkInfo flows in SM-49.
|
|
203
|
+
//
|
|
204
|
+
// computeLastExecutionIndex(snapshot) → Map<defId, {
|
|
205
|
+
// executionTicket, outcome, runAt, runByName, env
|
|
206
|
+
// }> | null
|
|
207
|
+
//
|
|
208
|
+
// Pure: doesn't touch the DOM. The renderers call this once per
|
|
209
|
+
// mount-cycle, then pipe the per-definition entry via opts.lastExecution
|
|
210
|
+
// into renderTicketCard.
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* SM-60: pick the most recent test-execution per test-definition.
|
|
214
|
+
* "Most recent" = highest `runAt`; ties broken by lexicographic ticketKey
|
|
215
|
+
* (deterministic). Soft-deleted executions are ignored.
|
|
216
|
+
*/
|
|
217
|
+
function computeLastExecutionIndex(snapshot) {
|
|
218
|
+
const map = new Map();
|
|
219
|
+
if (!snapshot || !Array.isArray(snapshot.tickets)) return map;
|
|
220
|
+
const TICKETS = snapshot.tickets;
|
|
221
|
+
// First, group all 'executes' links source-by-target.
|
|
222
|
+
const byDef = new Map(); // defId → [executionTicket, ...]
|
|
223
|
+
for (const t of TICKETS) {
|
|
224
|
+
if (t.isDeleted || t.type !== "test-execution") continue;
|
|
225
|
+
if (!Array.isArray(t.links)) continue;
|
|
226
|
+
for (const l of t.links) {
|
|
227
|
+
const lt = l.linkTypeId || l.type;
|
|
228
|
+
if (lt !== "executes") continue;
|
|
229
|
+
const defId = l.targetTicketId;
|
|
230
|
+
if (!defId) continue;
|
|
231
|
+
const arr = byDef.get(defId) || [];
|
|
232
|
+
arr.push(t);
|
|
233
|
+
byDef.set(defId, arr);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
for (const [defId, executions] of byDef) {
|
|
237
|
+
executions.sort((a, b) => {
|
|
238
|
+
const ra = typeof a.runAt === "number" ? a.runAt : 0;
|
|
239
|
+
const rb = typeof b.runAt === "number" ? b.runAt : 0;
|
|
240
|
+
if (rb !== ra) return rb - ra;
|
|
241
|
+
return String(b.ticketKey || "").localeCompare(String(a.ticketKey || ""));
|
|
242
|
+
});
|
|
243
|
+
const top = executions[0];
|
|
244
|
+
const outcome = _outcomeOf(top);
|
|
245
|
+
map.set(defId, {
|
|
246
|
+
executionTicket: top,
|
|
247
|
+
outcome: outcome,
|
|
248
|
+
runAt: top.runAt || null,
|
|
249
|
+
runByName: top.runBy && top.runBy.name ? top.runBy.name : null,
|
|
250
|
+
env: top.env || null
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
return map;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Inline version of getEffectiveOutcome — kept local so the card module
|
|
257
|
+
// doesn't pull in core just for this. Mirrors core.getEffectiveOutcome:
|
|
258
|
+
// manual override wins; otherwise derive from execution steps.
|
|
259
|
+
const _OUTCOMES = ["pending", "passed", "failed", "blocked", "skipped"];
|
|
260
|
+
function _outcomeOf(execTicket) {
|
|
261
|
+
if (!execTicket) return "pending";
|
|
262
|
+
if (typeof execTicket.outcomeOverride === "string"
|
|
263
|
+
&& _OUTCOMES.indexOf(execTicket.outcomeOverride) >= 0) {
|
|
264
|
+
return execTicket.outcomeOverride;
|
|
265
|
+
}
|
|
266
|
+
const steps = Array.isArray(execTicket.executionSteps) ? execTicket.executionSteps : [];
|
|
267
|
+
if (steps.length === 0) return "pending";
|
|
268
|
+
let allPassed = true;
|
|
269
|
+
let anyBlocked = false;
|
|
270
|
+
for (const s of steps) {
|
|
271
|
+
if (!s || typeof s.status !== "string") { allPassed = false; continue; }
|
|
272
|
+
if (s.status === "failed") return "failed";
|
|
273
|
+
if (s.status === "blocked") anyBlocked = true;
|
|
274
|
+
if (s.status !== "passed") allPassed = false;
|
|
275
|
+
}
|
|
276
|
+
if (anyBlocked) return "blocked";
|
|
277
|
+
return allPassed ? "passed" : "pending";
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function _fmtRunAt(ms) {
|
|
281
|
+
if (typeof ms !== "number") return null;
|
|
282
|
+
try {
|
|
283
|
+
const d = new Date(ms);
|
|
284
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
285
|
+
return d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate())
|
|
286
|
+
+ " " + pad(d.getHours()) + ":" + pad(d.getMinutes());
|
|
287
|
+
} catch (_) { return null; }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Build the outcome pill for a test-execution card. The execution carries
|
|
292
|
+
* its own outcome (derived from steps or via manual override) — no
|
|
293
|
+
* external lookup needed. Tooltip surfaces run metadata (when, by whom,
|
|
294
|
+
* env) so the user gets context without opening the modal.
|
|
295
|
+
*/
|
|
296
|
+
function buildExecutionOutcomeBadge(execTicket) {
|
|
297
|
+
const outcome = _outcomeOf(execTicket);
|
|
298
|
+
const tooltipParts = ["outcome: " + outcome];
|
|
299
|
+
const at = _fmtRunAt(execTicket && execTicket.runAt);
|
|
300
|
+
if (at) tooltipParts.push("ran " + at);
|
|
301
|
+
const runBy = execTicket && execTicket.runBy && execTicket.runBy.name;
|
|
302
|
+
if (runBy) tooltipParts.push("by " + runBy);
|
|
303
|
+
if (execTicket && execTicket.env) tooltipParts.push("env: " + execTicket.env);
|
|
304
|
+
return el("span", {
|
|
305
|
+
class: "sm-badge sm-exec-badge sm-exec-badge-" + outcome,
|
|
306
|
+
title: tooltipParts.join(" · "),
|
|
307
|
+
text: outcome
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// SM-158: the ticket-KEY is a link to the full-page editor when a projectId
|
|
312
|
+
// is supplied (board → editor for every item, incl. epics). The editor
|
|
313
|
+
// resolves the key → internal id. Clicking it must not start a card drag (dnd
|
|
314
|
+
// ignores <a>) nor open the modal (stopPropagation on click). Without a
|
|
315
|
+
// projectId it falls back to a plain span (e.g. isolated unit tests).
|
|
316
|
+
function buildTicketKeyEl(ticketKey, projectId) {
|
|
317
|
+
if (!projectId) return el("span", { class: "sm-story-key", text: ticketKey });
|
|
318
|
+
return el("a", {
|
|
319
|
+
class: "sm-story-key sm-key-link",
|
|
320
|
+
href: "/editor?projectId=" + encodeURIComponent(projectId) + "&ticketId=" + encodeURIComponent(ticketKey),
|
|
321
|
+
title: "Open " + ticketKey + " in the full-page editor",
|
|
322
|
+
text: ticketKey,
|
|
323
|
+
onclick: function (e) { if (e && e.stopPropagation) e.stopPropagation(); }
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Generic card shell (DRY core — SM-256/SM-248). Builds the shared
|
|
329
|
+
* `.sm-story-card` element used by BOTH the ticket renderer AND the
|
|
330
|
+
* process-step editor: same DOM, same dnd drag wiring, same dblclick
|
|
331
|
+
* contract — only the leading element (key/ordinal), the title, the
|
|
332
|
+
* optional meta row and the cluster colour differ. There is deliberately
|
|
333
|
+
* NO second card component.
|
|
334
|
+
*
|
|
335
|
+
* spec:
|
|
336
|
+
* cluster cluster colour key ("plum"/"teal"/…)
|
|
337
|
+
* statusClass? extra class on the card (tickets pass "sm-status-<status>")
|
|
338
|
+
* dataset? dataset object set on the card element
|
|
339
|
+
* width? explicit px width
|
|
340
|
+
* isShadow? preview-only variant (dashed border, NO listeners)
|
|
341
|
+
* keyEl? optional leading element (ticket-key link OR step ordinal)
|
|
342
|
+
* title the prominent title text
|
|
343
|
+
* meta? array of meta-row children; omitted/empty → no meta row
|
|
344
|
+
* onDblClick? dblclick handler (open detail modal / edit dialog)
|
|
345
|
+
* dragType?, dragId?, dragOnEnd? dnd.enableDraggable wiring
|
|
346
|
+
*/
|
|
347
|
+
function renderCard(spec) {
|
|
348
|
+
spec = spec || {};
|
|
349
|
+
const isShadow = !!spec.isShadow;
|
|
350
|
+
const card = el("div", {
|
|
351
|
+
class: "sm-story-card sm-cluster-" + spec.cluster
|
|
352
|
+
+ (spec.statusClass ? " " + spec.statusClass : "")
|
|
353
|
+
+ (isShadow ? " sm-card-shadow" : ""),
|
|
354
|
+
dataset: spec.dataset || {}
|
|
355
|
+
});
|
|
356
|
+
if (typeof spec.width === "number") card.style.width = spec.width + "px";
|
|
357
|
+
|
|
358
|
+
const info = el("div", { class: "sm-card-info" });
|
|
359
|
+
if (spec.keyEl) info.appendChild(spec.keyEl);
|
|
360
|
+
info.appendChild(el("span", { class: "sm-story-title", text: spec.title || "" }));
|
|
361
|
+
card.appendChild(info);
|
|
362
|
+
|
|
363
|
+
if (spec.meta && spec.meta.length) {
|
|
364
|
+
const meta = el("div", { class: "sm-card-meta" });
|
|
365
|
+
for (const m of spec.meta) if (m) meta.appendChild(m);
|
|
366
|
+
card.appendChild(meta);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (isShadow) return card; // preview-only — no listeners
|
|
370
|
+
|
|
371
|
+
if (typeof spec.onDblClick === "function") {
|
|
372
|
+
card.addEventListener("dblclick", (ev) => { ev.stopPropagation(); spec.onDblClick(); });
|
|
373
|
+
}
|
|
374
|
+
if (spec.dragType) {
|
|
375
|
+
dnd.enableDraggable(card, { dragType: spec.dragType, dragId: spec.dragId, onEnd: spec.dragOnEnd });
|
|
376
|
+
}
|
|
377
|
+
return card;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function renderTicketCard(t, opts) {
|
|
381
|
+
opts = opts || {};
|
|
382
|
+
// Meta row, in stable order: status pill, DoR/DoD, link badges, outcome, age.
|
|
383
|
+
const meta = [el("span", { class: "sm-card-status sm-status-pill-" + t.status, text: t.status })];
|
|
384
|
+
const dorBadge = checklistBadge(t.definitionOfReady && t.definitionOfReady.items);
|
|
385
|
+
const dodBadge = checklistBadge(t.definitionOfDone && t.definitionOfDone.items);
|
|
386
|
+
if (dorBadge) meta.push(el("span", { class: "sm-badge sm-badge-dor", title: "Definition of Ready", text: "DoR " + dorBadge }));
|
|
387
|
+
if (dodBadge) meta.push(el("span", { class: "sm-badge sm-badge-dod", title: "Definition of Done", text: "DoD " + dodBadge }));
|
|
388
|
+
// SM-49: dependency badges (forward + backward) if a linkIndex entry is supplied.
|
|
389
|
+
const linkInfo = opts.linkInfo;
|
|
390
|
+
if (linkInfo) {
|
|
391
|
+
if (linkInfo.forward > 0) {
|
|
392
|
+
const sem = dominantSemantic(linkInfo.forwardSemantics);
|
|
393
|
+
meta.push(buildLinkBadge("forward", linkInfo.forward, sem, linkInfo.forwardTargetKeys, linkInfo));
|
|
394
|
+
}
|
|
395
|
+
if (linkInfo.backward > 0) {
|
|
396
|
+
const sem = dominantSemantic(linkInfo.backwardSemantics);
|
|
397
|
+
meta.push(buildLinkBadge("backward", linkInfo.backward, sem, linkInfo.backwardSourceKeys, linkInfo));
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
// SM-60: outcome badge belongs on the test-execution card.
|
|
401
|
+
if (t.type === "test-execution") meta.push(buildExecutionOutcomeBadge(t));
|
|
402
|
+
// SM-170: aging badge (only when the caller opts in for this card).
|
|
403
|
+
if (opts.showAge) {
|
|
404
|
+
const ageRef = typeof opts.ageRef === "number" ? opts.ageRef : Date.now();
|
|
405
|
+
const ageBadge = buildAgeBadge(t, ageRef);
|
|
406
|
+
if (ageBadge) meta.push(ageBadge);
|
|
407
|
+
}
|
|
408
|
+
return renderCard({
|
|
409
|
+
cluster: clusterForType(t.type),
|
|
410
|
+
statusClass: "sm-status-" + t.status,
|
|
411
|
+
dataset: { ticketId: t.id, ticketType: t.type, ticketStatus: t.status },
|
|
412
|
+
width: typeof opts.width === "number" ? opts.width : undefined,
|
|
413
|
+
isShadow: !!opts.isShadow,
|
|
414
|
+
keyEl: t.ticketKey ? buildTicketKeyEl(t.ticketKey, opts.projectId) : null,
|
|
415
|
+
title: t.title || "",
|
|
416
|
+
meta: meta,
|
|
417
|
+
onDblClick: typeof opts.onTicketClick === "function" ? function () { opts.onTicketClick(t.id); } : null,
|
|
418
|
+
dragType: opts.dragType,
|
|
419
|
+
dragId: opts.dragId || t.id,
|
|
420
|
+
dragOnEnd: opts.dragOnEnd
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* SM-20 Phase B — patch an EXISTING `.sm-story-card` DOM node in place to
|
|
426
|
+
* reflect the latest ticket data, without destroying the node. Used by
|
|
427
|
+
* the renderers' content-only fast-path so applyRemote-driven field edits
|
|
428
|
+
* don't trigger a full host.innerHTML rebuild.
|
|
429
|
+
*
|
|
430
|
+
* Visible card surfaces that may change between renders:
|
|
431
|
+
* - cluster class (.sm-cluster-X) ← `t.type`
|
|
432
|
+
* - status class (.sm-status-Y) ← `t.status`
|
|
433
|
+
* - dataset: ticketType, ticketStatus
|
|
434
|
+
* - title text ← `t.title`
|
|
435
|
+
* - status pill text + class ← `t.status`
|
|
436
|
+
* - DoR badge text / presence ← `t.definitionOfReady.items`
|
|
437
|
+
* - DoD badge text / presence ← `t.definitionOfDone.items`
|
|
438
|
+
*
|
|
439
|
+
* ticketKey, drag-handlers, dblclick-handler, contextmenu-handler — all
|
|
440
|
+
* stable across edits (they were registered when the card was first
|
|
441
|
+
* created). We never touch them here.
|
|
442
|
+
*/
|
|
443
|
+
function patchTicketCardInPlace(card, t, linkInfo, lastExecution, opts) {
|
|
444
|
+
if (!card || !t) return;
|
|
445
|
+
opts = opts || {};
|
|
446
|
+
const cluster = clusterForType(t.type);
|
|
447
|
+
const newClass = "sm-story-card sm-cluster-" + cluster + " sm-status-" + t.status
|
|
448
|
+
+ (card.classList.contains("sm-card-shadow") ? " sm-card-shadow" : "")
|
|
449
|
+
+ (card.classList.contains("sm-dragging") ? " sm-dragging" : "")
|
|
450
|
+
+ (card.classList.contains("sm-card-flash") ? " sm-card-flash" : "");
|
|
451
|
+
if (card.className !== newClass) card.className = newClass;
|
|
452
|
+
if (card.dataset.ticketType !== t.type) card.dataset.ticketType = t.type;
|
|
453
|
+
if (card.dataset.ticketStatus !== t.status) card.dataset.ticketStatus = t.status;
|
|
454
|
+
|
|
455
|
+
const titleEl = card.querySelector(".sm-story-title");
|
|
456
|
+
if (titleEl && titleEl.textContent !== (t.title || "")) titleEl.textContent = t.title || "";
|
|
457
|
+
|
|
458
|
+
const statusEl = card.querySelector(".sm-card-status");
|
|
459
|
+
if (statusEl) {
|
|
460
|
+
const wantClass = "sm-card-status sm-status-pill-" + t.status;
|
|
461
|
+
if (statusEl.className !== wantClass) statusEl.className = wantClass;
|
|
462
|
+
if (statusEl.textContent !== t.status) statusEl.textContent = t.status;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const meta = card.querySelector(".sm-card-meta");
|
|
466
|
+
if (meta) {
|
|
467
|
+
patchBadge(meta, "sm-badge-dor", "DoR", t.definitionOfReady && t.definitionOfReady.items, "Definition of Ready");
|
|
468
|
+
patchBadge(meta, "sm-badge-dod", "DoD", t.definitionOfDone && t.definitionOfDone.items, "Definition of Done");
|
|
469
|
+
patchLinkBadges(meta, linkInfo);
|
|
470
|
+
// SM-60: keep the last-execution pill in sync. test-definition cards
|
|
471
|
+
// get/keep the badge; other types must NOT show it (e.g. type-change).
|
|
472
|
+
patchLastExecutionBadge(meta, t, lastExecution, opts);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function patchLastExecutionBadge(meta, t /* , lastExecution, opts */) {
|
|
477
|
+
// Remove any existing badge — cheaper than diffing colour/text.
|
|
478
|
+
const existing = meta.querySelector(".sm-exec-badge");
|
|
479
|
+
if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
|
|
480
|
+
// Badge lives only on test-execution cards now (SM-60 revision).
|
|
481
|
+
if (t.type !== "test-execution") return;
|
|
482
|
+
meta.appendChild(buildExecutionOutcomeBadge(t));
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function patchLinkBadges(meta, linkInfo) {
|
|
486
|
+
// Pure remove-and-rebuild: link badges have click handlers that capture
|
|
487
|
+
// a hostRef, so we can't simply patch text. Drop both, then re-append.
|
|
488
|
+
meta.querySelectorAll(".sm-link-badge").forEach(b => b.parentNode && b.parentNode.removeChild(b));
|
|
489
|
+
if (!linkInfo) return;
|
|
490
|
+
if (linkInfo.forward > 0) {
|
|
491
|
+
const sem = dominantSemantic(linkInfo.forwardSemantics);
|
|
492
|
+
meta.appendChild(buildLinkBadge("forward", linkInfo.forward, sem, linkInfo.forwardTargetKeys, linkInfo));
|
|
493
|
+
}
|
|
494
|
+
if (linkInfo.backward > 0) {
|
|
495
|
+
const sem = dominantSemantic(linkInfo.backwardSemantics);
|
|
496
|
+
meta.appendChild(buildLinkBadge("backward", linkInfo.backward, sem, linkInfo.backwardSourceKeys, linkInfo));
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function patchBadge(meta, badgeClass, label, items, tooltip) {
|
|
501
|
+
const text = checklistBadge(items);
|
|
502
|
+
let badge = meta.querySelector("." + badgeClass);
|
|
503
|
+
if (text) {
|
|
504
|
+
const want = label + " " + text;
|
|
505
|
+
if (!badge) {
|
|
506
|
+
// Append after any existing badges (preserve order: DoR then DoD).
|
|
507
|
+
const span = document.createElement("span");
|
|
508
|
+
span.className = "sm-badge " + badgeClass;
|
|
509
|
+
span.title = tooltip;
|
|
510
|
+
span.textContent = want;
|
|
511
|
+
meta.appendChild(span);
|
|
512
|
+
} else if (badge.textContent !== want) {
|
|
513
|
+
badge.textContent = want;
|
|
514
|
+
}
|
|
515
|
+
} else if (badge && badge.parentNode) {
|
|
516
|
+
badge.parentNode.removeChild(badge);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// ---- Shared release-label header (SM-169) --------------------------
|
|
521
|
+
//
|
|
522
|
+
// The Story-Map's stacked release rows AND the Kanban's release swimlanes
|
|
523
|
+
// render the release header identically through this one component, so the
|
|
524
|
+
// chevron, the clickable name, the completed/cancelled strikethrough + pill
|
|
525
|
+
// all look and behave the same in both views. The caller owns the outer
|
|
526
|
+
// container's dataset / lifecycle hooks (Map needs tagBirth + sync keys);
|
|
527
|
+
// this builds the `.sm-release-label-row` and appends the standard parts.
|
|
528
|
+
//
|
|
529
|
+
// spec:
|
|
530
|
+
// id release id (passed back to the callbacks)
|
|
531
|
+
// name display name
|
|
532
|
+
// status release.status ("completed"/"cancelled"/… or null)
|
|
533
|
+
// collapsed boolean — chevron glyph + collapsed row styling
|
|
534
|
+
// onToggleCollapsed(id) chevron click (collapse/expand)
|
|
535
|
+
// onLabelClick(id) label click (e.g. open edit dialog). Omit → the
|
|
536
|
+
// label is non-interactive (used for "No release").
|
|
537
|
+
// trailing optional array of extra elements appended after the pill
|
|
538
|
+
// (e.g. the Kanban count badge, or the Map "+ Add Release").
|
|
539
|
+
function renderReleaseLabelRow(spec) {
|
|
540
|
+
const row = el("div", {
|
|
541
|
+
class: "sm-release-label-row" + (spec.collapsed ? " sm-release-collapsed" : ""),
|
|
542
|
+
dataset: { releaseId: spec.id || "", status: spec.status || "" }
|
|
543
|
+
});
|
|
544
|
+
// SM-178: chevron + name + pill live in a `.sm-release-label-main` cluster
|
|
545
|
+
// so the Story-Map can make JUST that cluster sticky to the left edge
|
|
546
|
+
// (orientation while scrolling horizontally through wide process steps).
|
|
547
|
+
// The Kanban swimlane header uses the same DOM; only the Map pins it (CSS).
|
|
548
|
+
const main = el("div", { class: "sm-release-label-main" });
|
|
549
|
+
const chevron = el("button", {
|
|
550
|
+
class: "sm-release-chevron",
|
|
551
|
+
type: "button",
|
|
552
|
+
title: spec.collapsed ? "Expand release" : "Collapse release",
|
|
553
|
+
text: spec.collapsed ? "▶" : "▼"
|
|
554
|
+
});
|
|
555
|
+
chevron.setAttribute("aria-expanded", spec.collapsed ? "false" : "true");
|
|
556
|
+
chevron.addEventListener("click", (ev) => {
|
|
557
|
+
ev.stopPropagation();
|
|
558
|
+
if (typeof spec.onToggleCollapsed === "function") spec.onToggleCollapsed(spec.id);
|
|
559
|
+
});
|
|
560
|
+
main.appendChild(chevron);
|
|
561
|
+
|
|
562
|
+
const labelProps = { class: "sm-release-label", dataset: { status: spec.status || "" }, text: spec.name };
|
|
563
|
+
if (typeof spec.onLabelClick === "function") labelProps.title = "Click to edit release";
|
|
564
|
+
const label = el("div", labelProps);
|
|
565
|
+
if (typeof spec.onLabelClick === "function") {
|
|
566
|
+
label.addEventListener("click", (ev) => { ev.stopPropagation(); spec.onLabelClick(spec.id); });
|
|
567
|
+
} else {
|
|
568
|
+
label.style.cursor = "default";
|
|
569
|
+
}
|
|
570
|
+
main.appendChild(label);
|
|
571
|
+
|
|
572
|
+
// SM-277: fixed column order — title (fixed width) → progress count →
|
|
573
|
+
// status pill → trailing (Add release). Keeps the badges from fluttering as
|
|
574
|
+
// titles / counts / the completed pill change width across rows + re-renders.
|
|
575
|
+
// SM-239: X/Y progress badge over non-epic work items. Hidden for an empty
|
|
576
|
+
// release (total 0 → neutral, never green). Green (.complete) at done===total.
|
|
577
|
+
if (spec.progress && spec.progress.total > 0) {
|
|
578
|
+
main.appendChild(el("span", {
|
|
579
|
+
class: "sm-release-progress" + (spec.progress.complete ? " complete" : ""),
|
|
580
|
+
text: spec.progress.done + "/" + spec.progress.total,
|
|
581
|
+
title: spec.progress.done + " of " + spec.progress.total + " work items done"
|
|
582
|
+
}));
|
|
583
|
+
}
|
|
584
|
+
if (spec.status === "completed") {
|
|
585
|
+
main.appendChild(el("span", { class: "sm-release-pill sm-release-pill-completed", text: "Completed" }));
|
|
586
|
+
} else if (spec.status === "cancelled") {
|
|
587
|
+
main.appendChild(el("span", { class: "sm-release-pill sm-release-pill-cancelled", text: "Cancelled" }));
|
|
588
|
+
}
|
|
589
|
+
row.appendChild(main);
|
|
590
|
+
if (Array.isArray(spec.trailing)) for (const t of spec.trailing) if (t) row.appendChild(t);
|
|
591
|
+
return row;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// SM-239: patch an existing release-label-row's progress badge in place —
|
|
595
|
+
// used by the renderers' Phase-A/B fast-paths (which don't rebuild the row)
|
|
596
|
+
// so a child status change that shifts X/Y never leaves the badge stale.
|
|
597
|
+
function updateReleaseProgressBadge(rowEl, progress) {
|
|
598
|
+
if (!rowEl) return;
|
|
599
|
+
const main = rowEl.querySelector(".sm-release-label-main") || rowEl;
|
|
600
|
+
let badge = main.querySelector(".sm-release-progress");
|
|
601
|
+
if (!progress || progress.total <= 0) {
|
|
602
|
+
if (badge && badge.parentNode) badge.parentNode.removeChild(badge);
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
const text = progress.done + "/" + progress.total;
|
|
606
|
+
if (!badge) {
|
|
607
|
+
badge = el("span", { class: "sm-release-progress", text: text });
|
|
608
|
+
main.appendChild(badge);
|
|
609
|
+
} else if (badge.textContent !== text) {
|
|
610
|
+
badge.textContent = text;
|
|
611
|
+
}
|
|
612
|
+
badge.classList.toggle("complete", !!progress.complete);
|
|
613
|
+
badge.title = progress.done + " of " + progress.total + " work items done";
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
return {
|
|
617
|
+
CARD_WIDTH_PX,
|
|
618
|
+
TYPE_TO_CLUSTER,
|
|
619
|
+
clusterForType,
|
|
620
|
+
checklistBadge,
|
|
621
|
+
CARD_AGE,
|
|
622
|
+
buildAgeBadge,
|
|
623
|
+
renderCard,
|
|
624
|
+
renderTicketCard,
|
|
625
|
+
renderReleaseLabelRow,
|
|
626
|
+
updateReleaseProgressBadge,
|
|
627
|
+
buildTicketKeyEl,
|
|
628
|
+
patchTicketCardInPlace,
|
|
629
|
+
// SM-49
|
|
630
|
+
computeLinkIndex,
|
|
631
|
+
dominantSemantic,
|
|
632
|
+
buildLinkBadge,
|
|
633
|
+
// SM-60 — outcome badge lives on test-execution cards
|
|
634
|
+
buildExecutionOutcomeBadge,
|
|
635
|
+
// Kept for compat / tests — still computes "latest exec per definition"
|
|
636
|
+
// even though the card renderer no longer surfaces it.
|
|
637
|
+
computeLastExecutionIndex
|
|
638
|
+
};
|
|
639
|
+
}));
|