@jhizzard/termdeck 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,258 @@
1
+ // flashback-history.js — Sprint 43 T2 audit dashboard.
2
+ //
3
+ // Renders the durable flashback_events table (SQLite-backed) plus the
4
+ // click-through funnel aggregate. Reads from GET /api/flashback/history.
5
+ // Filter: time-window selector (1d / 7d / 30d / all). One round-trip per
6
+ // view; the server returns events + funnel in one response.
7
+ //
8
+ // Zero-state handling is intentional and prominent: an empty table after
9
+ // 7+ days of normal use IS the diagnostic signal Joshua needs (he's been
10
+ // flashback-blind in past sprints; "0 fires" tells him to investigate the
11
+ // PTY → analyzer → bridge → emit pipeline, not silently degrade).
12
+ //
13
+ // Vanilla JS, no framework — matches the rest of public/.
14
+
15
+ (() => {
16
+ const API = window.location.origin;
17
+
18
+ const els = {
19
+ windowSel: document.getElementById('fbWindow'),
20
+ refreshBtn: document.getElementById('fbRefresh'),
21
+ errorBanner: document.getElementById('fbErrorBanner'),
22
+ content: document.getElementById('fbContent'),
23
+ barFires: document.getElementById('fbBarFires'),
24
+ barDismissed: document.getElementById('fbBarDismissed'),
25
+ barClicked: document.getElementById('fbBarClicked'),
26
+ countFires: document.getElementById('fbCountFires'),
27
+ countDismissed: document.getElementById('fbCountDismissed'),
28
+ countClicked: document.getElementById('fbCountClicked'),
29
+ pctDismissed: document.getElementById('fbPctDismissed'),
30
+ pctClicked: document.getElementById('fbPctClicked'),
31
+ };
32
+
33
+ // URL state: ?window=7d persists across reload.
34
+ function loadStateFromUrl() {
35
+ const qs = new URLSearchParams(window.location.search);
36
+ const win = qs.get('window');
37
+ if (win && ['1d', '7d', '30d', 'all'].includes(win)) {
38
+ els.windowSel.value = win;
39
+ }
40
+ }
41
+ function writeStateToUrl() {
42
+ const qs = new URLSearchParams(window.location.search);
43
+ qs.set('window', els.windowSel.value);
44
+ const next = `${window.location.pathname}?${qs.toString()}`;
45
+ window.history.replaceState(null, '', next);
46
+ }
47
+
48
+ // Compute ISO timestamp for the "since" filter from the window selector.
49
+ // Returns null for "all time" (no filter).
50
+ function sinceFromWindow(key) {
51
+ const now = Date.now();
52
+ const ms = {
53
+ '1d': 24 * 60 * 60 * 1000,
54
+ '7d': 7 * 24 * 60 * 60 * 1000,
55
+ '30d': 30 * 24 * 60 * 60 * 1000,
56
+ }[key];
57
+ if (!ms) return null;
58
+ return new Date(now - ms).toISOString();
59
+ }
60
+
61
+ function escapeHtml(s) {
62
+ if (s == null) return '';
63
+ return String(s)
64
+ .replace(/&/g, '&')
65
+ .replace(/</g, '&lt;')
66
+ .replace(/>/g, '&gt;')
67
+ .replace(/"/g, '&quot;')
68
+ .replace(/'/g, '&#39;');
69
+ }
70
+
71
+ function fmtTime(iso) {
72
+ if (!iso) return '—';
73
+ try {
74
+ const d = new Date(iso);
75
+ const now = Date.now();
76
+ const diffMs = now - d.getTime();
77
+ const diffSec = Math.floor(diffMs / 1000);
78
+ if (diffSec < 60) return `${diffSec}s ago`;
79
+ const diffMin = Math.floor(diffSec / 60);
80
+ if (diffMin < 60) return `${diffMin}m ago`;
81
+ const diffHr = Math.floor(diffMin / 60);
82
+ if (diffHr < 24) return `${diffHr}h ago`;
83
+ const diffDay = Math.floor(diffHr / 24);
84
+ if (diffDay < 7) return `${diffDay}d ago`;
85
+ // Older than a week: show the date.
86
+ return d.toISOString().slice(0, 10);
87
+ } catch {
88
+ return iso;
89
+ }
90
+ }
91
+
92
+ function fmtScore(score) {
93
+ if (score == null || !Number.isFinite(score)) return '—';
94
+ return `${(score * 100).toFixed(0)}%`;
95
+ }
96
+
97
+ function renderFunnel(funnel) {
98
+ const fires = Number(funnel?.fires || 0);
99
+ const dismissed = Number(funnel?.dismissed || 0);
100
+ const clicked = Number(funnel?.clicked_through || 0);
101
+
102
+ els.countFires.textContent = String(fires);
103
+ els.countDismissed.textContent = String(dismissed);
104
+ els.countClicked.textContent = String(clicked);
105
+
106
+ // Bar widths: fires is always 100% (the cohort baseline); dismissed and
107
+ // clicked are proportions of fires. Ratio out of fires (not out of
108
+ // dismissed) keeps the funnel-shape visual intuitive.
109
+ els.barFires.style.width = fires > 0 ? '100%' : '0%';
110
+ if (fires > 0) {
111
+ els.barDismissed.style.width = `${(dismissed / fires) * 100}%`;
112
+ els.barClicked.style.width = `${(clicked / fires) * 100}%`;
113
+ els.pctDismissed.textContent = ` · ${Math.round((dismissed / fires) * 100)}%`;
114
+ els.pctClicked.textContent = ` · ${Math.round((clicked / fires) * 100)}%`;
115
+ } else {
116
+ els.barDismissed.style.width = '0%';
117
+ els.barClicked.style.width = '0%';
118
+ els.pctDismissed.textContent = '';
119
+ els.pctClicked.textContent = '';
120
+ }
121
+ }
122
+
123
+ function renderZeroState(windowKey) {
124
+ const windowLabel = {
125
+ '1d': 'the last 24 hours',
126
+ '7d': 'the last 7 days',
127
+ '30d': 'the last 30 days',
128
+ 'all': 'recorded history',
129
+ }[windowKey] || 'the selected window';
130
+
131
+ els.content.innerHTML = `
132
+ <div class="fb-zero">
133
+ <h3>0 fires in ${escapeHtml(windowLabel)}</h3>
134
+ <p>Flashback might not be firing at all — or the underlying RAG isn't returning hits. Investigate the pipeline:</p>
135
+ <p>
136
+ <code>GET /api/flashback/diag?eventType=pattern_match</code> —
137
+ are PTY errors being detected?
138
+ </p>
139
+ <p>
140
+ <code>GET /api/flashback/diag?eventType=bridge_query</code> —
141
+ are queries reaching Mnestra?
142
+ </p>
143
+ <p>
144
+ <code>GET /api/flashback/diag?eventType=proactive_memory_emit</code> —
145
+ are emits being attempted, and what's the outcome?
146
+ </p>
147
+ <p>
148
+ A populated <code>diag</code> ring with no <code>flashback_events</code>
149
+ rows means the WS send is failing or the toast is being dropped on
150
+ the client side. An empty <code>diag</code> ring means the
151
+ analyzer never even matched.
152
+ </p>
153
+ </div>
154
+ `;
155
+ }
156
+
157
+ function renderTable(events) {
158
+ const rows = events.map((e) => {
159
+ const projectCell = e.project
160
+ ? `<span class="fb-cell-project">${escapeHtml(e.project)}</span>`
161
+ : `<span class="fb-cell-project" style="color:var(--tg-text-dim)">—</span>`;
162
+
163
+ const statusPills = [];
164
+ if (e.clicked_through) {
165
+ statusPills.push(`<span class="fb-pill fb-pill-clicked">clicked</span>`);
166
+ } else if (e.dismissed_at) {
167
+ statusPills.push(`<span class="fb-pill fb-pill-dismissed">dismissed</span>`);
168
+ } else {
169
+ statusPills.push(`<span class="fb-pill fb-pill-pending">pending</span>`);
170
+ }
171
+
172
+ const errorPreview = (e.error_text || '').slice(0, 200);
173
+
174
+ return `
175
+ <tr>
176
+ <td class="fb-cell-time" title="${escapeHtml(e.fired_at || '')}">${escapeHtml(fmtTime(e.fired_at))}</td>
177
+ <td>${projectCell}</td>
178
+ <td class="fb-cell-error" title="${escapeHtml(e.error_text || '')}">${escapeHtml(errorPreview)}</td>
179
+ <td class="fb-cell-hits">${escapeHtml(String(e.hits_count ?? 0))}</td>
180
+ <td class="fb-cell-score">${escapeHtml(fmtScore(e.top_hit_score))}</td>
181
+ <td class="fb-cell-status">${statusPills.join('')}</td>
182
+ </tr>
183
+ `;
184
+ }).join('');
185
+
186
+ els.content.innerHTML = `
187
+ <div class="fb-table-wrap">
188
+ <table class="fb-table">
189
+ <thead>
190
+ <tr>
191
+ <th>When</th>
192
+ <th>Project</th>
193
+ <th>Search context</th>
194
+ <th style="text-align:right">Hits</th>
195
+ <th style="text-align:right">Score</th>
196
+ <th>Status</th>
197
+ </tr>
198
+ </thead>
199
+ <tbody>${rows}</tbody>
200
+ </table>
201
+ </div>
202
+ `;
203
+ }
204
+
205
+ function showError(msg) {
206
+ els.errorBanner.hidden = false;
207
+ els.errorBanner.textContent = msg;
208
+ }
209
+ function clearError() {
210
+ els.errorBanner.hidden = true;
211
+ els.errorBanner.textContent = '';
212
+ }
213
+
214
+ async function refresh() {
215
+ clearError();
216
+ els.content.innerHTML = `<div class="fb-loading">Loading flashback history…</div>`;
217
+
218
+ const winKey = els.windowSel.value || '7d';
219
+ const since = sinceFromWindow(winKey);
220
+ const qs = new URLSearchParams();
221
+ if (since) qs.set('since', since);
222
+ qs.set('limit', '200');
223
+
224
+ let data;
225
+ try {
226
+ const res = await fetch(`${API}/api/flashback/history?${qs.toString()}`);
227
+ if (!res.ok) {
228
+ throw new Error(`HTTP ${res.status}`);
229
+ }
230
+ data = await res.json();
231
+ } catch (err) {
232
+ showError(`Failed to load flashback history: ${err.message}`);
233
+ els.content.innerHTML = '';
234
+ renderFunnel({ fires: 0, dismissed: 0, clicked_through: 0 });
235
+ return;
236
+ }
237
+
238
+ renderFunnel(data.funnel || { fires: 0, dismissed: 0, clicked_through: 0 });
239
+
240
+ if (!Array.isArray(data.events) || data.events.length === 0) {
241
+ renderZeroState(winKey);
242
+ return;
243
+ }
244
+
245
+ renderTable(data.events);
246
+ }
247
+
248
+ // Wire controls
249
+ els.windowSel.addEventListener('change', () => {
250
+ writeStateToUrl();
251
+ refresh();
252
+ });
253
+ els.refreshBtn.addEventListener('click', () => refresh());
254
+
255
+ // Boot
256
+ loadStateFromUrl();
257
+ refresh();
258
+ })();
@@ -0,0 +1,217 @@
1
+ /* TermDeck — Knowledge Graph view-controls (Sprint 43 T1)
2
+ *
3
+ * Pure functions for filtering and laying out the D3 force-directed graph.
4
+ * Extracted from graph.js so the filter logic can be unit-tested under
5
+ * `node --test` without a bundler.
6
+ *
7
+ * Dual-export pattern: in Node, `module.exports` returns the API object; in
8
+ * the browser, the same API attaches to `window.GraphControls`. graph.html
9
+ * loads this file with a plain <script> tag BEFORE graph.js so the IIFE in
10
+ * graph.js can read `window.GraphControls.applyControls(...)`.
11
+ *
12
+ * The four user-visible controls are:
13
+ * 1. hide-isolated — drop nodes with degree 0
14
+ * 2. min-degree — keep nodes with degree >= N (N ∈ {0,1,2,3,5})
15
+ * 3. time-window — keep nodes with createdAt within last N days
16
+ * 4. layout — 'force' (default) | 'hierarchical' | 'radial'
17
+ *
18
+ * Filter precedence inside applyControls:
19
+ * time-window ⇒ degree. Time-window first because dropping nodes by age
20
+ * also drops their edges, which changes the degree count for the survivors.
21
+ */
22
+
23
+ (function (root) {
24
+ 'use strict';
25
+
26
+ const WINDOW_DAYS = { '7d': 7, '30d': 30, '90d': 90 };
27
+ const LAYOUTS = ['force', 'hierarchical', 'radial'];
28
+ const VALID_WINDOWS = ['all', '7d', '30d', '90d'];
29
+
30
+ function edgeEndpointId(end) {
31
+ return (end && typeof end === 'object') ? end.id : end;
32
+ }
33
+
34
+ function computeDegrees(nodes, edges) {
35
+ const deg = new Map();
36
+ for (const n of nodes) deg.set(n.id, 0);
37
+ for (const e of edges) {
38
+ const sId = edgeEndpointId(e.source);
39
+ const tId = edgeEndpointId(e.target);
40
+ // Only count edges where BOTH endpoints are in the node set; a half-known
41
+ // edge is "broken" and shouldn't bump the survivor's degree.
42
+ if (!deg.has(sId) || !deg.has(tId)) continue;
43
+ deg.set(sId, deg.get(sId) + 1);
44
+ deg.set(tId, deg.get(tId) + 1);
45
+ }
46
+ return deg;
47
+ }
48
+
49
+ function windowMinTime(windowKey, now) {
50
+ const days = WINDOW_DAYS[windowKey];
51
+ if (!days) return null;
52
+ const ref = (typeof now === 'number' && Number.isFinite(now)) ? now : Date.now();
53
+ return ref - days * 86_400_000;
54
+ }
55
+
56
+ function filterEdgesByNodeSet(edges, idSet) {
57
+ const out = [];
58
+ for (const e of edges) {
59
+ const sId = edgeEndpointId(e.source);
60
+ const tId = edgeEndpointId(e.target);
61
+ if (idSet.has(sId) && idSet.has(tId)) out.push(e);
62
+ }
63
+ return out;
64
+ }
65
+
66
+ function applyControls(nodes, edges, controls, now) {
67
+ const c = controls || {};
68
+ let outNodes = Array.isArray(nodes) ? nodes.slice() : [];
69
+ let outEdges = Array.isArray(edges) ? edges.slice() : [];
70
+
71
+ const minTime = windowMinTime(c.window, now);
72
+ if (minTime != null) {
73
+ outNodes = outNodes.filter((n) => {
74
+ if (!n.createdAt) return false;
75
+ const t = Date.parse(n.createdAt);
76
+ return Number.isFinite(t) && t >= minTime;
77
+ });
78
+ outEdges = filterEdgesByNodeSet(outEdges, new Set(outNodes.map((n) => n.id)));
79
+ }
80
+
81
+ const minDegreeRaw = Number.isFinite(c.minDegree) ? c.minDegree : 0;
82
+ const minDegree = Math.max(0, Math.floor(minDegreeRaw));
83
+ const hideIsolated = !!c.hideIsolated;
84
+ const threshold = Math.max(minDegree, hideIsolated ? 1 : 0);
85
+
86
+ if (threshold > 0) {
87
+ const deg = computeDegrees(outNodes, outEdges);
88
+ outNodes = outNodes.filter((n) => (deg.get(n.id) || 0) >= threshold);
89
+ outEdges = filterEdgesByNodeSet(outEdges, new Set(outNodes.map((n) => n.id)));
90
+ }
91
+
92
+ return { nodes: outNodes, edges: outEdges };
93
+ }
94
+
95
+ function defaultControls() {
96
+ return { hideIsolated: false, minDegree: 0, window: 'all', layout: 'force' };
97
+ }
98
+
99
+ function normalizeControls(c) {
100
+ const def = defaultControls();
101
+ const out = Object.assign({}, def, c || {});
102
+ out.hideIsolated = !!out.hideIsolated;
103
+ const md = Number(out.minDegree);
104
+ out.minDegree = Number.isFinite(md) && md > 0 ? Math.max(0, Math.floor(md)) : 0;
105
+ if (!VALID_WINDOWS.includes(out.window)) out.window = 'all';
106
+ if (!LAYOUTS.includes(out.layout)) out.layout = 'force';
107
+ return out;
108
+ }
109
+
110
+ function encodeControls(qs, controls) {
111
+ const c = normalizeControls(controls);
112
+ if (c.hideIsolated) qs.set('hideIsolated', '1');
113
+ else qs.delete('hideIsolated');
114
+ if (c.minDegree > 0) qs.set('minDegree', String(c.minDegree));
115
+ else qs.delete('minDegree');
116
+ if (c.window !== 'all') qs.set('window', c.window);
117
+ else qs.delete('window');
118
+ if (c.layout !== 'force') qs.set('layout', c.layout);
119
+ else qs.delete('layout');
120
+ return qs;
121
+ }
122
+
123
+ function decodeControls(qs) {
124
+ const out = defaultControls();
125
+ if (qs.get('hideIsolated') === '1') out.hideIsolated = true;
126
+ const md = parseInt(qs.get('minDegree'), 10);
127
+ if (Number.isFinite(md) && md > 0) out.minDegree = md;
128
+ const win = qs.get('window');
129
+ if (win && VALID_WINDOWS.includes(win)) out.window = win;
130
+ const lay = qs.get('layout');
131
+ if (lay && LAYOUTS.includes(lay)) out.layout = lay;
132
+ return out;
133
+ }
134
+
135
+ // Hierarchical layering — assigns each node a "level" by BFS from roots
136
+ // (nodes with no incoming `supersedes` / `caused_by` edges). Used by the
137
+ // hierarchical layout to map level → y position. Falls back to a single
138
+ // level when no directional edges are present.
139
+ function computeHierarchyLevels(nodes, edges) {
140
+ const directional = new Set(['supersedes', 'caused_by', 'blocks']);
141
+ const incoming = new Map();
142
+ const outgoing = new Map();
143
+ for (const n of nodes) {
144
+ incoming.set(n.id, []);
145
+ outgoing.set(n.id, []);
146
+ }
147
+ for (const e of edges) {
148
+ if (!directional.has(e.kind)) continue;
149
+ const sId = edgeEndpointId(e.source);
150
+ const tId = edgeEndpointId(e.target);
151
+ if (incoming.has(tId)) incoming.get(tId).push(sId);
152
+ if (outgoing.has(sId)) outgoing.get(sId).push(tId);
153
+ }
154
+ const levels = new Map();
155
+ const roots = [];
156
+ for (const n of nodes) {
157
+ if ((incoming.get(n.id) || []).length === 0) {
158
+ roots.push(n.id);
159
+ levels.set(n.id, 0);
160
+ }
161
+ }
162
+ if (roots.length === 0) {
163
+ for (const n of nodes) levels.set(n.id, 0);
164
+ return levels;
165
+ }
166
+ const queue = roots.slice();
167
+ while (queue.length) {
168
+ const id = queue.shift();
169
+ const cur = levels.get(id) || 0;
170
+ for (const next of (outgoing.get(id) || [])) {
171
+ const prev = levels.get(next);
172
+ const candidate = cur + 1;
173
+ if (prev == null || candidate > prev) {
174
+ levels.set(next, candidate);
175
+ queue.push(next);
176
+ }
177
+ }
178
+ }
179
+ for (const n of nodes) {
180
+ if (!levels.has(n.id)) levels.set(n.id, 0);
181
+ }
182
+ return levels;
183
+ }
184
+
185
+ // Radial radius helper — high degree → small radius (center). Returns a
186
+ // function that takes a node and returns a target radius (0 < r ≤ rMax).
187
+ function radialRadiusFn(degreesMap, rMax) {
188
+ let maxDeg = 0;
189
+ for (const v of degreesMap.values()) if (v > maxDeg) maxDeg = v;
190
+ if (maxDeg === 0) return () => rMax * 0.5;
191
+ return (n) => {
192
+ const deg = degreesMap.get(n.id) || 0;
193
+ const t = 1 - (deg / maxDeg);
194
+ return rMax * (0.15 + 0.75 * t);
195
+ };
196
+ }
197
+
198
+ const api = {
199
+ LAYOUTS,
200
+ VALID_WINDOWS,
201
+ computeDegrees,
202
+ windowMinTime,
203
+ applyControls,
204
+ defaultControls,
205
+ normalizeControls,
206
+ encodeControls,
207
+ decodeControls,
208
+ computeHierarchyLevels,
209
+ radialRadiusFn,
210
+ };
211
+
212
+ if (typeof module !== 'undefined' && module.exports) {
213
+ module.exports = api;
214
+ } else {
215
+ root.GraphControls = api;
216
+ }
217
+ })(typeof window !== 'undefined' ? window : globalThis);
@@ -42,6 +42,41 @@
42
42
 
43
43
  <div class="graph-filters" id="graphFilters" role="toolbar" aria-label="Edge type filters"></div>
44
44
 
45
+ <div class="graph-filters graph-filters-row2" id="graphControls" role="toolbar" aria-label="Graph view controls">
46
+ <label class="graph-control graph-control-toggle" title="Drop nodes with zero edges">
47
+ <input type="checkbox" id="ctlHideIsolated">
48
+ <span>hide isolated</span>
49
+ </label>
50
+ <label class="graph-control" title="Keep nodes with at least N edges">
51
+ <span>min degree</span>
52
+ <select id="ctlMinDegree">
53
+ <option value="0">all</option>
54
+ <option value="1">≥ 1</option>
55
+ <option value="2">≥ 2</option>
56
+ <option value="3">≥ 3</option>
57
+ <option value="5">≥ 5</option>
58
+ </select>
59
+ </label>
60
+ <label class="graph-control" title="Keep nodes created within window">
61
+ <span>window</span>
62
+ <select id="ctlWindow">
63
+ <option value="all">all time</option>
64
+ <option value="7d">last 7 days</option>
65
+ <option value="30d">last 30 days</option>
66
+ <option value="90d">last 90 days</option>
67
+ </select>
68
+ </label>
69
+ <label class="graph-control" title="Force algorithm used to position nodes">
70
+ <span>layout</span>
71
+ <select id="ctlLayout">
72
+ <option value="force">force-directed</option>
73
+ <option value="hierarchical">hierarchical</option>
74
+ <option value="radial">radial</option>
75
+ </select>
76
+ </label>
77
+ <span class="graph-control-stat" id="ctlVisibleStat" aria-live="polite"></span>
78
+ </div>
79
+
45
80
  <main class="graph-stage" id="graphStage">
46
81
  <div class="graph-loading" id="graphLoading">
47
82
  <div class="graph-loading-spinner" aria-hidden="true"></div>
@@ -100,6 +135,7 @@
100
135
  </main>
101
136
 
102
137
  <script src="https://cdn.jsdelivr.net/npm/d3@7"></script>
138
+ <script src="graph-controls.js"></script>
103
139
  <script src="graph.js" defer></script>
104
140
  </body>
105
141
  </html>