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.
Files changed (61) hide show
  1. package/ARCHITECTURE.md +334 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +6 -0
  4. package/README.md +239 -0
  5. package/frontend/css/storymap.css +4637 -0
  6. package/frontend/js/adapters.js +423 -0
  7. package/frontend/js/card-animate.js +384 -0
  8. package/frontend/js/core/graph.js +825 -0
  9. package/frontend/js/core.js +3908 -0
  10. package/frontend/js/dialog-ticket-import.js +506 -0
  11. package/frontend/js/dnd.js +322 -0
  12. package/frontend/js/filter.js +215 -0
  13. package/frontend/js/main.js +2499 -0
  14. package/frontend/js/project-io.js +109 -0
  15. package/frontend/js/query-autocomplete.js +196 -0
  16. package/frontend/js/query-engine.js +339 -0
  17. package/frontend/js/query.js +280 -0
  18. package/frontend/js/renderer-card.js +639 -0
  19. package/frontend/js/renderer-dependencies.js +974 -0
  20. package/frontend/js/renderer-kanban.js +505 -0
  21. package/frontend/js/renderer-storymap.js +2530 -0
  22. package/frontend/js/renderer-ticket-editor.js +455 -0
  23. package/frontend/js/renderer-ticket-modal.js +758 -0
  24. package/frontend/js/save-pipeline.js +170 -0
  25. package/frontend/js/smartbar-autocomplete.js +162 -0
  26. package/frontend/js/store.js +197 -0
  27. package/frontend/js/ticket-editor-boot.js +24 -0
  28. package/frontend/js/ticket-form.js +2095 -0
  29. package/frontend/js/ticket-import.js +477 -0
  30. package/frontend/js/ui-shell.js +441 -0
  31. package/frontend/js/view-process-steps.js +233 -0
  32. package/frontend/js/view-requirements.js +361 -0
  33. package/frontend/js/view-settings.js +1864 -0
  34. package/frontend/js/view-table.js +659 -0
  35. package/frontend/js/wheel-pan.js +65 -0
  36. package/frontend/storymap.html +87 -0
  37. package/frontend/ticket-editor.html +29 -0
  38. package/package.json +76 -0
  39. package/server/bus.js +16 -0
  40. package/server/core/graph.js +10 -0
  41. package/server/core.js +10 -0
  42. package/server/identity.js +134 -0
  43. package/server/index.js +283 -0
  44. package/server/ingest.js +212 -0
  45. package/server/mcp.js +2510 -0
  46. package/server/server.js +1599 -0
  47. package/server/slice.js +103 -0
  48. package/server/storage.js +571 -0
  49. package/server/validation.js +225 -0
  50. package/shared/core/graph.js +825 -0
  51. package/shared/core.js +3908 -0
  52. package/shared/project-io.js +109 -0
  53. package/shared/query-autocomplete.js +196 -0
  54. package/shared/query-engine.js +339 -0
  55. package/shared/query.js +280 -0
  56. package/shared/ticket-import.js +477 -0
  57. package/skill/SKILL.md +458 -0
  58. package/skill/reference/anatomy.md +196 -0
  59. package/skill/reference/spec-evolution.md +52 -0
  60. package/skill/reference/tools.md +156 -0
  61. package/skill/reference/workflows.md +203 -0
