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,322 @@
1
+ /**
2
+ * Pointer-Events-basiertes Drag-and-Drop.
3
+ *
4
+ * Ersetzt die HTML5-`draggable`-API, die in Safari und einigen anderen
5
+ * Browsern unzuverlässig ist. Pointer-Events funktionieren konsistent in
6
+ * Maus + Touch, lassen sich präzise hooken und erlauben uns ein eigenes
7
+ * Ghost-Element zu rendern.
8
+ *
9
+ * API:
10
+ * enableDraggable(element, { dragType, dragId, onStart?, onEnd? }) → cleanup
11
+ * enableDropTarget(element, { accepts, onEnter?, onLeave?, onDrop }) → cleanup
12
+ * clearAll() — drop all registered targets (used by renderer rerender)
13
+ *
14
+ * Der Drop-Callback bekommt `{ type, id, target, event }`.
15
+ *
16
+ * Ghost-Preview folgt der Maus mit ~0.7 Opacity. Drop-Target-Discovery
17
+ * via `elementFromPoint`, Walk-Up zum nächsten registrierten Target.
18
+ */
19
+ (function (root, factory) {
20
+ if (typeof module === "object" && module.exports) module.exports = factory();
21
+ else (root.STORYMAP = root.STORYMAP || {}).dnd = factory();
22
+ }(typeof self !== "undefined" ? self : this, function () {
23
+ "use strict";
24
+
25
+ const CONSTANTS = {
26
+ DRAG_THRESHOLD_PX: 4,
27
+ GHOST_OPACITY: 0.78,
28
+ GHOST_Z_INDEX: 1000,
29
+ GHOST_TILT_DEG: 2,
30
+ GHOST_MAX_WIDTH_PX: 280
31
+ };
32
+
33
+ // Global registry — keyed by element via a WeakMap (SM-153). The story-map
34
+ // builds-then-discards DOM nodes on every morph render; with a Set those
35
+ // discarded nodes' target entries leaked (unbounded growth + linear
36
+ // findTarget). A WeakMap lets the GC reclaim entries for detached nodes and
37
+ // makes findTarget an O(depth) per-element lookup. `clearAll()` swaps in a
38
+ // fresh map.
39
+ let _targetsByEl = new WeakMap();
40
+ let _active = null; // { type, id, ghost, originEl, offsetX, offsetY, currentTarget, onEnd }
41
+
42
+ /**
43
+ * Walk up from `node` and return the FIRST registered drop target that
44
+ * also accepts `dragType`. Without the type-filter, nested targets that
45
+ * don't accept the active drag (e.g. an Epic-Card inside a Cell — the
46
+ * Epic-Card accepts STORY but not EPIC) would swallow the search and
47
+ * leave the user without a valid drop target — causing same-cell
48
+ * Epic-reorders to silently no-op (SM-61).
49
+ *
50
+ * When `dragType` is omitted (or for backwards compatibility), falls
51
+ * back to the original "first registered target wins" semantics.
52
+ */
53
+ function findTarget(node, dragType) {
54
+ let cur = node;
55
+ while (cur && cur !== document.body && cur !== document) {
56
+ const t = _targetsByEl.get(cur);
57
+ if (t) {
58
+ if (!dragType || (t.accepts && t.accepts.indexOf(dragType) >= 0)) return t;
59
+ // Registered but doesn't accept this type — keep walking up.
60
+ }
61
+ cur = cur.parentNode;
62
+ }
63
+ return null;
64
+ }
65
+
66
+ function startDrag(originEl, opts, pointerEvent) {
67
+ const rect = originEl.getBoundingClientRect();
68
+ const targetW = Math.min(rect.width, CONSTANTS.GHOST_MAX_WIDTH_PX);
69
+ // Proportional scale: if the ghost's width was clamped, the grab-offset
70
+ // must shrink in the same ratio so the cursor stays at the same relative
71
+ // x within the (smaller) ghost. Without this, stretched origin cards
72
+ // produce a ghost that jumps sideways from the cursor.
73
+ const scale = rect.width > 0 ? (targetW / rect.width) : 1;
74
+ const ghost = originEl.cloneNode(true);
75
+ ghost.style.position = "fixed";
76
+ ghost.style.left = rect.left + "px";
77
+ ghost.style.top = rect.top + "px";
78
+ ghost.style.width = targetW + "px";
79
+ ghost.style.zIndex = String(CONSTANTS.GHOST_Z_INDEX);
80
+ ghost.style.pointerEvents = "none";
81
+ ghost.style.opacity = String(CONSTANTS.GHOST_OPACITY);
82
+ ghost.style.transform = "rotate(" + CONSTANTS.GHOST_TILT_DEG + "deg)";
83
+ ghost.style.boxShadow = "0 10px 28px rgba(0,0,0,0.18), 0 4px 10px rgba(0,0,0,0.10)";
84
+ ghost.classList.add("sm-drag-ghost");
85
+ document.body.appendChild(ghost);
86
+
87
+ originEl.classList.add("sm-dragging");
88
+
89
+ _active = {
90
+ type: opts.dragType,
91
+ id: opts.dragId,
92
+ originEl,
93
+ ghost,
94
+ offsetX: (pointerEvent.clientX - rect.left) * scale,
95
+ offsetY: (pointerEvent.clientY - rect.top),
96
+ currentTarget: null,
97
+ onEnd: opts.onEnd
98
+ };
99
+
100
+ if (typeof opts.onStart === "function") opts.onStart();
101
+
102
+ document.addEventListener("pointermove", onDragMove, true);
103
+ document.addEventListener("pointerup", onDragEnd, true);
104
+ document.addEventListener("pointercancel", onDragCancel, true);
105
+ }
106
+
107
+ function onDragMove(ev) {
108
+ if (!_active) return;
109
+ ev.preventDefault();
110
+ _active.ghost.style.left = (ev.clientX - _active.offsetX) + "px";
111
+ _active.ghost.style.top = (ev.clientY - _active.offsetY) + "px";
112
+
113
+ // Hide ghost temporarily so elementFromPoint sees what's under it.
114
+ _active.ghost.style.display = "none";
115
+ const under = document.elementFromPoint(ev.clientX, ev.clientY);
116
+ _active.ghost.style.display = "";
117
+
118
+ // Pass the active drag-type so findTarget skips registered targets that
119
+ // don't accept it and walks up to the nearest one that DOES — required
120
+ // for same-cell Epic-reorders where the Epic-Card (accepts STORY only)
121
+ // would otherwise swallow the EPIC-drop search (SM-61).
122
+ const target = under ? findTarget(under, _active.type) : null;
123
+ const acceptedTarget = target; // findTarget already type-filtered.
124
+
125
+ if (acceptedTarget !== _active.currentTarget) {
126
+ if (_active.currentTarget && typeof _active.currentTarget.onLeave === "function") {
127
+ try { _active.currentTarget.onLeave(_active.currentTarget.element); } catch (_) {}
128
+ }
129
+ _active.currentTarget = acceptedTarget;
130
+ if (_active.currentTarget && typeof _active.currentTarget.onEnter === "function") {
131
+ try { _active.currentTarget.onEnter(_active.currentTarget.element); } catch (_) {}
132
+ }
133
+ }
134
+
135
+ // Per-pointermove notification to the active target, used by the
136
+ // live-reorder mechanism to compute the insertion index from clientY.
137
+ if (_active.currentTarget && typeof _active.currentTarget.onMove === "function") {
138
+ try {
139
+ _active.currentTarget.onMove({
140
+ type: _active.type,
141
+ id: _active.id,
142
+ target: _active.currentTarget.element,
143
+ clientX: ev.clientX,
144
+ clientY: ev.clientY
145
+ });
146
+ } catch (_) {}
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Visuelle Aufräumarbeiten (Ghost entfernen, sm-dragging entfernen,
152
+ * Drop-Target-Highlight clearen). Wird VOR dem onDrop-Callback aufgerufen,
153
+ * damit der re-render danach saubere DOM-State sieht — selbst wenn das
154
+ * Drop-Callback asynchron ist (HTTP-PUT + reloadStore).
155
+ */
156
+ function cleanupVisualOnly() {
157
+ if (!_active) return;
158
+ if (_active.ghost && _active.ghost.parentNode) _active.ghost.parentNode.removeChild(_active.ghost);
159
+ if (_active.originEl) _active.originEl.classList.remove("sm-dragging");
160
+ if (_active.currentTarget && typeof _active.currentTarget.onLeave === "function") {
161
+ try { _active.currentTarget.onLeave(_active.currentTarget.element); } catch (_) {}
162
+ }
163
+ }
164
+
165
+ function resetActiveState() {
166
+ if (!_active) return;
167
+ if (typeof _active.onEnd === "function") {
168
+ try { _active.onEnd(); } catch (_) {}
169
+ }
170
+ document.removeEventListener("pointermove", onDragMove, true);
171
+ document.removeEventListener("pointerup", onDragEnd, true);
172
+ document.removeEventListener("pointercancel", onDragCancel, true);
173
+ _active = null;
174
+ }
175
+
176
+ function onDragEnd(ev) {
177
+ if (!_active) return;
178
+ // Order is load-bearing:
179
+ // 1. cleanupVisualOnly — ghost off, dragging-class off (visual reset).
180
+ // 2. target.onDrop — fires the drop callback. MUST happen BEFORE
181
+ // resetActiveState, because resetActiveState invokes the draggable's
182
+ // onEnd, which (in the storymap renderer) calls
183
+ // setDragProjection(null). If we cleared the projection first, the
184
+ // drop callback would read a null projection and silently fall back
185
+ // to "no insertion index" (= splice at end), losing the user's
186
+ // intended drop position.
187
+ // 3. resetActiveState — fires onEnd, removes listeners, _active=null.
188
+ const target = _active.currentTarget;
189
+ const type = _active.type;
190
+ const id = _active.id;
191
+ cleanupVisualOnly();
192
+ if (target && typeof target.onDrop === "function") {
193
+ try { target.onDrop({ type, id, target: target.element, event: ev }); } catch (e) { console.error(e); }
194
+ }
195
+ resetActiveState();
196
+ }
197
+
198
+ function onDragCancel() {
199
+ cleanupVisualOnly();
200
+ resetActiveState();
201
+ }
202
+
203
+ /**
204
+ * Brute-force cleanup of orphan drag artifacts. Called by the renderer
205
+ * before each mount so a previous incomplete drag can't leave stale
206
+ * ghosts / dragging-classes in the document.
207
+ */
208
+ function scrubArtifacts() {
209
+ if (typeof document === "undefined") return;
210
+ // Never remove the CURRENTLY ACTIVE drag's ghost — only orphans from
211
+ // a previous incomplete drag. Live-reorder triggers a renderer
212
+ // re-render mid-drag; without this guard the active ghost would be
213
+ // deleted on every projection change and the user loses cursor-follow.
214
+ const activeGhost = _active && _active.ghost;
215
+ const activeOrigin = _active && _active.originEl;
216
+ // SM-79: same exclusion-pattern as ghost + dragging — DON'T strip the
217
+ // currently-hovered drop-target's highlight class mid-drag. Without this
218
+ // guard, every `setDragProjection` (fires per pointermove inside any
219
+ // drop target) triggers a renderer rerender → scrubArtifacts → wipes
220
+ // .sm-drop-target → user never sees a drop-zone highlight at all.
221
+ const activeDrop = _active && _active.currentTarget && _active.currentTarget.element;
222
+ const ghosts = document.querySelectorAll(".sm-drag-ghost");
223
+ for (const g of ghosts) if (g !== activeGhost && g.parentNode) g.parentNode.removeChild(g);
224
+ const stuck = document.querySelectorAll(".sm-dragging");
225
+ for (const d of stuck) if (d !== activeOrigin) d.classList.remove("sm-dragging");
226
+ const targets = document.querySelectorAll(".sm-drop-target");
227
+ for (const t of targets) if (t !== activeDrop) t.classList.remove("sm-drop-target");
228
+ // Orphan live-reorder shadow cards from an aborted drag — but ONLY
229
+ // when no drag is active. During an active drag the shadow card is
230
+ // re-emitted by the renderer at the new insertion position.
231
+ if (!_active) {
232
+ const shadows = document.querySelectorAll(".sm-card-shadow");
233
+ for (const s of shadows) if (s.parentNode) s.parentNode.removeChild(s);
234
+ }
235
+ }
236
+
237
+ // ---- Public API ----------------------------------------------------
238
+
239
+ /**
240
+ * Mark an element draggable. Returns a cleanup function that
241
+ * removes the pointerdown listener.
242
+ *
243
+ * The threshold-based gesture avoids stealing clicks: short taps
244
+ * still propagate to click-handlers, only movement > threshold
245
+ * starts a drag.
246
+ */
247
+ function enableDraggable(element, opts) {
248
+ function onDown(ev) {
249
+ if (ev.button !== 0 || _active) return;
250
+ // SM-158: `a` added — clicking a ticket-key link inside a draggable card
251
+ // must navigate, not start a drag.
252
+ if (ev.target && ev.target.closest && ev.target.closest("button,input,select,textarea,a")) return;
253
+ // Stop the pointerdown from bubbling to ancestor draggables. A Story-
254
+ // Card inside an Epic-Card needs its own drag to win — without this
255
+ // the Epic's onDown also fires and the two drags race for _active.
256
+ ev.stopPropagation();
257
+ const startX = ev.clientX, startY = ev.clientY;
258
+ function maybeStart(mEv) {
259
+ if (Math.hypot(mEv.clientX - startX, mEv.clientY - startY) < CONSTANTS.DRAG_THRESHOLD_PX) return;
260
+ document.removeEventListener("pointermove", maybeStart, true);
261
+ document.removeEventListener("pointerup", cancel, true);
262
+ if (_active) return; // belt+suspenders: another draggable beat us to it
263
+ startDrag(element, opts, mEv);
264
+ }
265
+ function cancel() {
266
+ document.removeEventListener("pointermove", maybeStart, true);
267
+ document.removeEventListener("pointerup", cancel, true);
268
+ }
269
+ document.addEventListener("pointermove", maybeStart, true);
270
+ document.addEventListener("pointerup", cancel, true);
271
+ }
272
+ element.addEventListener("pointerdown", onDown);
273
+ return function detach() {
274
+ element.removeEventListener("pointerdown", onDown);
275
+ };
276
+ }
277
+
278
+ /**
279
+ * Register an element as a drop target.
280
+ * accepts: Array<string> — only drag-types in this list will activate enter/drop.
281
+ * onEnter(el), onLeave(el), onDrop({type, id, target, event})
282
+ * Returns a cleanup function that removes it from the registry.
283
+ */
284
+ function enableDropTarget(element, opts) {
285
+ const target = {
286
+ element,
287
+ accepts: opts.accepts || [],
288
+ onEnter: opts.onEnter,
289
+ onLeave: opts.onLeave,
290
+ onMove: opts.onMove,
291
+ onDrop: opts.onDrop
292
+ };
293
+ _targetsByEl.set(element, target);
294
+ return function detach() {
295
+ if (_targetsByEl.get(element) === target) _targetsByEl.delete(element);
296
+ };
297
+ }
298
+
299
+ /**
300
+ * Wipe all registered drop targets. The renderer calls this at the
301
+ * start of each remount so stale targets from the previous DOM tree
302
+ * are cleared before new ones are registered.
303
+ */
304
+ function clearAll() { _targetsByEl = new WeakMap(); }
305
+
306
+ function isDragging() { return _active != null; }
307
+ function currentDrag() { return _active ? { type: _active.type, id: _active.id } : null; }
308
+
309
+ return {
310
+ CONSTANTS,
311
+ enableDraggable,
312
+ enableDropTarget,
313
+ clearAll,
314
+ scrubArtifacts,
315
+ isDragging,
316
+ currentDrag,
317
+ // Test seam: the registered drop-target entry for an element (handlers +
318
+ // accepts). Lets tests invoke the EXACT onDrop closure that a real pointer
319
+ // drop would fire — including a morph-reused node's (possibly stale) one.
320
+ _getDropTarget: function (el) { return _targetsByEl.get(el); }
321
+ };
322
+ }));
@@ -0,0 +1,215 @@
1
+ /**
2
+ * SM-82 — Ticket-Filter (View-Layer)
3
+ *
4
+ * Pure module. No DOM access, no storage, no store. Consumers (renderers,
5
+ * main.js) wire it into their pipelines.
6
+ *
7
+ * Two surface shapes:
8
+ *
9
+ * 1. **Simple filter spec** — what the toolbar UI produces:
10
+ *
11
+ * { statuses: string[]|null, // null = no constraint
12
+ * types: string[]|null,
13
+ * hideCompletedReleases: bool }
14
+ *
15
+ * 2. **Internal Boolean-Tree AST** — what matchesFilter actually evaluates:
16
+ *
17
+ * Node = { op: "AND", children: Node[] }
18
+ * | { op: "OR", children: Node[] }
19
+ * | { op: "NOT", child: Node }
20
+ * | { op: "CLAUSE", field, clauseOp, value?, values? };
21
+ *
22
+ * `toAST` converts the simple spec into a flat AND-tree of clauses. A future
23
+ * JQL parser (v3+) would produce arbitrary AND/OR/NOT trees consumed by the
24
+ * same `matchesFilter` evaluator — the evaluator is already recursive.
25
+ *
26
+ * Supported clauseOps today: eq, neq, in, not-in.
27
+ *
28
+ * Supported fields:
29
+ * - "status" — ticket.status
30
+ * - "type" — ticket.type
31
+ * - "release.status" — virtual; resolves via ctx.releaseById (built by
32
+ * `buildReleaseCtx(snapshot)` once per render).
33
+ *
34
+ * `releaseVisible(release, filter)` is a separate helper because the renderer
35
+ * uses it to skip release-row iteration entirely when `hideCompletedReleases`
36
+ * is on — different shape than per-ticket filtering.
37
+ */
38
+
39
+ (function (global, factory) {
40
+ if (typeof module === "object" && module.exports) module.exports = factory();
41
+ else {
42
+ const ns = (global.STORYMAP = global.STORYMAP || {});
43
+ ns.filter = factory();
44
+ }
45
+ }(typeof window !== "undefined" ? window : globalThis, function () {
46
+ "use strict";
47
+
48
+ /** Normalise the simple filter spec — empty arrays → null. */
49
+ function normalizeFilter(raw) {
50
+ raw = raw || {};
51
+ const statuses = Array.isArray(raw.statuses) && raw.statuses.length > 0
52
+ ? raw.statuses.slice() : null;
53
+ const types = Array.isArray(raw.types) && raw.types.length > 0
54
+ ? raw.types.slice() : null;
55
+ return {
56
+ statuses: statuses,
57
+ types: types,
58
+ hideCompletedReleases: !!raw.hideCompletedReleases
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Convert the simple spec into a Boolean-Tree AST. Today's UI generates a
64
+ * flat AND-tree; a future JQL parser produces arbitrary trees with the
65
+ * same node shapes and the same evaluator works unchanged.
66
+ */
67
+ function toAST(filter) {
68
+ const f = normalizeFilter(filter);
69
+ const clauses = [];
70
+ if (f.statuses) {
71
+ clauses.push({ op: "CLAUSE", field: "status", clauseOp: "in", values: f.statuses });
72
+ }
73
+ if (f.types) {
74
+ clauses.push({ op: "CLAUSE", field: "type", clauseOp: "in", values: f.types });
75
+ }
76
+ if (f.hideCompletedReleases) {
77
+ clauses.push({ op: "CLAUSE", field: "release.status", clauseOp: "neq", value: "completed" });
78
+ }
79
+ return { op: "AND", children: clauses };
80
+ }
81
+
82
+ function isAST(x) {
83
+ return x && typeof x === "object" && typeof x.op === "string"
84
+ && (x.op === "AND" || x.op === "OR" || x.op === "NOT" || x.op === "CLAUSE");
85
+ }
86
+
87
+ /**
88
+ * Test whether `ticket` satisfies the filter. Accepts either a simple spec
89
+ * (will be converted to AST internally) or a pre-built AST. `ctx` provides
90
+ * resolution helpers for virtual fields — pass `buildReleaseCtx(snapshot)`
91
+ * if your filter uses `release.status`.
92
+ */
93
+ function matchesFilter(ticket, filter, ctx) {
94
+ if (!filter) return true;
95
+ const ast = isAST(filter) ? filter : toAST(filter);
96
+ return evalNode(ticket, ast, ctx);
97
+ }
98
+
99
+ function evalNode(ticket, node, ctx) {
100
+ if (!node) return true;
101
+ if (node.op === "AND") {
102
+ return (node.children || []).every(function (c) { return evalNode(ticket, c, ctx); });
103
+ }
104
+ if (node.op === "OR") {
105
+ const cs = node.children || [];
106
+ if (cs.length === 0) return true;
107
+ return cs.some(function (c) { return evalNode(ticket, c, ctx); });
108
+ }
109
+ if (node.op === "NOT") {
110
+ return !evalNode(ticket, node.child, ctx);
111
+ }
112
+ if (node.op === "CLAUSE") {
113
+ return evalClause(ticket, node, ctx);
114
+ }
115
+ return true;
116
+ }
117
+
118
+ function evalClause(ticket, clause, ctx) {
119
+ const value = resolveField(ticket, clause.field, ctx);
120
+ switch (clause.clauseOp) {
121
+ case "eq": return value === clause.value;
122
+ case "neq": return value !== clause.value;
123
+ case "in": return Array.isArray(clause.values) && clause.values.indexOf(value) >= 0;
124
+ case "not-in": return !Array.isArray(clause.values) || clause.values.indexOf(value) < 0;
125
+ default: return true;
126
+ }
127
+ }
128
+
129
+ function resolveField(ticket, field, ctx) {
130
+ if (!ticket || !field) return null;
131
+ if (field === "status") return ticket.status;
132
+ if (field === "type") return ticket.type;
133
+ if (field === "release.status") {
134
+ const rid = ticket.position && ticket.position.releaseId;
135
+ if (!rid || !ctx || !ctx.releaseById) return null;
136
+ const r = ctx.releaseById.get(rid);
137
+ return r ? r.status : null;
138
+ }
139
+ return null;
140
+ }
141
+
142
+ /**
143
+ * Whether a release-row should be rendered at all. Used by Map to skip
144
+ * the entire row when its status is `completed` and the filter hides it.
145
+ */
146
+ function releaseVisible(release, filter) {
147
+ if (!filter || !release) return true;
148
+ const f = normalizeFilter(filter);
149
+ if (f.hideCompletedReleases && release.status === "completed") return false;
150
+ return true;
151
+ }
152
+
153
+ /** Build a release-by-id lookup for use as `ctx` in matchesFilter. */
154
+ function buildReleaseCtx(snapshot) {
155
+ const releaseById = new Map();
156
+ const releases = (snapshot && snapshot.releases) || [];
157
+ for (let i = 0; i < releases.length; i++) {
158
+ releaseById.set(releases[i].id, releases[i]);
159
+ }
160
+ return { releaseById: releaseById };
161
+ }
162
+
163
+ /** Count how many filter dimensions are active. Drives the toolbar badge. */
164
+ function countActiveRules(filter) {
165
+ const f = normalizeFilter(filter);
166
+ let n = 0;
167
+ if (f.statuses) n++;
168
+ if (f.types) n++;
169
+ if (f.hideCompletedReleases) n++;
170
+ return n;
171
+ }
172
+
173
+ /** Sentinel: a fully-empty filter (no constraint). Useful as a default. */
174
+ const EMPTY_FILTER = Object.freeze({
175
+ statuses: null,
176
+ types: null,
177
+ hideCompletedReleases: false
178
+ });
179
+
180
+ /**
181
+ * SM-16 — free-text search match (distinct from the structured board filter:
182
+ * search HIGHLIGHTS, the filter REMOVES). Case-insensitive; an empty/blank
183
+ * query matches everything. Every whitespace-separated term must be found
184
+ * (AND) somewhere in the ticket's key / title / description / labels.
185
+ */
186
+ function searchHaystack(ticket) {
187
+ if (!ticket) return "";
188
+ const labels = Array.isArray(ticket.labels) ? ticket.labels.join(" ") : "";
189
+ return [ticket.ticketKey, ticket.title, ticket.description, labels]
190
+ .filter(function (s) { return typeof s === "string"; })
191
+ .join(" ")
192
+ .toLowerCase();
193
+ }
194
+
195
+ function matchesSearch(ticket, query) {
196
+ const q = (query == null ? "" : String(query)).toLowerCase().trim();
197
+ if (q === "") return true;
198
+ const hay = searchHaystack(ticket);
199
+ return q.split(/\s+/).every(function (term) { return hay.indexOf(term) >= 0; });
200
+ }
201
+
202
+ return {
203
+ normalizeFilter: normalizeFilter,
204
+ toAST: toAST,
205
+ matchesFilter: matchesFilter,
206
+ matchesSearch: matchesSearch,
207
+ releaseVisible: releaseVisible,
208
+ buildReleaseCtx: buildReleaseCtx,
209
+ countActiveRules: countActiveRules,
210
+ EMPTY_FILTER: EMPTY_FILTER,
211
+ // Exposed for tests / debugging:
212
+ _evalNode: evalNode,
213
+ _resolveField: resolveField
214
+ };
215
+ }));