karajan-code 3.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/docs/README.es.md +1 -1
- package/package.json +3 -2
- package/packages/hu-board/public/app.js +81 -4025
- package/packages/hu-board/public/index.html +20 -0
- package/packages/hu-board/public/utils/api.js +139 -0
- package/packages/hu-board/public/utils/board-view.js +453 -0
- package/packages/hu-board/public/utils/command-launcher.js +213 -0
- package/packages/hu-board/public/utils/config-editor.js +181 -0
- package/packages/hu-board/public/utils/dashboard-view.js +103 -0
- package/packages/hu-board/public/utils/formatters.js +232 -0
- package/packages/hu-board/public/utils/graph-view.js +216 -0
- package/packages/hu-board/public/utils/hu-actions.js +224 -0
- package/packages/hu-board/public/utils/init-listeners.js +180 -0
- package/packages/hu-board/public/utils/log-panel.js +222 -0
- package/packages/hu-board/public/utils/modals.js +290 -0
- package/packages/hu-board/public/utils/plan-rollup-view.js +53 -0
- package/packages/hu-board/public/utils/preflight-view.js +89 -0
- package/packages/hu-board/public/utils/project-actions.js +143 -0
- package/packages/hu-board/public/utils/project-picker-view.js +139 -0
- package/packages/hu-board/public/utils/run-launcher.js +169 -0
- package/packages/hu-board/public/utils/server-push.js +252 -0
- package/packages/hu-board/public/utils/sessions-view.js +199 -0
- package/packages/hu-board/public/utils/story-detail-view.js +324 -0
- package/packages/hu-board/public/utils/story-edit-form.js +271 -0
- package/packages/hu-board/src/config-yaml.js +14 -0
- package/packages/hu-board/src/db.js +15 -2
- package/packages/hu-board/src/routes/api.js +27 -9
- package/scripts/audit-test-diet.mjs +151 -0
- package/src/audit/harness-scorecard.js +52 -3
- package/src/audit/harness-section.js +6 -2
- package/src/checks/runner.js +10 -0
- package/src/cli/register-meta.js +15 -2
- package/src/cli/register-plan.js +10 -1
- package/src/commands/clean.js +62 -0
- package/src/commands/plan/generate.js +9 -3
- package/src/commands/sync.js +30 -2
- package/src/config/defaults.js +5 -0
- package/src/config/schema.js +2 -0
- package/src/orchestrator/config-init.js +1 -0
- package/src/orchestrator/drivers/iteration-phases/quality-gates.js +23 -0
- package/src/orchestrator/stages/coder-stage.js +8 -2
- package/src/orchestrator/stages/tdd-discipline-stage.js +56 -0
- package/src/orchestrator/stages/tool-judge-stage.js +53 -0
- package/src/prompts/tool-correctness-judge.js +31 -0
- package/src/review/git-stash-helper.js +104 -0
- package/src/review/tdd-discipline.js +90 -0
- package/src/roles/tool-correctness-judge-role.js +78 -0
- package/src/session/mutators.js +4 -0
- package/src/sync/apply-canvas-patch.js +55 -0
- package/src/utils/paths.js +12 -0
- package/src/utils/repo-cleaner.js +79 -0
- package/src/utils/tool-call-extractor.js +56 -0
- package/src/utils/vector-store-orphans.js +89 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// KJC-TSK-0501 step 8e/8 — Command launcher modal + log viewer + is-test helper.
|
|
2
|
+
//
|
|
3
|
+
// Classic script (no exports). Loaded by index.html before app.js so the
|
|
4
|
+
// top-level `function` declarations are hoisted into the shared realm —
|
|
5
|
+
// the inline event listener on the ⚡ header button (in app.js) and the
|
|
6
|
+
// is-test click handler resolve these names via that hoisting.
|
|
7
|
+
//
|
|
8
|
+
// Globals consumed from earlier scripts / app.js:
|
|
9
|
+
// - esc() (utils/formatters.js)
|
|
10
|
+
// - ensureDialog() (app.js)
|
|
11
|
+
// - showError() (utils/modals.js)
|
|
12
|
+
// - showConfirm() (utils/modals.js)
|
|
13
|
+
// - openGenericLogPanel() (utils/log-panel.js)
|
|
14
|
+
// - lastOpenedLog (mutable in app.js — assignment crosses
|
|
15
|
+
// scripts transparently)
|
|
16
|
+
|
|
17
|
+
// ---- Command launcher modal ----
|
|
18
|
+
//
|
|
19
|
+
// Opens a small form inside the existing <dialog> singleton with a
|
|
20
|
+
// command picker (plan / architect / clean) and the appropriate
|
|
21
|
+
// fields per command. Submit POSTs to /api/commands/:command, the
|
|
22
|
+
// server spawns a detached `kj` child, and we open the existing log
|
|
23
|
+
// viewer (re-uses openLogViewer's tail loop, just pointed at the
|
|
24
|
+
// new generic /api/runs/:commandId/log endpoint).
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async function showCommandLauncher() {
|
|
28
|
+
const dlg = ensureDialog();
|
|
29
|
+
// Per-command field schemas — kept in the client because each
|
|
30
|
+
// command has different inputs and the modal renders accordingly.
|
|
31
|
+
const SCHEMAS = {
|
|
32
|
+
plan: {
|
|
33
|
+
label: '📋 kj plan',
|
|
34
|
+
help: 'Generate an implementation plan with HUs. Paste the task text OR a file path.',
|
|
35
|
+
fields: [
|
|
36
|
+
{ name: 'taskFile', label: 'SPEC file path (absolute)', type: 'text', placeholder: '/home/me/project/SPEC.md' },
|
|
37
|
+
{ name: 'task', label: 'OR paste task text directly', type: 'textarea', rows: 6 },
|
|
38
|
+
{ name: 'context', label: 'Extra context (optional)', type: 'text' },
|
|
39
|
+
{ name: 'projectDir', label: 'Project directory (optional, defaults to board\'s cwd)', type: 'text' },
|
|
40
|
+
{ name: 'quick', label: '--quick (skip synth + reviewer)', type: 'checkbox' },
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
architect: {
|
|
44
|
+
label: '🏛 kj architect',
|
|
45
|
+
help: 'Design an architecture and persist ADRs. Same input as kj plan.',
|
|
46
|
+
fields: [
|
|
47
|
+
{ name: 'taskFile', label: 'SPEC file path (absolute)', type: 'text', placeholder: '/home/me/project/SPEC.md' },
|
|
48
|
+
{ name: 'task', label: 'OR paste task text directly', type: 'textarea', rows: 6 },
|
|
49
|
+
{ name: 'context', label: 'Extra context (optional)', type: 'text' },
|
|
50
|
+
{ name: 'projectDir', label: 'Project directory (optional)', type: 'text' },
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
clean: {
|
|
54
|
+
label: '🧹 kj clean',
|
|
55
|
+
help: 'Garbage-collect plans / sessions / batches. --nuke wipes everything including the board DB.',
|
|
56
|
+
fields: [
|
|
57
|
+
{ name: 'dryRun', label: '--dry-run (just report)', type: 'checkbox' },
|
|
58
|
+
{ name: 'nuke', label: '⚠ --nuke (delete everything, including this board\'s DB)', type: 'checkbox' },
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
let active = 'plan';
|
|
64
|
+
const renderForm = () => {
|
|
65
|
+
const s = SCHEMAS[active];
|
|
66
|
+
const fields = s.fields.map((f) => {
|
|
67
|
+
const id = `cmd-field-${f.name}`;
|
|
68
|
+
if (f.type === 'textarea') {
|
|
69
|
+
return `
|
|
70
|
+
<label style="display:flex;flex-direction:column;gap:4px;font-size:0.85rem">
|
|
71
|
+
<span style="color:var(--text-muted)">${esc(f.label)}</span>
|
|
72
|
+
<textarea id="${id}" rows="${f.rows || 4}" placeholder="${esc(f.placeholder || '')}"
|
|
73
|
+
style="padding:8px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text);border-radius:var(--radius-sm);font-family:inherit;font-size:0.9rem;line-height:1.45;resize:vertical"></textarea>
|
|
74
|
+
</label>`;
|
|
75
|
+
}
|
|
76
|
+
if (f.type === 'checkbox') {
|
|
77
|
+
return `
|
|
78
|
+
<label style="display:flex;align-items:center;gap:8px;font-size:0.85rem">
|
|
79
|
+
<input type="checkbox" id="${id}">
|
|
80
|
+
<span>${esc(f.label)}</span>
|
|
81
|
+
</label>`;
|
|
82
|
+
}
|
|
83
|
+
return `
|
|
84
|
+
<label style="display:flex;flex-direction:column;gap:4px;font-size:0.85rem">
|
|
85
|
+
<span style="color:var(--text-muted)">${esc(f.label)}</span>
|
|
86
|
+
<input type="text" id="${id}" placeholder="${esc(f.placeholder || '')}"
|
|
87
|
+
style="padding:8px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text);border-radius:var(--radius-sm);font-size:0.9rem">
|
|
88
|
+
</label>`;
|
|
89
|
+
}).join('');
|
|
90
|
+
|
|
91
|
+
dlg.innerHTML = `
|
|
92
|
+
<div style="padding:14px 18px;border-bottom:1px solid var(--border);font-weight:600;display:flex;align-items:center;justify-content:space-between;gap:10px">
|
|
93
|
+
<span>⚡ Run a Karajan command</span>
|
|
94
|
+
<button id="cmd-close" type="button" class="control-btn"
|
|
95
|
+
style="padding:4px 10px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text);border-radius:var(--radius-sm);cursor:pointer">
|
|
96
|
+
✕
|
|
97
|
+
</button>
|
|
98
|
+
</div>
|
|
99
|
+
<div style="padding:12px 18px;display:flex;gap:8px;border-bottom:1px solid var(--border)">
|
|
100
|
+
${Object.entries(SCHEMAS).map(([key, s]) => `
|
|
101
|
+
<button type="button" data-cmd="${key}"
|
|
102
|
+
style="padding:6px 12px;border:1px solid var(--border);
|
|
103
|
+
background:${key === active ? 'var(--color-green)' : 'var(--bg-primary)'};
|
|
104
|
+
color:${key === active ? '#fff' : 'var(--text)'};
|
|
105
|
+
border-radius:var(--radius-sm);cursor:pointer;font-size:0.85rem;font-weight:${key === active ? '600' : '400'}">
|
|
106
|
+
${esc(s.label)}
|
|
107
|
+
</button>
|
|
108
|
+
`).join('')}
|
|
109
|
+
</div>
|
|
110
|
+
<form id="cmd-form" onsubmit="return false"
|
|
111
|
+
style="padding:14px 18px;display:flex;flex-direction:column;gap:12px;width:min(640px, 88vw);box-sizing:border-box">
|
|
112
|
+
<div style="font-size:0.8rem;color:var(--text-muted)">${esc(SCHEMAS[active].help)}</div>
|
|
113
|
+
${fields}
|
|
114
|
+
<div style="display:flex;justify-content:flex-end;gap:8px;padding-top:8px;border-top:1px solid var(--border)">
|
|
115
|
+
<button type="button" id="cmd-cancel" class="control-btn"
|
|
116
|
+
style="padding:6px 14px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text);border-radius:var(--radius-sm);cursor:pointer">
|
|
117
|
+
Cancel
|
|
118
|
+
</button>
|
|
119
|
+
<button type="button" id="cmd-submit" class="control-btn"
|
|
120
|
+
style="padding:6px 14px;border:none;background:var(--color-green);color:#fff;border-radius:var(--radius-sm);cursor:pointer;font-weight:600">
|
|
121
|
+
Run
|
|
122
|
+
</button>
|
|
123
|
+
</div>
|
|
124
|
+
</form>
|
|
125
|
+
`;
|
|
126
|
+
|
|
127
|
+
dlg.querySelectorAll('[data-cmd]').forEach((btn) => {
|
|
128
|
+
btn.addEventListener('click', () => { active = btn.dataset.cmd; renderForm(); });
|
|
129
|
+
});
|
|
130
|
+
dlg.querySelector('#cmd-close').addEventListener('click', () => dlg.close());
|
|
131
|
+
dlg.querySelector('#cmd-cancel').addEventListener('click', () => dlg.close());
|
|
132
|
+
dlg.querySelector('#cmd-submit').addEventListener('click', () => submitCommand(active));
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
async function submitCommand(command) {
|
|
136
|
+
const s = SCHEMAS[command];
|
|
137
|
+
const body = {};
|
|
138
|
+
for (const f of s.fields) {
|
|
139
|
+
const el = document.getElementById(`cmd-field-${f.name}`);
|
|
140
|
+
if (!el) continue;
|
|
141
|
+
if (f.type === 'checkbox') body[f.name] = el.checked;
|
|
142
|
+
else {
|
|
143
|
+
const v = (el.value || '').trim();
|
|
144
|
+
if (v) body[f.name] = v;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// plan / architect: at least one of taskFile or task is required.
|
|
148
|
+
if ((command === 'plan' || command === 'architect') && !body.taskFile && !body.task) {
|
|
149
|
+
await showError('Provide either a SPEC file path or paste the task text.', { title: 'Missing input' });
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (command === 'clean' && body.nuke) {
|
|
153
|
+
const ok = await showConfirm(
|
|
154
|
+
'`kj clean --nuke` will delete EVERY plan / session / batch + wipe the HU Board DB. Cannot be undone.',
|
|
155
|
+
{ title: 'Confirm --nuke', okLabel: 'Wipe everything', destructive: true }
|
|
156
|
+
);
|
|
157
|
+
if (!ok) return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let res, payload;
|
|
161
|
+
try {
|
|
162
|
+
res = await fetch(`/api/commands/${encodeURIComponent(command)}`, {
|
|
163
|
+
method: 'POST',
|
|
164
|
+
headers: { 'Content-Type': 'application/json' },
|
|
165
|
+
body: JSON.stringify(body),
|
|
166
|
+
});
|
|
167
|
+
payload = await res.json().catch(() => ({}));
|
|
168
|
+
} catch (err) {
|
|
169
|
+
await showError(err.message, { title: 'Could not launch command' });
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (!res.ok) {
|
|
173
|
+
await showError(payload.error || `HTTP ${res.status}`, { title: 'Command rejected' });
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
dlg.close();
|
|
178
|
+
if (payload.commandId) openCommandLogViewer(command, payload.commandId);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
renderForm();
|
|
182
|
+
dlg.showModal();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Open the run-log panel pointed at a /api/runs/<commandId>/log
|
|
187
|
+
* tail. Reuses the same panel UI as openLogViewer() for plans —
|
|
188
|
+
* just swaps the URL it polls.
|
|
189
|
+
*/
|
|
190
|
+
function openCommandLogViewer(commandLabel, commandId) {
|
|
191
|
+
const args = {
|
|
192
|
+
id: commandId,
|
|
193
|
+
label: `kj ${commandLabel}`,
|
|
194
|
+
tailUrl: (offset) => `/api/runs/${encodeURIComponent(commandId)}/log?offset=${offset}`,
|
|
195
|
+
};
|
|
196
|
+
// Remember it so the section header's 📜 View log button can
|
|
197
|
+
// re-open this same log after the user closes the panel.
|
|
198
|
+
lastOpenedLog = args;
|
|
199
|
+
openGenericLogPanel(args);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// is_test toggle (KJC-TSK-0371 — board polish #3). Three-state cycle:
|
|
203
|
+
// null (heuristic) → 1 (always test) → 0 (always keep) → null …
|
|
204
|
+
// `null` is encoded as the empty string in `data-is-test` so the
|
|
205
|
+
// button's HTML serialisation is stable across renders.
|
|
206
|
+
function nextIsTestValue(current) {
|
|
207
|
+
// Treat both empty string ("") and "null" as "no override" — the
|
|
208
|
+
// dataset value is the empty string at render time, but URLs and
|
|
209
|
+
// some legacy callers may send the literal "null".
|
|
210
|
+
if (current === '' || current === 'null') return 1;
|
|
211
|
+
if (current === '1') return 0;
|
|
212
|
+
return null; // from "0" → revert to default
|
|
213
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// KJC-TSK-0501 step 8d/8 — Config editor modal (kj.config.yml).
|
|
2
|
+
//
|
|
3
|
+
// Classic script (no exports). Loaded by index.html before app.js so the
|
|
4
|
+
// top-level `function` declaration is hoisted into the shared realm and
|
|
5
|
+
// the inline `onclick` on the ⚙ header button (and `handleRoute`)
|
|
6
|
+
// resolve `showConfigEditor` via that hoisting.
|
|
7
|
+
//
|
|
8
|
+
// Globals consumed from earlier scripts / app.js:
|
|
9
|
+
// - esc() (app.js)
|
|
10
|
+
// - ensureDialog() (app.js)
|
|
11
|
+
// - showError() (utils/modals.js)
|
|
12
|
+
|
|
13
|
+
// PR5: kj.config.yml editor modal
|
|
14
|
+
//
|
|
15
|
+
// Goal: a non-technical user opens the modal, picks "Sonnet siempre"
|
|
16
|
+
// from a dropdown, hits "Guardar", and the next `kj run` is cheap.
|
|
17
|
+
// No yml editing, no terminal trip.
|
|
18
|
+
//
|
|
19
|
+
// Each EDITABLE_FIELDS entry the backend exposes becomes one form
|
|
20
|
+
// row. `select` → <select>, `boolean` → <input type=checkbox>,
|
|
21
|
+
// `number` → <input type=number min/max>. Help text under each
|
|
22
|
+
// label as a small grey paragraph.
|
|
23
|
+
async function showConfigEditor(scope = 'global') {
|
|
24
|
+
// v2.30.0 PR4 — scope toggle. 'global' edita ~/.karajan/kj.config.yml
|
|
25
|
+
// (afecta a todos los proyectos); 'project' edita
|
|
26
|
+
// <projectDir>/.karajan/kj.config.yml (sólo el repo actual). El backend
|
|
27
|
+
// resuelve <projectDir> desde KJ_PROJECT_DIR || cwd.
|
|
28
|
+
let cfg;
|
|
29
|
+
try {
|
|
30
|
+
const r = await fetch(`/api/config?scope=${encodeURIComponent(scope)}`);
|
|
31
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
32
|
+
cfg = await r.json();
|
|
33
|
+
} catch (err) {
|
|
34
|
+
await showError(`No se pudo leer la configuración: ${err.message}`, { title: 'Configuración' });
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const dlg = document.getElementById('app-dialog') || ensureDialog();
|
|
38
|
+
const renderField = (f) => {
|
|
39
|
+
const id = `cfg-field-${f.key}`;
|
|
40
|
+
let input = '';
|
|
41
|
+
if (f.type === 'select') {
|
|
42
|
+
input = `<select id="${id}" data-key="${esc(f.key)}" style="width:100%;padding:6px 8px;background:var(--bg-primary);border:1px solid var(--border);color:var(--text);border-radius:var(--radius-sm);">
|
|
43
|
+
${f.options.map((o) => `<option value="${esc(o)}"${o === f.value ? ' selected' : ''}>${esc(o)}</option>`).join('')}
|
|
44
|
+
</select>`;
|
|
45
|
+
} else if (f.type === 'boolean') {
|
|
46
|
+
input = `<label style="display:flex;align-items:center;gap:8px;cursor:pointer;">
|
|
47
|
+
<input type="checkbox" id="${id}" data-key="${esc(f.key)}"${f.value ? ' checked' : ''} style="cursor:pointer;">
|
|
48
|
+
<span style="font-size:0.85rem;color:var(--text-muted);">${f.value ? 'activado' : 'desactivado'}</span>
|
|
49
|
+
</label>`;
|
|
50
|
+
} else if (f.type === 'number') {
|
|
51
|
+
// KJC-BUG-0069 — honour the schema's `step` (e.g. 'any' for floats like
|
|
52
|
+
// rag.search.alpha=0.6). HTML5 number inputs default to step=1, which
|
|
53
|
+
// marks any fractional value as stepMismatch and refuses to commit.
|
|
54
|
+
input = `<input type="number" id="${id}" data-key="${esc(f.key)}" value="${esc(String(f.value))}"${f.min != null ? ` min="${f.min}"` : ''}${f.max != null ? ` max="${f.max}"` : ''}${f.step != null ? ` step="${esc(String(f.step))}"` : ''} style="width:120px;padding:6px 8px;background:var(--bg-primary);border:1px solid var(--border);color:var(--text);border-radius:var(--radius-sm);">`;
|
|
55
|
+
}
|
|
56
|
+
return `
|
|
57
|
+
<div style="margin-bottom:14px;">
|
|
58
|
+
<label for="${id}" style="display:block;font-weight:600;color:var(--text);font-size:0.88rem;margin-bottom:4px;">${esc(f.label)}</label>
|
|
59
|
+
${input}
|
|
60
|
+
${f.help ? `<p style="margin:4px 0 0;font-size:0.75rem;color:var(--text-muted);">${esc(f.help)}</p>` : ''}
|
|
61
|
+
</div>
|
|
62
|
+
`;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// v2.30.0 — agrupa los campos por `f.category`. El backend declara
|
|
66
|
+
// las categorías ordenadas en `cfg.categories`; cualquier campo sin
|
|
67
|
+
// categoría cae a "Otros" (defensivo, no se pierde si olvidamos
|
|
68
|
+
// etiquetar uno nuevo). Mantiene el orden interno del whitelist
|
|
69
|
+
// dentro de cada sección.
|
|
70
|
+
const cats = Array.isArray(cfg.categories) && cfg.categories.length > 0
|
|
71
|
+
? cfg.categories
|
|
72
|
+
: [{ id: 'misc', label: 'Otros', icon: '⚙', order: 999 }];
|
|
73
|
+
const byCat = new Map(cats.map((c) => [c.id, []]));
|
|
74
|
+
byCat.set('misc', byCat.get('misc') || []);
|
|
75
|
+
for (const f of cfg.fields) {
|
|
76
|
+
const id = byCat.has(f.category) ? f.category : 'misc';
|
|
77
|
+
if (!byCat.has(id)) byCat.set(id, []);
|
|
78
|
+
byCat.get(id).push(f);
|
|
79
|
+
}
|
|
80
|
+
const renderSection = (cat) => {
|
|
81
|
+
const fields = byCat.get(cat.id) || [];
|
|
82
|
+
if (fields.length === 0) return '';
|
|
83
|
+
return `
|
|
84
|
+
<section style="margin-bottom:22px;">
|
|
85
|
+
<h3 style="display:flex;align-items:center;gap:8px;margin:0 0 12px;padding-bottom:6px;border-bottom:1px solid var(--border);font-size:0.95rem;font-weight:700;color:var(--text);">
|
|
86
|
+
<span aria-hidden="true">${esc(cat.icon || '⚙')}</span>
|
|
87
|
+
<span>${esc(cat.label)}</span>
|
|
88
|
+
</h3>
|
|
89
|
+
${fields.map(renderField).join('')}
|
|
90
|
+
</section>
|
|
91
|
+
`;
|
|
92
|
+
};
|
|
93
|
+
const allCats = [...cats];
|
|
94
|
+
if ((byCat.get('misc') || []).length > 0 && !allCats.some((c) => c.id === 'misc')) {
|
|
95
|
+
allCats.push({ id: 'misc', label: 'Otros', icon: '⚙', order: 999 });
|
|
96
|
+
}
|
|
97
|
+
const sectionsHtml = allCats.map(renderSection).join('');
|
|
98
|
+
|
|
99
|
+
// PR4 — toggle global / proyecto. Cambiarlo recarga el modal contra el
|
|
100
|
+
// otro fichero. Visual: dos pills mutuamente excluyentes.
|
|
101
|
+
const pill = (id, value, label, active) => `
|
|
102
|
+
<button data-scope="${value}" id="${id}" type="button"
|
|
103
|
+
style="padding:4px 10px;border-radius:999px;border:1px solid var(--border);
|
|
104
|
+
background:${active ? 'var(--color-blue)' : 'var(--bg-primary)'};
|
|
105
|
+
color:${active ? '#fff' : 'var(--text)'};font-size:0.75rem;cursor:pointer;">
|
|
106
|
+
${esc(label)}
|
|
107
|
+
</button>`;
|
|
108
|
+
const scopeToggle = `
|
|
109
|
+
<div style="display:flex;align-items:center;gap:4px;margin-left:auto;" role="radiogroup" aria-label="Ámbito de configuración">
|
|
110
|
+
${pill('cfg-scope-global', 'global', 'Global', scope === 'global')}
|
|
111
|
+
${pill('cfg-scope-project', 'project', 'Este proyecto', scope === 'project')}
|
|
112
|
+
</div>`;
|
|
113
|
+
dlg.innerHTML = `
|
|
114
|
+
<div style="padding:14px 18px;border-bottom:1px solid var(--border);font-weight:700;display:flex;align-items:center;gap:10px;">
|
|
115
|
+
<span style="font-size:1.2rem;">⚙</span>
|
|
116
|
+
<span>Configuración de Karajan</span>
|
|
117
|
+
${scopeToggle}
|
|
118
|
+
</div>
|
|
119
|
+
<div style="padding:6px 18px 0;font-family:var(--font-mono,monospace);font-size:0.7rem;color:var(--text-muted);">
|
|
120
|
+
${esc(cfg.path || '')}${cfg.exists === false ? ' · (aún no existe, se creará al guardar)' : ''}
|
|
121
|
+
</div>
|
|
122
|
+
<div style="padding:14px 18px;max-height:65vh;overflow:auto;">
|
|
123
|
+
${sectionsHtml}
|
|
124
|
+
<p style="margin:14px 0 0;font-size:0.75rem;color:var(--text-muted);">
|
|
125
|
+
Los cambios se guardan en <code>kj.config.yml</code> de forma atómica (con copia de seguridad <code>.bak</code>).
|
|
126
|
+
El próximo <code>kj run</code> los aplicará — no hace falta reiniciar el board.
|
|
127
|
+
</p>
|
|
128
|
+
</div>
|
|
129
|
+
<div style="padding:12px 18px;border-top:1px solid var(--border);display:flex;gap:8px;justify-content:flex-end;">
|
|
130
|
+
<button id="config-cancel" style="padding:8px 16px;background:var(--bg-primary);border:1px solid var(--border);color:var(--text);border-radius:var(--radius-sm);cursor:pointer;">Cancelar</button>
|
|
131
|
+
<button id="config-save" style="padding:8px 16px;background:var(--color-green);border:none;color:#fff;border-radius:var(--radius-sm);cursor:pointer;font-weight:600;">Guardar</button>
|
|
132
|
+
</div>
|
|
133
|
+
`;
|
|
134
|
+
if (typeof dlg.showModal === 'function' && !dlg.open) dlg.showModal();
|
|
135
|
+
// PR4 — al cambiar de pill, recargar el modal con el otro scope.
|
|
136
|
+
for (const btn of dlg.querySelectorAll('[data-scope]')) {
|
|
137
|
+
btn.addEventListener('click', () => {
|
|
138
|
+
const next = btn.getAttribute('data-scope');
|
|
139
|
+
if (next === scope) return;
|
|
140
|
+
try { dlg.close(); } catch { /* ignore */ }
|
|
141
|
+
showConfigEditor(next);
|
|
142
|
+
}, { once: true });
|
|
143
|
+
}
|
|
144
|
+
dlg.querySelector('#config-cancel')?.addEventListener('click', () => { try { dlg.close(); } catch { /* ignore */ } }, { once: true });
|
|
145
|
+
dlg.querySelector('#config-save')?.addEventListener('click', async () => {
|
|
146
|
+
// Build the patch from the form, comparing against initial values
|
|
147
|
+
// so we only send what changed.
|
|
148
|
+
const patch = {};
|
|
149
|
+
for (const f of cfg.fields) {
|
|
150
|
+
const el = dlg.querySelector(`[data-key="${f.key}"]`);
|
|
151
|
+
if (!el) continue;
|
|
152
|
+
let v;
|
|
153
|
+
if (f.type === 'boolean') v = el.checked;
|
|
154
|
+
else if (f.type === 'number') v = Number(el.value);
|
|
155
|
+
else v = el.value;
|
|
156
|
+
if (v !== f.value) patch[f.key] = v;
|
|
157
|
+
}
|
|
158
|
+
if (Object.keys(patch).length === 0) {
|
|
159
|
+
try { dlg.close(); } catch { /* ignore */ }
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
const r = await fetch('/api/config', {
|
|
164
|
+
method: 'PUT',
|
|
165
|
+
headers: { 'Content-Type': 'application/json' },
|
|
166
|
+
body: JSON.stringify({ patch, scope }),
|
|
167
|
+
});
|
|
168
|
+
if (!r.ok) {
|
|
169
|
+
const body = await r.json().catch(() => ({}));
|
|
170
|
+
const msg = body?.details?.errors?.length
|
|
171
|
+
? body.details.errors.join('\n')
|
|
172
|
+
: (body.error || `HTTP ${r.status}`);
|
|
173
|
+
await showError(msg, { title: 'No se pudo guardar' });
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
try { dlg.close(); } catch { /* ignore */ }
|
|
177
|
+
} catch (err) {
|
|
178
|
+
await showError(err.message || String(err), { title: 'Error guardando configuración' });
|
|
179
|
+
}
|
|
180
|
+
}, { once: true });
|
|
181
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// KJC-TSK-0501 step 6/8 — Dashboard view (global stats + project cards).
|
|
2
|
+
//
|
|
3
|
+
// Classic script (no exports). Loaded by index.html after utils/api.js
|
|
4
|
+
// and the other utility scripts, before app.js. Function declarations
|
|
5
|
+
// hoist to the script-level lexical environment so app.js (and the
|
|
6
|
+
// `data-view="dashboard"` nav handler) can call renderDashboard()
|
|
7
|
+
// transparently.
|
|
8
|
+
//
|
|
9
|
+
// Globals consumed from earlier scripts / app.js:
|
|
10
|
+
// - api() (utils/api.js)
|
|
11
|
+
// - esc() (app.js)
|
|
12
|
+
// - timeAgo() (utils/formatters.js)
|
|
13
|
+
// - renderEmptyState() (app.js)
|
|
14
|
+
// - isTestTitle() / isTestIcon()(app.js)
|
|
15
|
+
// - selectProject() (app.js)
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Renders the dashboard view with global stats and project cards.
|
|
19
|
+
*/
|
|
20
|
+
async function renderDashboard() {
|
|
21
|
+
const app = document.getElementById('app');
|
|
22
|
+
app.innerHTML = '<div class="loading"><div class="loading__spinner"></div><p>Loading dashboard...</p></div>';
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const [stats, projects] = await Promise.all([
|
|
26
|
+
api('/api/dashboard'),
|
|
27
|
+
api('/api/projects'),
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
const certPct = stats.total_stories > 0
|
|
31
|
+
? Math.round((stats.certified_stories / stats.total_stories) * 100)
|
|
32
|
+
: 0;
|
|
33
|
+
|
|
34
|
+
app.innerHTML = `
|
|
35
|
+
<div class="stats-grid">
|
|
36
|
+
<div class="stat-card">
|
|
37
|
+
<div class="stat-card__value">${stats.total_stories}</div>
|
|
38
|
+
<div class="stat-card__label">Total Stories</div>
|
|
39
|
+
</div>
|
|
40
|
+
<div class="stat-card">
|
|
41
|
+
<div class="stat-card__value stat-card__value--green">${stats.certified_stories} (${certPct}%)</div>
|
|
42
|
+
<div class="stat-card__label">Certified</div>
|
|
43
|
+
</div>
|
|
44
|
+
<div class="stat-card">
|
|
45
|
+
<div class="stat-card__value stat-card__value--yellow">${stats.pending_stories}</div>
|
|
46
|
+
<div class="stat-card__label">Pending</div>
|
|
47
|
+
</div>
|
|
48
|
+
<div class="stat-card">
|
|
49
|
+
<div class="stat-card__value stat-card__value--purple">${stats.avg_quality !== null ? stats.avg_quality + '/60' : '--'}</div>
|
|
50
|
+
<div class="stat-card__label">Avg Quality</div>
|
|
51
|
+
</div>
|
|
52
|
+
<div class="stat-card">
|
|
53
|
+
<div class="stat-card__value">${stats.total_sessions}</div>
|
|
54
|
+
<div class="stat-card__label">Sessions</div>
|
|
55
|
+
</div>
|
|
56
|
+
<div class="stat-card">
|
|
57
|
+
<div class="stat-card__value stat-card__value--green">${stats.approved_sessions}</div>
|
|
58
|
+
<div class="stat-card__label">Approved</div>
|
|
59
|
+
</div>
|
|
60
|
+
<div class="stat-card">
|
|
61
|
+
<div class="stat-card__value stat-card__value--purple">${stats.total_projects}</div>
|
|
62
|
+
<div class="stat-card__label">Projects</div>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<div class="section-header">
|
|
67
|
+
<span class="section-header__title">Projects</span>
|
|
68
|
+
<span class="section-header__count">${projects.length} projects</span>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
${projects.length === 0 ? renderEmptyState() : `
|
|
72
|
+
<div class="projects-grid">
|
|
73
|
+
${projects.map((p) => `
|
|
74
|
+
<div class="project-card">
|
|
75
|
+
<button class="project-card__delete" title="Delete project (cascade)" data-project-id="${esc(p.id)}" data-project-name="${esc(p.name || p.id)}">🗑️</button>
|
|
76
|
+
<button class="project-card__is-test" title="${esc(isTestTitle(p))}" data-project-id="${esc(p.id)}" data-is-test="${p.is_test === null || p.is_test === undefined ? '' : p.is_test}">${isTestIcon(p)}</button>
|
|
77
|
+
<div class="project-card__body" onclick="selectProject('${esc(p.id)}')">
|
|
78
|
+
<div class="project-card__name">${p.is_shared === 1 ? '<span class="project-card__shared-badge" title="Team-shared via .karajan-shared/">🔗</span> ' : ''}${esc(p.name || p.id)}</div>
|
|
79
|
+
<div class="project-card__stats">
|
|
80
|
+
<div class="project-card__stat">
|
|
81
|
+
<div class="project-card__stat-value">${p.story_count || 0}</div>
|
|
82
|
+
<div class="project-card__stat-label">Stories</div>
|
|
83
|
+
</div>
|
|
84
|
+
<div class="project-card__stat">
|
|
85
|
+
<div class="project-card__stat-value">${p.certified_count || 0}</div>
|
|
86
|
+
<div class="project-card__stat-label">Certified</div>
|
|
87
|
+
</div>
|
|
88
|
+
<div class="project-card__stat">
|
|
89
|
+
<div class="project-card__stat-value">${p.session_count || 0}</div>
|
|
90
|
+
<div class="project-card__stat-label">Sessions</div>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
<div class="project-card__activity">Last activity: ${timeAgo(p.last_activity)}</div>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
`).join('')}
|
|
97
|
+
</div>
|
|
98
|
+
`}
|
|
99
|
+
`;
|
|
100
|
+
} catch (err) {
|
|
101
|
+
app.innerHTML = `<div class="empty-state"><div class="empty-state__title">Error loading dashboard</div><div class="empty-state__text">${esc(err.message)}</div></div>`;
|
|
102
|
+
}
|
|
103
|
+
}
|