@@ -0,0 +1,659 @@
1
+ // SM-282 — Table view (Jira Issue Navigator style).
2
+ //
3
+ // A query-driven table over ALL tickets of the project — including epics and
4
+ // unassigned tickets that Map/Kanban hide. Own query line on top (same SM-187
5
+ // language as the smart-bar and MCP query_tickets; shared/query-engine.js is
6
+ // the single source), result table below. Columns are resolved through
7
+ // QUERY_FIELDS (label + resolve per field) — NO parallel field logic.
8
+ //
9
+ // SM-282 scope: fixed DEFAULT_COLUMNS, row click → ticket modal, live-sync
10
+ // with focus guard. Column config (SM-283), header sort (SM-284), CSV export
11
+ // (SM-285) and saved queries (SM-286) build on top of this skeleton.
12
+ //
13
+ // UMD-wrapped: same source runs in the browser AND require()s in Node tests.
14
+ (function (root, factory) {
15
+ if (typeof module === "object" && module.exports) {
16
+ module.exports = factory(
17
+ require("./query-engine.js"),
18
+ require("./smartbar-autocomplete.js"),
19
+ require("./dnd.js")
20
+ );
21
+ } else {
22
+ (root.STORYMAP = root.STORYMAP || {}).viewTable = factory(
23
+ root.STORYMAP && root.STORYMAP.queryEngine,
24
+ root.STORYMAP && root.STORYMAP.smartbarAutocomplete,
25
+ root.STORYMAP && root.STORYMAP.dnd
26
+ );
27
+ }
28
+ }(typeof self !== "undefined" ? self : this, function (queryEngine, smartbarAutocomplete, dnd) {
29
+ "use strict";
30
+
31
+ if (!queryEngine) throw new Error("view-table: query-engine module missing");
32
+
33
+ const TABLE_VIEW = {
34
+ // Column keys reference QUERY_FIELDS[].key — the single field source.
35
+ DEFAULT_COLUMNS: ["key", "type", "title", "status", "release", "processStep", "epic", "updated"],
36
+ // Query re-runs debounced while typing (same cadence as the smart-bar).
37
+ QUERY_DEBOUNCE_MS: 200,
38
+ // SM-283: column config persistence, one entry per project.
39
+ COLUMNS_STORAGE_PREFIX: "storymap-table-columns-",
40
+ // SM-283: dnd drag type for column picker rows.
41
+ DRAG_TYPE_COLUMN: "tv-column",
42
+ // SM-292: default column widths (px) for table-layout:fixed. `title` is
43
+ // deliberately absent — the one width-less column takes the remaining
44
+ // space, so the table always fills the full view width. epic + updated
45
+ // are wide enough that keys/timestamps never wrap (user finding).
46
+ DEFAULT_COL_WIDTHS: {
47
+ key: 90, type: 140, status: 100, release: 170,
48
+ processStep: 150, epic: 90, updated: 140,
49
+ created: 140, label: 140, acCount: 90, linkedTo: 120, linkedFrom: 120,
50
+ },
51
+ // SM-292: per-drag lower bound for column resizing.
52
+ COL_MIN_WIDTH_PX: 56,
53
+ };
54
+
55
+ function storage() {
56
+ try {
57
+ if (typeof localStorage !== "undefined") return localStorage;
58
+ if (typeof window !== "undefined" && window.localStorage) return window.localStorage;
59
+ } catch (_e) { /* opaque origin / privacy mode — no persistence */ }
60
+ return null;
61
+ }
62
+
63
+ function el(tag, attrs, text) {
64
+ const d = document.createElement(tag);
65
+ if (attrs) for (const k in attrs) {
66
+ if (k === "class") d.className = attrs[k];
67
+ else d.setAttribute(k, attrs[k]);
68
+ }
69
+ if (text != null) d.textContent = text;
70
+ return d;
71
+ }
72
+
73
+ /** Display formatting per QUERY_FIELDS type. Pure. */
74
+ function formatCell(field, raw) {
75
+ if (raw == null) return "";
76
+ if (field.type === "date") {
77
+ const n = Number(raw);
78
+ if (!n) return "";
79
+ const d = new Date(n);
80
+ const pad = (x) => String(x).padStart(2, "0");
81
+ return d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate())
82
+ + " " + pad(d.getHours()) + ":" + pad(d.getMinutes());
83
+ }
84
+ if (Array.isArray(raw)) return raw.join(", ");
85
+ return String(raw);
86
+ }
87
+
88
+ /**
89
+ * Pure view model: run the query (empty string = all live tickets, default
90
+ * ticketKey order) and resolve the column cells via QUERY_FIELDS.
91
+ * → { columns: [{key, label}], rows: [{id, cells: [{key, value}]}], error }
92
+ * On a query error, rows is [] and error carries {message, position} — the
93
+ * DOM layer keeps the last good rows visible instead.
94
+ */
95
+ function computeTableModel(snapshot, queryString, columnKeys, orderBy) {
96
+ const fields = queryEngine.fieldsByKey();
97
+ const keys = (columnKeys && columnKeys.length ? columnKeys : TABLE_VIEW.DEFAULT_COLUMNS)
98
+ .filter((k) => fields[String(k).toLowerCase()]);
99
+ const columns = keys.map((k) => {
100
+ const f = fields[String(k).toLowerCase()];
101
+ return { key: k, label: f.label };
102
+ });
103
+ const q = (queryString == null ? "" : String(queryString)).trim();
104
+ // null AST = match all (engine contract) — the empty query shows the
105
+ // whole project, which is the visibility point of this view.
106
+ // SM-284: an explicit orderBy (header click) OVERRIDES the query's own
107
+ // ORDER BY (engine contract of opts.orderBy); without one, the query's
108
+ // ORDER BY applies and is reported back for the header indicator.
109
+ const result = queryEngine.queryTickets(snapshot, q === "" ? null : q, orderBy ? { orderBy: orderBy } : undefined);
110
+ if (result.error) return { columns: columns, rows: [], error: result.error, orderBy: null };
111
+ const ctx = queryEngine.buildQueryCtx(snapshot);
112
+ const rows = result.tickets.map((t) => ({
113
+ id: t.id,
114
+ cells: keys.map((k) => {
115
+ const f = fields[String(k).toLowerCase()];
116
+ return { key: k, value: formatCell(f, f.resolve(t, ctx)) };
117
+ })
118
+ }));
119
+ return { columns: columns, rows: rows, error: null, orderBy: result.orderBy || null };
120
+ }
121
+
122
+ // ---- SM-283: column configuration -----------------------------------------
123
+
124
+ /** Filter to known QUERY_FIELDS keys, dedupe; empty/invalid → default set. Pure. */
125
+ function normalizeColumnKeys(keys) {
126
+ const fields = queryEngine.fieldsByKey();
127
+ const seen = new Set();
128
+ const known = (Array.isArray(keys) ? keys : []).filter((k) => {
129
+ const lk = String(k).toLowerCase();
130
+ if (!fields[lk] || seen.has(lk)) return false;
131
+ seen.add(lk);
132
+ return true;
133
+ });
134
+ return known.length ? known : TABLE_VIEW.DEFAULT_COLUMNS.slice();
135
+ }
136
+
137
+ /** Pure reorder (mirrors view-settings.reorderStatuses on plain keys). */
138
+ function reorderColumnKeys(keys, sourceKey, targetKey, before) {
139
+ if (sourceKey === targetKey) return keys.slice();
140
+ const fromIdx = keys.indexOf(sourceKey);
141
+ if (fromIdx < 0) return keys.slice();
142
+ const next = keys.slice();
143
+ next.splice(fromIdx, 1);
144
+ let insertAt = next.indexOf(targetKey);
145
+ if (insertAt < 0) insertAt = next.length;
146
+ if (!before) insertAt += 1;
147
+ next.splice(insertAt, 0, sourceKey);
148
+ return next;
149
+ }
150
+
151
+ /**
152
+ * Read the per-project table config: { keys: string[]|null, widths: {} }.
153
+ * SM-292 extended the stored shape from a bare key array to
154
+ * { keys, widths } — the old array format is migrated on read.
155
+ */
156
+ function readColumnsFromStorage(projectId) {
157
+ const ls = storage();
158
+ if (!ls || !projectId) return { keys: null, widths: {} };
159
+ try {
160
+ const raw = ls.getItem(TABLE_VIEW.COLUMNS_STORAGE_PREFIX + projectId);
161
+ if (!raw) return { keys: null, widths: {} };
162
+ const data = JSON.parse(raw);
163
+ if (Array.isArray(data)) return { keys: data, widths: {} }; // legacy shape
164
+ if (data && typeof data === "object") {
165
+ return {
166
+ keys: Array.isArray(data.keys) ? data.keys : null,
167
+ widths: (data.widths && typeof data.widths === "object") ? data.widths : {}
168
+ };
169
+ }
170
+ return { keys: null, widths: {} };
171
+ } catch (_e) { return { keys: null, widths: {} }; }
172
+ }
173
+
174
+ function writeColumnsToStorage(projectId, keys, widths) {
175
+ const ls = storage();
176
+ if (!ls || !projectId) return;
177
+ try {
178
+ ls.setItem(TABLE_VIEW.COLUMNS_STORAGE_PREFIX + projectId,
179
+ JSON.stringify({ keys: keys, widths: widths || {} }));
180
+ }
181
+ catch (_e) { /* quota etc. — config just won't persist */ }
182
+ }
183
+
184
+ // ---- SM-285: CSV export ----------------------------------------------------
185
+
186
+ /** RFC-4180 field escaping: quote when the value contains , " CR or LF. Pure. */
187
+ function csvEscape(value) {
188
+ const s = value == null ? "" : String(value);
189
+ if (/[",\r\n]/.test(s)) return '"' + s.replace(/"/g, '""') + '"';
190
+ return s;
191
+ }
192
+
193
+ /** Serialize a table model to CSV. Header row = field keys. CRLF lines. Pure. */
194
+ function toCsv(columns, rows) {
195
+ const lines = [columns.map((c) => csvEscape(c.key)).join(",")];
196
+ rows.forEach((r) => {
197
+ lines.push(r.cells.map((c) => csvEscape(c.value)).join(","));
198
+ });
199
+ return lines.join("\r\n");
200
+ }
201
+
202
+ /** Download filename, sanitized like project-io.exportFilename. Pure. */
203
+ function csvFilename(projectId) {
204
+ const id = projectId ? String(projectId).replace(/[^a-zA-Z0-9_-]/g, "_") : "project";
205
+ return id + "-tickets.csv";
206
+ }
207
+
208
+ /** Browser glue: trigger a client-side download via a transient anchor.
209
+ * Uses a Blob URL when available, else a data: URI (also covers jsdom). */
210
+ function triggerDownload(filename, text) {
211
+ const a = document.createElement("a");
212
+ if (typeof URL !== "undefined" && typeof URL.createObjectURL === "function" && typeof Blob !== "undefined") {
213
+ const url = URL.createObjectURL(new Blob([text], { type: "text/csv;charset=utf-8" }));
214
+ a.href = url;
215
+ a.download = filename;
216
+ a.click();
217
+ setTimeout(() => URL.revokeObjectURL(url), 0);
218
+ } else {
219
+ a.href = "data:text/csv;charset=utf-8," + encodeURIComponent(text);
220
+ a.download = filename;
221
+ a.click();
222
+ }
223
+ }
224
+
225
+ // ---- DOM layer ------------------------------------------------------------
226
+
227
+ /** Effective width for a column: user-resized > default > null (flexible). */
228
+ function widthForColumn(key, widths) {
229
+ if (widths && typeof widths[key] === "number" && widths[key] > 0) return widths[key];
230
+ if (typeof TABLE_VIEW.DEFAULT_COL_WIDTHS[key] === "number") return TABLE_VIEW.DEFAULT_COL_WIDTHS[key];
231
+ return null;
232
+ }
233
+
234
+ /**
235
+ * SM-294: clamp a resize delta so BOTH the dragged column and the
236
+ * compensating column stay >= minW. This is what makes the separator stop
237
+ * (clip) at the ends instead of drifting. Pure.
238
+ */
239
+ function clampResizeDelta(dx, draggedW, compW, minW) {
240
+ // Bounds are pinned to never cross zero: when one side ALREADY sits
241
+ // below minW (overconstrained narrow viewport), the drag must not invert
242
+ // — dx=0 stays admissible and only the widening direction of the
243
+ // sub-min column is allowed (review finding on d25fd11).
244
+ return Math.max(Math.min(0, minW - draggedW), Math.min(Math.max(0, compW - minW), dx));
245
+ }
246
+
247
+ /**
248
+ * SM-294: pick the column that compensates a resize 1:1 (keeps the column
249
+ * sum == container, so fixed-layout has NO slack to redistribute and the
250
+ * separator tracks the mouse exactly). Preference: the flexible column
251
+ * (no width — normally `title`); when the flexible column itself is
252
+ * dragged, the next column to the right (or left, for the last one). Pure.
253
+ */
254
+ function pickCompensator(columnKeys, draggedKey, widths) {
255
+ // Dragging a width-carrying column: the flexible partner absorbs.
256
+ if (widthForColumn(draggedKey, widths) != null) {
257
+ const flex = columnKeys.find((k) => k !== draggedKey && widthForColumn(k, widths) == null);
258
+ if (flex) return flex;
259
+ }
260
+ const idx = columnKeys.indexOf(draggedKey);
261
+ if (idx < 0) return null;
262
+ // Dragging the flexible column (or no flexible partner exists): prefer a
263
+ // WIDTH-CARRYING neighbor — a flexible partner would make the resulting
264
+ // patch empty and the whole gesture a silent no-op (review finding).
265
+ const after = columnKeys.slice(idx + 1).find((k) => widthForColumn(k, widths) != null);
266
+ if (after) return after;
267
+ const before = columnKeys.slice(0, idx).reverse().find((k) => widthForColumn(k, widths) != null);
268
+ if (before) return before;
269
+ // All partners flexible: plain neighbor (live preview only).
270
+ if (idx < columnKeys.length - 1) return columnKeys[idx + 1];
271
+ return idx > 0 ? columnKeys[idx - 1] : null;
272
+ }
273
+
274
+ function renderTable(tableHost, model, opts, refs) {
275
+ tableHost.innerHTML = "";
276
+ const table = el("table", { class: "tv-table" });
277
+ // SM-292: table-layout:fixed + colgroup. Columns with a width keep it;
278
+ // the width-less ones (title by default) share the remaining space, so
279
+ // the table always fills the full view width.
280
+ const widths = (refs && refs.widths) || {};
281
+ const colgroup = el("colgroup");
282
+ const colEls = {};
283
+ const thEls = {};
284
+ model.columns.forEach((c) => {
285
+ const col = el("col", { "data-col": c.key });
286
+ const w = widthForColumn(c.key, widths);
287
+ if (w != null) col.style.width = w + "px";
288
+ colEls[c.key] = col;
289
+ colgroup.appendChild(col);
290
+ });
291
+ table.appendChild(colgroup);
292
+ const thead = el("thead");
293
+ const headRow = el("tr");
294
+ const sortField = model.orderBy ? String(model.orderBy.field).toLowerCase() : null;
295
+ model.columns.forEach((c) => {
296
+ const th = el("th", { "data-col": c.key });
297
+ th.appendChild(el("span", { class: "tv-th-label" }, c.label));
298
+ // SM-284: clickable headers; the active sort column carries the
299
+ // indicator (data-sort-dir styles the ▲/▼ via CSS).
300
+ if (sortField && String(c.key).toLowerCase() === sortField) {
301
+ th.setAttribute("data-sort-dir", model.orderBy.dir === "desc" ? "desc" : "asc");
302
+ }
303
+ if (opts.onHeaderSort) {
304
+ th.addEventListener("click", () => opts.onHeaderSort(c.key));
305
+ }
306
+ // SM-292: per-column resize handle at the right edge of the header.
307
+ // Pointer-drag adjusts the col width live; the handle never triggers
308
+ // the sort click (stopPropagation on down + click).
309
+ if (opts.onColumnResize) {
310
+ const handle = el("span", { class: "tv-resize-handle" });
311
+ handle.addEventListener("click", (ev) => ev.stopPropagation());
312
+ handle.addEventListener("pointerdown", (ev) => {
313
+ ev.stopPropagation();
314
+ ev.preventDefault();
315
+ // Capture so pointerup outside the window still reaches us (mouse
316
+ // input gets no implicit capture) — review finding on 8218ee3.
317
+ try { if (handle.setPointerCapture && ev.pointerId != null) handle.setPointerCapture(ev.pointerId); } catch (_e) {}
318
+ const keys = model.columns.map((x) => x.key);
319
+ const compKey = pickCompensator(keys, c.key, widths);
320
+ if (!compKey) return;
321
+ const startX = ev.clientX;
322
+ // SM-294: freeze EVERY column at its measured width (sum ==
323
+ // container) and compensate the drag 1:1 on compKey. With zero
324
+ // slack, fixed-layout redistributes nothing and the separator
325
+ // tracks the mouse exactly. Measure the live THs (cols have no
326
+ // principal box → zero rect); fall back to configured widths.
327
+ const start = {};
328
+ keys.forEach((k) => {
329
+ const r = thEls[k] && thEls[k].getBoundingClientRect ? thEls[k].getBoundingClientRect() : null;
330
+ start[k] = (r && r.width) || widthForColumn(k, widths) || 150;
331
+ });
332
+ keys.forEach((k) => { colEls[k].style.width = Math.round(start[k]) + "px"; });
333
+ let moved = false;
334
+ function onMove(mv) {
335
+ moved = true;
336
+ const dx = clampResizeDelta(mv.clientX - startX, start[c.key], start[compKey], TABLE_VIEW.COL_MIN_WIDTH_PX);
337
+ colEls[c.key].style.width = Math.round(start[c.key] + dx) + "px";
338
+ colEls[compKey].style.width = Math.round(start[compKey] - dx) + "px";
339
+ }
340
+ function cleanup() {
341
+ document.removeEventListener("pointermove", onMove);
342
+ document.removeEventListener("pointerup", onUp);
343
+ document.removeEventListener("pointercancel", onCancel);
344
+ }
345
+ function suppressTrailingClick() {
346
+ // A clamped drag can end with the cursor over the TH label — the
347
+ // synthesized click would toggle the sort. Swallow exactly the
348
+ // click that belongs to this gesture.
349
+ if (!moved) return;
350
+ const swallow = (ce) => ce.stopPropagation();
351
+ th.addEventListener("click", swallow, true);
352
+ setTimeout(() => th.removeEventListener("click", swallow, true), 0);
353
+ }
354
+ function onUp(uv) {
355
+ cleanup();
356
+ suppressTrailingClick();
357
+ const dx = clampResizeDelta(uv.clientX - startX, start[c.key], start[compKey], TABLE_VIEW.COL_MIN_WIDTH_PX);
358
+ // Persist only width-carrying columns. The flexible column (no
359
+ // configured width — normally `title`) NEVER gets one: it stays
360
+ // the slack absorber, so sum == container holds after re-render
361
+ // and its new size follows implicitly from the others.
362
+ const patch = {};
363
+ if (widthForColumn(c.key, widths) != null) patch[c.key] = Math.round(start[c.key] + dx);
364
+ if (widthForColumn(compKey, widths) != null) patch[compKey] = Math.round(start[compKey] - dx);
365
+ opts.onColumnResize(patch);
366
+ }
367
+ function onCancel() {
368
+ // Gesture taken over (touch scroll, OS gesture): persist nothing;
369
+ // the empty patch just re-renders the canonical widths.
370
+ cleanup();
371
+ suppressTrailingClick();
372
+ opts.onColumnResize({});
373
+ }
374
+ document.addEventListener("pointermove", onMove);
375
+ document.addEventListener("pointerup", onUp);
376
+ document.addEventListener("pointercancel", onCancel);
377
+ });
378
+ th.appendChild(handle);
379
+ }
380
+ thEls[c.key] = th;
381
+ headRow.appendChild(th);
382
+ });
383
+ thead.appendChild(headRow);
384
+ table.appendChild(thead);
385
+ const tbody = el("tbody");
386
+ model.rows.forEach((r) => {
387
+ const tr = el("tr", { "data-ticket-id": r.id });
388
+ r.cells.forEach((c) => tr.appendChild(el("td", { "data-col": c.key }, c.value)));
389
+ tr.addEventListener("click", () => { if (opts.onTicketClick) opts.onTicketClick(r.id); });
390
+ tbody.appendChild(tr);
391
+ });
392
+ table.appendChild(tbody);
393
+ tableHost.appendChild(table);
394
+ const count = el("div", { class: "tv-count" },
395
+ model.rows.length + (model.rows.length === 1 ? " ticket" : " tickets"));
396
+ tableHost.appendChild(count);
397
+ }
398
+
399
+ function applyQuery(refs, store, opts, queryString) {
400
+ // SM-284 (review finding): a NEW query resets the header sort — otherwise
401
+ // a single header click would permanently shadow any ORDER BY the user
402
+ // types later (engine opts.orderBy always wins over the query's own).
403
+ if (refs.sort && queryString !== refs.lastGoodQuery) refs.sort = null;
404
+ const model = computeTableModel(store.get(), queryString, refs.columns || opts.columns, refs.sort || null);
405
+ refs.errorHost.innerHTML = "";
406
+ if (model.error) {
407
+ // Non-destructive (same contract as the smart-bar): report the error,
408
+ // keep the last good result on screen.
409
+ const msg = model.error.message
410
+ + (model.error.position != null ? " (at " + model.error.position + ")" : "");
411
+ refs.errorHost.appendChild(el("div", { class: "tv-query-error" }, msg));
412
+ return;
413
+ }
414
+ refs.lastGoodQuery = queryString;
415
+ refs.lastOrderBy = model.orderBy; // SM-284: effective sort (header OR query ORDER BY)
416
+ renderTable(refs.tableHost, model, opts, refs);
417
+ }
418
+
419
+ /**
420
+ * Mount the table view. Returns { unmount }.
421
+ * opts: onTicketClick(id), columns? (SM-283 hook), getSnapshotForAutocomplete?
422
+ */
423
+ function mount(host, store, opts) {
424
+ opts = opts || {};
425
+ host.innerHTML = "";
426
+ const root = el("div", { class: "tv-root" });
427
+
428
+ const queryRow = el("div", { class: "tv-query-row" });
429
+ const input = el("input", {
430
+ class: "tv-query-input",
431
+ type: "text",
432
+ placeholder: "Query… e.g. type = user-story AND status IN (ready, in-progress) — empty shows all",
433
+ spellcheck: "false"
434
+ });
435
+ queryRow.appendChild(input);
436
+ const columnsBtn = el("button", { class: "btn tv-columns-btn", type: "button" }, "Columns");
437
+ queryRow.appendChild(columnsBtn);
438
+ // SM-289: second entry point to the (general) ticket-import dialog.
439
+ if (opts.onImport) {
440
+ const importBtn = el("button", { class: "btn tv-import-btn", type: "button" }, "Import…");
441
+ importBtn.addEventListener("click", () => opts.onImport());
442
+ queryRow.appendChild(importBtn);
443
+ }
444
+ const columnsPanelHost = el("div", { class: "tv-columns-panel-host" });
445
+ const errorHost = el("div", { class: "tv-query-error-host" });
446
+ const tableHost = el("div", { class: "tv-table-host" });
447
+ root.appendChild(queryRow);
448
+ root.appendChild(columnsPanelHost);
449
+ root.appendChild(errorHost);
450
+ root.appendChild(tableHost);
451
+ host.appendChild(root);
452
+
453
+ // SM-283/292: column config — per-project persisted selection, order + widths.
454
+ const projectId = (store.get() && store.get().project && store.get().project.id) || null;
455
+ const storedConfig = readColumnsFromStorage(projectId);
456
+ const refs = {
457
+ errorHost: errorHost,
458
+ tableHost: tableHost,
459
+ lastGoodQuery: "",
460
+ columns: normalizeColumnKeys(storedConfig.keys),
461
+ widths: storedConfig.widths || {}, // SM-292: per-column pixel widths (user-resized)
462
+ sort: null, // SM-284: header-click sort {field, dir} — overrides query ORDER BY
463
+ lastOrderBy: null // SM-284: effective sort of the last render (for toggle direction)
464
+ };
465
+
466
+ // SM-284: clickable column headers. Same column again toggles direction;
467
+ // a different column starts ascending. The header sort overrides a query
468
+ // ORDER BY (engine opts.orderBy contract).
469
+ // SM-292: onColumnResize persists the dragged width with the config.
470
+ opts = Object.assign({}, opts, {
471
+ onHeaderSort: (key) => {
472
+ const cur = refs.lastOrderBy;
473
+ const dir = (cur && String(cur.field).toLowerCase() === String(key).toLowerCase() && cur.dir !== "desc")
474
+ ? "desc" : "asc";
475
+ refs.sort = { field: key, dir: dir };
476
+ applyQuery(refs, store, opts, refs.lastGoodQuery);
477
+ },
478
+ // SM-294: the resize hands over a width PATCH (dragged + possibly the
479
+ // compensating neighbor; the flexible column is never included). An
480
+ // empty patch (cancelled drag) just re-renders the canonical widths.
481
+ onColumnResize: (patch) => {
482
+ const changed = patch && Object.keys(patch).length > 0;
483
+ if (changed) {
484
+ refs.widths = Object.assign({}, refs.widths, patch);
485
+ writeColumnsToStorage(projectId, refs.columns, refs.widths);
486
+ }
487
+ applyQuery(refs, store, opts, refs.lastGoodQuery);
488
+ }
489
+ });
490
+
491
+ function setColumns(nextKeys) {
492
+ refs.columns = normalizeColumnKeys(nextKeys);
493
+ writeColumnsToStorage(projectId, refs.columns, refs.widths);
494
+ applyQuery(refs, store, opts, refs.lastGoodQuery);
495
+ if (columnsPanelHost.childNodes.length) renderColumnsPanel();
496
+ }
497
+
498
+ function renderColumnsPanel() {
499
+ columnsPanelHost.innerHTML = "";
500
+ const panel = el("div", { class: "tv-columns-panel" });
501
+ // Active columns first (in display order), then the remaining fields in
502
+ // QUERY_FIELDS order. Only active rows carry a grip (order = table order).
503
+ const activeSet = new Set(refs.columns.map((k) => String(k).toLowerCase()));
504
+ const rest = queryEngine.QUERY_FIELDS
505
+ .map((f) => f.key)
506
+ .filter((k) => !activeSet.has(String(k).toLowerCase()));
507
+ const fields = queryEngine.fieldsByKey();
508
+ refs.columns.concat(rest).forEach((key) => {
509
+ const f = fields[String(key).toLowerCase()];
510
+ const active = activeSet.has(String(key).toLowerCase());
511
+ const row = el("div", { class: "tv-columns-row" + (active ? " tv-columns-row-active" : "") });
512
+ row.setAttribute("data-col-key", key);
513
+ if (active) row.appendChild(el("span", { class: "tv-columns-grip" }, "⋮⋮"));
514
+ const cb = el("input", { type: "checkbox" });
515
+ cb.checked = active;
516
+ cb.addEventListener("change", () => {
517
+ if (cb.checked) {
518
+ setColumns(refs.columns.concat([key]));
519
+ } else {
520
+ const next = refs.columns.filter((k) => k !== key);
521
+ // Min 1 column: refuse to empty the table entirely.
522
+ if (next.length === 0) { cb.checked = true; return; }
523
+ setColumns(next);
524
+ }
525
+ });
526
+ row.appendChild(cb);
527
+ row.appendChild(el("span", { class: "tv-columns-label" }, f.label));
528
+ if (active && dnd && typeof dnd.enableDraggable === "function") {
529
+ dnd.enableDraggable(row, { dragType: TABLE_VIEW.DRAG_TYPE_COLUMN, dragId: key });
530
+ dnd.enableDropTarget(row, {
531
+ accepts: [TABLE_VIEW.DRAG_TYPE_COLUMN],
532
+ onEnter: (e) => e.classList.add("tv-columns-drop-target"),
533
+ onLeave: (e) => e.classList.remove("tv-columns-drop-target"),
534
+ onDrop: ({ id, event }) => {
535
+ row.classList.remove("tv-columns-drop-target");
536
+ if (id === key) return;
537
+ const rect = row.getBoundingClientRect();
538
+ const before = (event && typeof event.clientY === "number")
539
+ ? event.clientY < rect.top + rect.height / 2 : true;
540
+ setColumns(reorderColumnKeys(refs.columns, id, key, before));
541
+ }
542
+ });
543
+ }
544
+ panel.appendChild(row);
545
+ });
546
+ const footer = el("div", { class: "tv-columns-footer" });
547
+ const resetBtn = el("button", { class: "btn tv-columns-reset", type: "button" }, "Reset to defaults");
548
+ resetBtn.addEventListener("click", () => setColumns(TABLE_VIEW.DEFAULT_COLUMNS.slice()));
549
+ footer.appendChild(resetBtn);
550
+ panel.appendChild(footer);
551
+ columnsPanelHost.appendChild(panel);
552
+ }
553
+
554
+ columnsBtn.addEventListener("click", () => {
555
+ if (columnsPanelHost.childNodes.length) { columnsPanelHost.innerHTML = ""; return; }
556
+ renderColumnsPanel();
557
+ });
558
+
559
+ // SM-285/292: CSV export lives in the Project menu now (user finding) —
560
+ // the mounted view exposes it on the controller so the menu exports the
561
+ // CURRENT state (query + columns + sort). One source: computeTableModel.
562
+ function exportCsv() {
563
+ const model = computeTableModel(store.get(), refs.lastGoodQuery, refs.columns, refs.sort || null);
564
+ if (model.error) return; // defensive: lastGoodQuery cannot really error
565
+ triggerDownload(csvFilename(projectId), toCsv(model.columns, model.rows));
566
+ }
567
+
568
+ let debounceTimer = null;
569
+ function onInput() {
570
+ if (debounceTimer) clearTimeout(debounceTimer);
571
+ debounceTimer = setTimeout(() => {
572
+ applyQuery(refs, store, opts, input.value || "");
573
+ }, TABLE_VIEW.QUERY_DEBOUNCE_MS);
574
+ }
575
+ input.addEventListener("input", onInput);
576
+ input.addEventListener("keydown", (ev) => {
577
+ if (ev.key === "Enter") {
578
+ if (debounceTimer) clearTimeout(debounceTimer);
579
+ applyQuery(refs, store, opts, input.value || "");
580
+ } else if (ev.key === "Escape") {
581
+ input.value = "";
582
+ applyQuery(refs, store, opts, "");
583
+ }
584
+ });
585
+
586
+ // Jira-style autocomplete on the view's own query line (same module as
587
+ // the smart-bar; capture-phase keydown intercepts Enter/Tab/Esc while
588
+ // the dropdown is open).
589
+ let acHandle = null;
590
+ if (smartbarAutocomplete && smartbarAutocomplete.attachAutocomplete) {
591
+ acHandle = smartbarAutocomplete.attachAutocomplete(input, {
592
+ getSnapshot: () => store.get(),
593
+ onAccept: () => applyQuery(refs, store, opts, input.value || "")
594
+ });
595
+ }
596
+
597
+ // Live-sync: any store commit re-renders the table. The query input lives
598
+ // outside tableHost and is never rebuilt, so typing is never stomped.
599
+ // Focus guard: while the user is mid-edit (input focused), the rerun uses
600
+ // the LAST APPLIED query, not the half-typed one — otherwise an external
601
+ // commit would surface a parse error for an incomplete expression.
602
+ const unsub = store.subscribe
603
+ ? store.subscribe(() => applyQuery(refs, store, opts,
604
+ document.activeElement === input ? refs.lastGoodQuery : (input.value || "")))
605
+ : null;
606
+
607
+ applyQuery(refs, store, opts, input.value || "");
608
+
609
+ return {
610
+ exportCsv: exportCsv, // SM-292: called from the Project menu
611
+ unmount() {
612
+ if (typeof unsub === "function") unsub();
613
+ if (acHandle && acHandle.detach) acHandle.detach();
614
+ if (debounceTimer) clearTimeout(debounceTimer);
615
+ host.innerHTML = "";
616
+ }
617
+ };
618
+ }
619
+
620
+ /**
621
+ * SM-292: Project-menu export path when the Table view is NOT mounted —
622
+ * all tickets (empty query) with the project's persisted column config.
623
+ */
624
+ function exportTicketsCsv(store, projectId) {
625
+ const stored = readColumnsFromStorage(projectId);
626
+ const model = computeTableModel(store.get(), "", normalizeColumnKeys(stored.keys), null);
627
+ if (model.error) return;
628
+ triggerDownload(csvFilename(projectId), toCsv(model.columns, model.rows));
629
+ }
630
+
631
+ /** Test hook: run a query against a mounted host synchronously. */
632
+ function _applyQueryForTest(host, store, queryString) {
633
+ const refs = {
634
+ errorHost: host.querySelector(".tv-query-error-host"),
635
+ tableHost: host.querySelector(".tv-table-host"),
636
+ lastGoodQuery: ""
637
+ };
638
+ applyQuery(refs, store, {}, queryString);
639
+ }
640
+
641
+ return {
642
+ TABLE_VIEW: TABLE_VIEW,
643
+ computeTableModel: computeTableModel,
644
+ formatCell: formatCell,
645
+ normalizeColumnKeys: normalizeColumnKeys,
646
+ reorderColumnKeys: reorderColumnKeys,
647
+ toCsv: toCsv,
648
+ csvEscape: csvEscape,
649
+ csvFilename: csvFilename,
650
+ widthForColumn: widthForColumn,
651
+ clampResizeDelta: clampResizeDelta,
652
+ pickCompensator: pickCompensator,
653
+ exportTicketsCsv: exportTicketsCsv,
654
+ readColumnsFromStorage: readColumnsFromStorage,
655
+ writeColumnsToStorage: writeColumnsToStorage,
656
+ mount: mount,
657
+ _applyQueryForTest: _applyQueryForTest
658
+ };
659
+ }));