@opensip-tools/contracts 1.0.4

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 (86) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/.turbo/turbo-typecheck.log +4 -0
  3. package/LICENSE +21 -0
  4. package/dist/__tests__/dashboard.test.d.ts +2 -0
  5. package/dist/__tests__/dashboard.test.d.ts.map +1 -0
  6. package/dist/__tests__/dashboard.test.js +85 -0
  7. package/dist/__tests__/dashboard.test.js.map +1 -0
  8. package/dist/__tests__/exit-codes.test.d.ts +2 -0
  9. package/dist/__tests__/exit-codes.test.d.ts.map +1 -0
  10. package/dist/__tests__/exit-codes.test.js +73 -0
  11. package/dist/__tests__/exit-codes.test.js.map +1 -0
  12. package/dist/__tests__/store.test.d.ts +2 -0
  13. package/dist/__tests__/store.test.d.ts.map +1 -0
  14. package/dist/__tests__/store.test.js +169 -0
  15. package/dist/__tests__/store.test.js.map +1 -0
  16. package/dist/exit-codes.d.ts +14 -0
  17. package/dist/exit-codes.d.ts.map +1 -0
  18. package/dist/exit-codes.js +61 -0
  19. package/dist/exit-codes.js.map +1 -0
  20. package/dist/index.d.ts +21 -0
  21. package/dist/index.d.ts.map +1 -0
  22. package/dist/index.js +20 -0
  23. package/dist/index.js.map +1 -0
  24. package/dist/persistence/dashboard/checks.d.ts +7 -0
  25. package/dist/persistence/dashboard/checks.d.ts.map +1 -0
  26. package/dist/persistence/dashboard/checks.js +279 -0
  27. package/dist/persistence/dashboard/checks.js.map +1 -0
  28. package/dist/persistence/dashboard/css.d.ts +6 -0
  29. package/dist/persistence/dashboard/css.d.ts.map +1 -0
  30. package/dist/persistence/dashboard/css.js +141 -0
  31. package/dist/persistence/dashboard/css.js.map +1 -0
  32. package/dist/persistence/dashboard/generator.d.ts +9 -0
  33. package/dist/persistence/dashboard/generator.d.ts.map +1 -0
  34. package/dist/persistence/dashboard/generator.js +79 -0
  35. package/dist/persistence/dashboard/generator.js.map +1 -0
  36. package/dist/persistence/dashboard/index.d.ts +5 -0
  37. package/dist/persistence/dashboard/index.d.ts.map +1 -0
  38. package/dist/persistence/dashboard/index.js +5 -0
  39. package/dist/persistence/dashboard/index.js.map +1 -0
  40. package/dist/persistence/dashboard/overview.d.ts +6 -0
  41. package/dist/persistence/dashboard/overview.d.ts.map +1 -0
  42. package/dist/persistence/dashboard/overview.js +65 -0
  43. package/dist/persistence/dashboard/overview.js.map +1 -0
  44. package/dist/persistence/dashboard/recipes.d.ts +6 -0
  45. package/dist/persistence/dashboard/recipes.d.ts.map +1 -0
  46. package/dist/persistence/dashboard/recipes.js +68 -0
  47. package/dist/persistence/dashboard/recipes.js.map +1 -0
  48. package/dist/persistence/dashboard/sessions.d.ts +6 -0
  49. package/dist/persistence/dashboard/sessions.d.ts.map +1 -0
  50. package/dist/persistence/dashboard/sessions.js +205 -0
  51. package/dist/persistence/dashboard/sessions.js.map +1 -0
  52. package/dist/persistence/dashboard/shared.d.ts +6 -0
  53. package/dist/persistence/dashboard/shared.d.ts.map +1 -0
  54. package/dist/persistence/dashboard/shared.js +211 -0
  55. package/dist/persistence/dashboard/shared.js.map +1 -0
  56. package/dist/persistence/dashboard/tool-tabs.d.ts +6 -0
  57. package/dist/persistence/dashboard/tool-tabs.d.ts.map +1 -0
  58. package/dist/persistence/dashboard/tool-tabs.js +102 -0
  59. package/dist/persistence/dashboard/tool-tabs.js.map +1 -0
  60. package/dist/persistence/store.d.ts +103 -0
  61. package/dist/persistence/store.d.ts.map +1 -0
  62. package/dist/persistence/store.js +156 -0
  63. package/dist/persistence/store.js.map +1 -0
  64. package/dist/types.d.ts +279 -0
  65. package/dist/types.d.ts.map +1 -0
  66. package/dist/types.js +2 -0
  67. package/dist/types.js.map +1 -0
  68. package/package.json +35 -0
  69. package/src/__tests__/dashboard.test.ts +102 -0
  70. package/src/__tests__/exit-codes.test.ts +87 -0
  71. package/src/__tests__/store.test.ts +213 -0
  72. package/src/exit-codes.ts +74 -0
  73. package/src/index.ts +71 -0
  74. package/src/persistence/dashboard/checks.ts +279 -0
  75. package/src/persistence/dashboard/css.ts +141 -0
  76. package/src/persistence/dashboard/generator.ts +89 -0
  77. package/src/persistence/dashboard/index.ts +5 -0
  78. package/src/persistence/dashboard/overview.ts +65 -0
  79. package/src/persistence/dashboard/recipes.ts +68 -0
  80. package/src/persistence/dashboard/sessions.ts +205 -0
  81. package/src/persistence/dashboard/shared.ts +211 -0
  82. package/src/persistence/dashboard/tool-tabs.ts +102 -0
  83. package/src/persistence/store.ts +233 -0
  84. package/src/types.ts +306 -0
  85. package/tsconfig.json +8 -0
  86. package/vitest.config.ts +16 -0
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Session table + session detail rendering — used by fitness/sim tabs.
3
+ * Returns JS code as a string.
4
+ */
5
+
6
+ export function dashboardSessionsJs(): string {
7
+ return String.raw`
8
+ // =======================================================
9
+ // SESSION TABLE (used by fitness/sim tabs)
10
+ // =======================================================
11
+
12
+ /** Derive 3-state session status: 'fail' | 'warn' | 'pass' */
13
+ function sessionStatus(s) {
14
+ if (s.summary.failed > 0) return 'fail';
15
+ if (s.summary.warnings > 0) return 'warn';
16
+ return 'pass';
17
+ }
18
+
19
+ function statusBadge(status) {
20
+ const labels = { fail: 'FAIL', warn: 'WARN', pass: 'PASS' };
21
+ const classes = { fail: 'badge-fail', warn: 'badge-warn', pass: 'badge-pass' };
22
+ return el('span', {class:'badge ' + classes[status], text: labels[status]});
23
+ }
24
+
25
+ function renderSessionTable(panel, toolSessions, accentColor) {
26
+ if (!toolSessions.length) {
27
+ panel.appendChild(el('div', {class:'empty', text:'No sessions yet.'}));
28
+ return;
29
+ }
30
+
31
+ const tool = toolSessions[0].tool;
32
+
33
+ const table = el('table', {class:'data-table sortable'});
34
+ const thead = el('thead');
35
+ const headerRow = el('tr');
36
+ ['Timestamp', 'Recipe', 'Pass Rate', 'Status', 'Passed', 'Failed', 'Findings', 'Duration'].forEach(h => {
37
+ headerRow.appendChild(el('th', {text: h}));
38
+ });
39
+ thead.appendChild(headerRow);
40
+ table.appendChild(thead);
41
+
42
+ const tbody = el('tbody');
43
+ toolSessions.forEach((s, idx) => {
44
+ const sc = s.score >= 90 ? 'color:var(--success)' : s.score >= 70 ? 'color:var(--warning)' : 'color:var(--error)';
45
+ const row = el('tr', {class:'clickable', id: 'session-row-' + tool + '-' + idx, onclick: () => {
46
+ tbody.querySelectorAll('tr.selected').forEach(r => r.classList.remove('selected'));
47
+ row.classList.add('selected');
48
+ renderDetail(s, idx);
49
+ }});
50
+ row.appendChild(el('td', {text: new Date(s.timestamp).toLocaleString()}));
51
+ row.appendChild(el('td', {text: s.recipe || 'default', style:'color:var(--text-muted)'}));
52
+ const scoreCell = el('td', {style: 'font-weight:600;' + sc});
53
+ scoreCell.textContent = s.score + '%';
54
+ row.appendChild(scoreCell);
55
+ const badgeCell = el('td');
56
+ badgeCell.appendChild(statusBadge(sessionStatus(s)));
57
+ row.appendChild(badgeCell);
58
+ row.appendChild(el('td', {text: ''+s.summary.passed, style:'color:var(--success)'}));
59
+ row.appendChild(el('td', {text: ''+s.summary.failed, style: s.summary.failed > 0 ? 'color:var(--error)' : 'color:var(--text-dim)'}));
60
+ row.appendChild(el('td', {text: ''+(s.summary.errors + (s.summary.warnings || 0))}));
61
+ row.appendChild(el('td', {text: (s.durationMs/1000).toFixed(1)+'s', style:'color:var(--text-dim)'}));
62
+ tbody.appendChild(row);
63
+ });
64
+ table.appendChild(tbody);
65
+
66
+ const sessionPag = el('div', {class:'pagination'});
67
+ const sec = el('div', {class:'section'}, [el('h3', {text:'Sessions (' + toolSessions.length + ')'}), el('div', {class:'card'}, [table, sessionPag])]);
68
+ panel.appendChild(sec);
69
+ paginateTable(tbody, sessionPag, 10);
70
+
71
+ // Detail container — kept as a direct reference, no global ID lookup needed
72
+ const detailContainer = el('div', {id: 'detail-' + tool + '-' + Math.random().toString(36).slice(2,8), class:'section', style:'display:none'});
73
+ panel.appendChild(detailContainer);
74
+
75
+ function renderDetail(session, idx) {
76
+ detailContainer.style.display = 'block';
77
+ while (detailContainer.firstChild) detailContainer.removeChild(detailContainer.firstChild);
78
+
79
+ // Compute session-level totals from check findings
80
+ let totalErrors = 0;
81
+ let totalWarnings = 0;
82
+ session.checks.forEach(c => {
83
+ if (c.findings) {
84
+ c.findings.forEach(f => {
85
+ if (f.severity === 'error') totalErrors++;
86
+ else if (f.severity === 'warning') totalWarnings++;
87
+ });
88
+ }
89
+ });
90
+
91
+ const headerRow = el('div', {style:'display:flex;align-items:center;justify-content:space-between;margin-bottom:16px'});
92
+ const headerLeft = el('div');
93
+ headerLeft.appendChild(el('h3', {text: 'Session Detail \u2014 ' + new Date(session.timestamp).toLocaleString(), style:'margin-bottom:4px'}));
94
+ const sub = el('div', {style:'color:var(--text-dim);font-size:12px'});
95
+ const countParts = [];
96
+ if (totalErrors > 0) countParts.push(totalErrors + ' error' + (totalErrors !== 1 ? 's' : ''));
97
+ if (totalWarnings > 0) countParts.push(totalWarnings + ' warning' + (totalWarnings !== 1 ? 's' : ''));
98
+ const countsStr = countParts.length > 0 ? ' \u2014 ' + countParts.join(', ') : '';
99
+ sub.textContent = session.cwd + (session.recipe ? ' \u2014 recipe: ' + session.recipe : '') + countsStr;
100
+ headerLeft.appendChild(sub);
101
+ headerRow.appendChild(headerLeft);
102
+
103
+ detailContainer.appendChild(headerRow);
104
+ const filterUid = 'df-' + tool + '-' + idx + '-' + Math.random().toString(36).slice(2,6);
105
+
106
+ // Check detail table
107
+ const table = el('table', {class:'data-table sortable'});
108
+ const thead = el('thead');
109
+ const thRow = el('tr');
110
+ ['', 'Check', 'Status', 'Errors', 'Warnings', 'Findings', 'Duration'].forEach(h => {
111
+ thRow.appendChild(el('th', {text: h}));
112
+ });
113
+ thead.appendChild(thRow);
114
+ table.appendChild(thead);
115
+
116
+ const tbody = el('tbody');
117
+ const sortedChecks = [...session.checks].sort((a, b) => {
118
+ const aErrors = a.findings ? a.findings.filter(f => f.severity === 'error').length : 0;
119
+ const bErrors = b.findings ? b.findings.filter(f => f.severity === 'error').length : 0;
120
+ return bErrors - aErrors;
121
+ });
122
+ sortedChecks.forEach((check, i) => {
123
+ const checkErrors = check.findings ? check.findings.filter(f => f.severity === 'error').length : 0;
124
+ const checkWarnings = check.findings ? check.findings.filter(f => f.severity === 'warning').length : 0;
125
+ const findingsTotal = checkErrors + checkWarnings;
126
+ const hasFindings = findingsTotal > 0;
127
+ const expanderId = filterUid + '-exp-' + i;
128
+ const checkStatusVal = check.passed ? 'pass' : 'fail';
129
+
130
+ const arrowCell = el('td', {style:'width:24px;text-align:center;color:var(--text-dim);font-size:12px'});
131
+ if (hasFindings) arrowCell.textContent = '\u25B6';
132
+
133
+ const row = el('tr', {class: hasFindings ? 'clickable' : '', 'data-check-status': checkStatusVal, onclick: hasFindings ? () => {
134
+ const exp = document.getElementById(expanderId);
135
+ if (exp) {
136
+ const isOpen = exp.classList.toggle('open');
137
+ exp.style.display = isOpen ? 'table-row' : 'none';
138
+ arrowCell.textContent = isOpen ? '\u25BC' : '\u25B6';
139
+ }
140
+ row.classList.toggle('expanded');
141
+ } : undefined});
142
+ row.appendChild(arrowCell);
143
+ row.appendChild(el('td', {text: check.checkSlug, style:'font-weight:500'}));
144
+
145
+ const statusCell = el('td');
146
+ statusCell.appendChild(el('span', {class:'badge ' + (check.passed ? 'badge-pass' : 'badge-fail'), text: check.passed ? 'PASS' : 'FAIL'}));
147
+ row.appendChild(statusCell);
148
+ row.appendChild(el('td', {text: ''+checkErrors, style: checkErrors > 0 ? 'color:var(--error)' : 'color:var(--text-dim)'}));
149
+ row.appendChild(el('td', {text: ''+checkWarnings, style: checkWarnings > 0 ? 'color:var(--warning)' : 'color:var(--text-dim)'}));
150
+ row.appendChild(el('td', {text: ''+findingsTotal, style: findingsTotal > 0 ? 'color:var(--text)' : 'color:var(--text-dim)'}));
151
+ row.appendChild(el('td', {text: check.durationMs > 0 ? check.durationMs + 'ms' : '0ms', style:'color:var(--text-dim)'}));
152
+ tbody.appendChild(row);
153
+
154
+ if (hasFindings) {
155
+ const expRow = el('tr', {id: expanderId, class:'expander-row', 'data-check-status': checkStatusVal});
156
+ const expCell = el('td', {colspan:'7', style:'padding:0'});
157
+ const expContent = el('div', {class:'expander-content'});
158
+
159
+ const fTable = el('table', {class:'data-table', style:'margin:0;border:none'});
160
+ const fHead = el('thead');
161
+ const fHeaderRow = el('tr');
162
+ ['Severity', 'Message', 'File', 'Suggestion'].forEach(h => {
163
+ fHeaderRow.appendChild(el('th', {text: h, style:'font-size:11px;padding:6px 12px'}));
164
+ });
165
+ fHead.appendChild(fHeaderRow);
166
+ fTable.appendChild(fHead);
167
+
168
+ const fBody = el('tbody');
169
+ check.findings.forEach(f => {
170
+ const fRow = el('tr');
171
+ const sevCell = el('td', {style:'padding:6px 12px'});
172
+ sevCell.appendChild(el('span', {class:'finding-sev ' + f.severity, text: f.severity}));
173
+ fRow.appendChild(sevCell);
174
+ fRow.appendChild(el('td', {text: f.message, style:'padding:6px 12px;font-size:13px'}));
175
+ fRow.appendChild(el('td', {text: f.filePath ? f.filePath + (f.line ? ':' + f.line : '') : '\u2014', style:'padding:6px 12px;color:var(--text-dim);font-size:12px'}));
176
+ fRow.appendChild(el('td', {text: f.suggestion || '\u2014', style:'padding:6px 12px;color:var(--accent);font-size:12px'}));
177
+ fBody.appendChild(fRow);
178
+ });
179
+ fTable.appendChild(fBody);
180
+ expContent.appendChild(fTable);
181
+ expCell.appendChild(expContent);
182
+ expRow.appendChild(expCell);
183
+ tbody.appendChild(expRow);
184
+ }
185
+ });
186
+ table.appendChild(tbody);
187
+ const detailPag = el('div', {class:'pagination'});
188
+ detailContainer.appendChild(el('div', {class:'card'}, [table, detailPag]));
189
+
190
+ // Enable sorting on the detail table
191
+ makeSortable(table);
192
+
193
+ // Paginate
194
+ paginateGroupedRows(tbody, detailPag, 10);
195
+ }
196
+
197
+ // Auto-show latest and highlight first row
198
+ renderDetail(toolSessions[0], 0);
199
+ const firstRow = tbody.querySelector('tr');
200
+ if (firstRow) firstRow.classList.add('selected');
201
+ }
202
+
203
+
204
+ `;
205
+ }
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Shared dashboard JS — el() helper, pagination, tab switching.
3
+ * Returns JS code as a string to be inlined in the <script> block.
4
+ */
5
+
6
+ export function dashboardSharedJs(): string {
7
+ return String.raw`
8
+ // Tab switching
9
+ document.getElementById('tab-bar').addEventListener('click', e => {
10
+ const tab = e.target.closest('.tab');
11
+ if (!tab) return;
12
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
13
+ document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
14
+ tab.classList.add('active');
15
+ document.getElementById('panel-' + tab.dataset.tab).classList.add('active');
16
+ });
17
+
18
+ function el(tag, attrs, children) {
19
+ const e = document.createElement(tag);
20
+ if (attrs) Object.entries(attrs).forEach(([k,v]) => {
21
+ if (k === 'text') e.textContent = v;
22
+ else if (k === 'class') e.className = v;
23
+ else if (k.startsWith('on')) e.addEventListener(k.slice(2), v);
24
+ else e.setAttribute(k, v);
25
+ });
26
+ if (children) children.forEach(c => { if (typeof c === 'string') e.appendChild(document.createTextNode(c)); else if (c) e.appendChild(c); });
27
+ return e;
28
+ }
29
+
30
+ // =======================================================
31
+ // PAGINATION HELPERS
32
+ // =======================================================
33
+
34
+ function renderPageButtons(container, currentPage, totalPages, goToPage) {
35
+ container.appendChild(el('button', {class:'pagination-btn' + (currentPage === 0 ? ' disabled' : ''), text:'\u2190 Prev', onclick: () => { if (currentPage > 0) goToPage(currentPage - 1); }}));
36
+
37
+ const pages = [];
38
+ for (let p = 0; p < totalPages; p++) {
39
+ if (p < 2 || p >= totalPages - 2 || Math.abs(p - currentPage) <= 1) {
40
+ pages.push(p);
41
+ } else if (pages.length > 0 && pages[pages.length - 1] !== -1) {
42
+ pages.push(-1);
43
+ }
44
+ }
45
+
46
+ pages.forEach(p => {
47
+ if (p === -1) {
48
+ container.appendChild(el('span', {style:'color:var(--text-dim);padding:4px 4px;font-size:12px', text:'\u2026'}));
49
+ } else {
50
+ container.appendChild(el('button', {class:'pagination-btn' + (p === currentPage ? ' active' : ''), text: ''+(p+1), onclick: () => goToPage(p)}));
51
+ }
52
+ });
53
+
54
+ container.appendChild(el('button', {class:'pagination-btn' + (currentPage >= totalPages-1 ? ' disabled' : ''), text:'Next \u2192', onclick: () => { if (currentPage < totalPages-1) goToPage(currentPage + 1); }}));
55
+ }
56
+
57
+ function paginateTable(tbody, paginationContainer, pageSize) {
58
+ const rows = Array.from(tbody.children);
59
+ let currentPage = 0;
60
+ const totalPages = Math.max(1, Math.ceil(rows.length / pageSize));
61
+
62
+ function renderPage() {
63
+ const start = currentPage * pageSize;
64
+ const end = start + pageSize;
65
+ rows.forEach((row, i) => { row.style.display = (i >= start && i < end) ? '' : 'none'; });
66
+
67
+ while (paginationContainer.firstChild) paginationContainer.removeChild(paginationContainer.firstChild);
68
+ if (rows.length <= pageSize) return;
69
+
70
+ const info = el('div', {class:'pagination-info', text: 'Showing ' + (start+1) + '-' + Math.min(end, rows.length) + ' of ' + rows.length});
71
+ paginationContainer.appendChild(info);
72
+
73
+ const btns = el('div', {class:'pagination-btns'});
74
+ renderPageButtons(btns, currentPage, totalPages, (p) => { currentPage = p; renderPage(); });
75
+ paginationContainer.appendChild(btns);
76
+ }
77
+
78
+ renderPage();
79
+ }
80
+
81
+ function paginateGroupedRows(tbody, paginationContainer, pageSize) {
82
+ const allRows = Array.from(tbody.children);
83
+ const groups = [];
84
+ for (let i = 0; i < allRows.length; i++) {
85
+ const row = allRows[i];
86
+ if (row.classList.contains('expander-row')) continue;
87
+ const group = [row];
88
+ if (i + 1 < allRows.length && allRows[i+1].classList.contains('expander-row')) {
89
+ group.push(allRows[i+1]);
90
+ }
91
+ groups.push(group);
92
+ }
93
+
94
+ let currentPage = 0;
95
+ const totalPages = Math.max(1, Math.ceil(groups.length / pageSize));
96
+
97
+ function renderPage() {
98
+ const start = currentPage * pageSize;
99
+ const end = start + pageSize;
100
+ groups.forEach((group, i) => {
101
+ const visible = i >= start && i < end;
102
+ group.forEach(row => {
103
+ if (row.classList.contains('expander-row')) {
104
+ row.dataset.paged = visible ? 'yes' : 'no';
105
+ if (!visible) row.style.display = 'none';
106
+ } else {
107
+ row.style.display = visible ? '' : 'none';
108
+ }
109
+ });
110
+ });
111
+
112
+ while (paginationContainer.firstChild) paginationContainer.removeChild(paginationContainer.firstChild);
113
+ if (groups.length <= pageSize) return;
114
+
115
+ const info = el('div', {class:'pagination-info', text: 'Showing ' + (start+1) + '-' + Math.min(end, groups.length) + ' of ' + groups.length + ' checks'});
116
+ paginationContainer.appendChild(info);
117
+
118
+ const btns = el('div', {class:'pagination-btns'});
119
+ renderPageButtons(btns, currentPage, totalPages, (p) => { currentPage = p; renderPage(); });
120
+ paginationContainer.appendChild(btns);
121
+ }
122
+
123
+ renderPage();
124
+ }
125
+
126
+ // =======================================================
127
+ // SORTABLE TABLE COLUMNS
128
+ // =======================================================
129
+
130
+ function makeSortable(table) {
131
+ const thead = table.querySelector('thead');
132
+ const tbody = table.querySelector('tbody');
133
+ if (!thead || !tbody) return;
134
+
135
+ const headers = Array.from(thead.querySelectorAll('th'));
136
+ let sortCol = -1;
137
+ let sortAsc = true;
138
+
139
+ headers.forEach((th, colIdx) => {
140
+ if (!th.textContent.trim()) return; // skip empty headers (arrow column)
141
+ th.style.cursor = 'pointer';
142
+ th.style.userSelect = 'none';
143
+ th.addEventListener('click', () => {
144
+ if (sortCol === colIdx) {
145
+ sortAsc = !sortAsc;
146
+ } else {
147
+ sortCol = colIdx;
148
+ sortAsc = true;
149
+ }
150
+
151
+ // Update sort indicators
152
+ headers.forEach(h => { h.dataset.sort = ''; });
153
+ th.dataset.sort = sortAsc ? 'asc' : 'desc';
154
+
155
+ // Collect data rows with their expander rows
156
+ const allRows = Array.from(tbody.children);
157
+ const groups = [];
158
+ for (let i = 0; i < allRows.length; i++) {
159
+ const row = allRows[i];
160
+ if (row.classList.contains('expander-row')) continue;
161
+ const group = [row];
162
+ if (i + 1 < allRows.length && allRows[i+1].classList.contains('expander-row')) {
163
+ group.push(allRows[i+1]);
164
+ }
165
+ groups.push(group);
166
+ }
167
+
168
+ groups.sort((a, b) => {
169
+ const aText = (a[0].children[colIdx]?.textContent || '').trim();
170
+ const bText = (b[0].children[colIdx]?.textContent || '').trim();
171
+ // Try numeric comparison
172
+ const aNum = parseFloat(aText);
173
+ const bNum = parseFloat(bText);
174
+ if (!isNaN(aNum) && !isNaN(bNum)) {
175
+ return sortAsc ? aNum - bNum : bNum - aNum;
176
+ }
177
+ // Date detection (contains / or -)
178
+ const aDate = Date.parse(aText);
179
+ const bDate = Date.parse(bText);
180
+ if (!isNaN(aDate) && !isNaN(bDate)) {
181
+ return sortAsc ? aDate - bDate : bDate - aDate;
182
+ }
183
+ // String comparison
184
+ return sortAsc ? aText.localeCompare(bText) : bText.localeCompare(aText);
185
+ });
186
+
187
+ // Reorder DOM — append each group (data row + optional expander)
188
+ groups.forEach(group => {
189
+ group.forEach(row => tbody.appendChild(row));
190
+ });
191
+
192
+ // Re-paginate if a pagination container exists after the table
193
+ const pagContainer = table.parentElement?.querySelector('.pagination');
194
+ if (pagContainer) {
195
+ const hasExpanders = groups.some(g => g.length > 1);
196
+ if (hasExpanders) {
197
+ paginateGroupedRows(tbody, pagContainer, 10);
198
+ } else {
199
+ paginateTable(tbody, pagContainer, 10);
200
+ }
201
+ }
202
+ });
203
+ });
204
+ }
205
+
206
+ // After all rendering: init sorting
207
+ setTimeout(() => {
208
+ document.querySelectorAll('.data-table.sortable').forEach(t => makeSortable(t));
209
+ }, 0);
210
+ `;
211
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Tool tab rendering — creates subtabs (Overview / Catalog / Recipes) under each tool tab.
3
+ * Returns JS code as a string.
4
+ */
5
+
6
+ export function dashboardToolTabsJs(): string {
7
+ return `
8
+ // =======================================================
9
+ // TOOL SUBTAB RENDERING
10
+ // =======================================================
11
+
12
+ /**
13
+ * Render a tool tab with subtabs: Overview | Catalog | Recipes
14
+ * @param panelId - e.g., 'panel-fitness'
15
+ * @param toolSessions - filtered sessions for this tool
16
+ * @param accentColor - CSS var for accent
17
+ * @param catalogLabel - e.g., 'Checks', 'Scenarios', 'Assessments'
18
+ * @param catalogData - check/scenario/assessment catalog entries (or empty)
19
+ * @param renderCatalogFn - function(container, data) to render the catalog
20
+ */
21
+ function renderToolTab(panelId, toolSessions, accentColor, catalogLabel, catalogData, renderCatalogFn) {
22
+ const panel = document.getElementById(panelId);
23
+
24
+ // Subtab bar
25
+ const subtabBar = el('div', {class:'subtab-bar'});
26
+ const tabs = [
27
+ { id: 'overview', label: 'Overview' },
28
+ { id: 'catalog', label: catalogLabel },
29
+ { id: 'recipes', label: 'Recipes' },
30
+ ];
31
+
32
+ const panels = {};
33
+ tabs.forEach((t, i) => {
34
+ const subtab = el('div', {
35
+ class: 'subtab' + (i === 0 ? ' active' : ''),
36
+ 'data-subtab': t.id,
37
+ text: t.label,
38
+ });
39
+ subtabBar.appendChild(subtab);
40
+
41
+ const subpanel = el('div', {
42
+ class: 'subtab-panel' + (i === 0 ? ' active' : ''),
43
+ id: panelId + '-' + t.id,
44
+ });
45
+ panels[t.id] = subpanel;
46
+ });
47
+
48
+ panel.appendChild(subtabBar);
49
+ tabs.forEach(t => panel.appendChild(panels[t.id]));
50
+
51
+ // Subtab switching
52
+ subtabBar.addEventListener('click', e => {
53
+ const tab = e.target.closest('.subtab');
54
+ if (!tab) return;
55
+ subtabBar.querySelectorAll('.subtab').forEach(t => t.classList.remove('active'));
56
+ tab.classList.add('active');
57
+ tabs.forEach(t => panels[t.id].classList.remove('active'));
58
+ panels[tab.dataset.subtab].classList.add('active');
59
+ });
60
+
61
+ // Render Overview subtab (sessions + detail)
62
+ renderSessionTable(panels['overview'], toolSessions, accentColor);
63
+
64
+ // Render Catalog subtab
65
+ if (catalogData && catalogData.length > 0) {
66
+ renderCatalogFn(panels['catalog'], catalogData);
67
+ } else {
68
+ panels['catalog'].appendChild(el('div', {class:'empty', text:'No ' + catalogLabel.toLowerCase() + ' available yet.'}));
69
+ }
70
+
71
+ // Render Recipes subtab
72
+ renderRecipesPanel(panels['recipes'], recipeCatalog);
73
+ }
74
+
75
+ // =======================================================
76
+ // RENDER ALL TOOL TABS
77
+ // =======================================================
78
+
79
+ function renderFitnessTab() {
80
+ renderToolTab(
81
+ 'panel-fitness',
82
+ fitSessions,
83
+ 'var(--accent-fitness)',
84
+ 'Checks',
85
+ checkCatalog,
86
+ function(container, data) { renderChecksCatalog(container, data); }
87
+ );
88
+ }
89
+
90
+ function renderSimulationTab() {
91
+ renderToolTab(
92
+ 'panel-simulation',
93
+ simSessions,
94
+ 'var(--accent-sim)',
95
+ 'Scenarios',
96
+ [], // No scenarios yet
97
+ function(container, data) {}
98
+ );
99
+ }
100
+
101
+ `;
102
+ }