trackops 2.0.6 → 2.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 +295 -701
- package/bin/trackops.js +24 -16
- package/lib/config.js +265 -58
- package/lib/control.js +830 -292
- package/lib/init.js +46 -16
- package/lib/opera-bootstrap.js +85 -45
- package/lib/opera-phase-dod.js +485 -0
- package/lib/opera.js +8 -5
- package/lib/plans.js +1329 -0
- package/lib/quality-assert.js +49 -0
- package/lib/quality.js +1759 -0
- package/lib/release.js +18 -11
- package/lib/server.js +504 -192
- package/locales/en.json +249 -15
- package/locales/es.json +249 -15
- package/package.json +6 -5
- package/scripts/quality-unit-tests.js +130 -0
- package/scripts/smoke-tests.js +357 -57
- package/skills/trackops/skill.json +29 -29
- package/templates/skills/opera-quality-guard/SKILL.md +26 -0
- package/templates/skills/opera-quality-guard/locales/en/SKILL.md +26 -0
- package/templates/skills/opera-skill/SKILL.md +8 -0
- package/templates/skills/opera-skill/locales/en/SKILL.md +8 -0
- package/ui/js/api.js +93 -26
- package/ui/js/app.js +13 -7
- package/ui/js/filters.js +49 -29
- package/ui/js/time-tracker.js +41 -28
- package/ui/js/views/board.js +22 -14
- package/ui/js/views/dashboard.js +206 -49
- package/ui/js/views/execution.js +7 -3
- package/ui/js/views/plans.js +284 -0
- package/ui/js/views/scrum.js +25 -13
- package/ui/js/views/sidebar.js +9 -8
- package/ui/js/views/tasks.js +238 -134
|
@@ -1,29 +1,29 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "trackops",
|
|
3
|
-
"shortDescription": "Global TrackOps skill that explains TrackOps, requires explicit runtime install, and guides per-repository activation.",
|
|
4
|
-
"description": "Explains what TrackOps does, installs the global skill layer, requires explicit runtime installation with npm, supports Spanish and English, activates TrackOps repository by repository, and routes OPERA onboarding into either direct bootstrap or agent-led discovery.",
|
|
5
|
-
"skillVersion": "2.0
|
|
6
|
-
"trackopsVersion": "2.0
|
|
7
|
-
"npmPackage": "trackops",
|
|
8
|
-
"bootstrapPolicy": "explicit_install",
|
|
9
|
-
"supportedAgentsV1": [
|
|
10
|
-
"antigravity",
|
|
11
|
-
"claude-code",
|
|
12
|
-
"codex",
|
|
13
|
-
"cursor",
|
|
14
|
-
"gemini-cli",
|
|
15
|
-
"github-copilot",
|
|
16
|
-
"kiro-cli"
|
|
17
|
-
],
|
|
18
|
-
"distribution": {
|
|
19
|
-
"source": "Baxahaun/trackops",
|
|
20
|
-
"skill": "trackops",
|
|
21
|
-
"fullDepth": true
|
|
22
|
-
},
|
|
23
|
-
"repository": {
|
|
24
|
-
"provider": "github",
|
|
25
|
-
"owner": "Baxahaun",
|
|
26
|
-
"repo": "trackops",
|
|
27
|
-
"skillPath": "skills/trackops"
|
|
28
|
-
}
|
|
29
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "trackops",
|
|
3
|
+
"shortDescription": "Global TrackOps skill that explains TrackOps, requires explicit runtime install, and guides per-repository activation.",
|
|
4
|
+
"description": "Explains what TrackOps does, installs the global skill layer, requires explicit runtime installation with npm, supports Spanish and English, activates TrackOps repository by repository, and routes OPERA onboarding into either direct bootstrap or agent-led discovery.",
|
|
5
|
+
"skillVersion": "2.1.0",
|
|
6
|
+
"trackopsVersion": "2.1.0",
|
|
7
|
+
"npmPackage": "trackops",
|
|
8
|
+
"bootstrapPolicy": "explicit_install",
|
|
9
|
+
"supportedAgentsV1": [
|
|
10
|
+
"antigravity",
|
|
11
|
+
"claude-code",
|
|
12
|
+
"codex",
|
|
13
|
+
"cursor",
|
|
14
|
+
"gemini-cli",
|
|
15
|
+
"github-copilot",
|
|
16
|
+
"kiro-cli"
|
|
17
|
+
],
|
|
18
|
+
"distribution": {
|
|
19
|
+
"source": "Baxahaun/trackops",
|
|
20
|
+
"skill": "trackops",
|
|
21
|
+
"fullDepth": true
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"provider": "github",
|
|
25
|
+
"owner": "Baxahaun",
|
|
26
|
+
"repo": "trackops",
|
|
27
|
+
"skillPath": "skills/trackops"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: "opera-quality-guard"
|
|
3
|
+
description: "Guardia local de calidad para proyectos OPERA. Obliga al agente a consultar estado de calidad, ejecutar verificaciones declaradas y comprobar readiness antes de release o recomendacion de despliegue."
|
|
4
|
+
metadata:
|
|
5
|
+
version: "1.0"
|
|
6
|
+
type: "project"
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# OPERA Quality Guard
|
|
10
|
+
|
|
11
|
+
## Mision
|
|
12
|
+
|
|
13
|
+
Complementar OPERA con una capa de calidad continua y readiness de salida.
|
|
14
|
+
|
|
15
|
+
## Reglas
|
|
16
|
+
|
|
17
|
+
- antes de cerrar una fase, ejecuta `trackops quality status`
|
|
18
|
+
- tras cambios relevantes en codigo, entorno, build o smoke, ejecuta `trackops quality verify`
|
|
19
|
+
- antes de `trackops release`, ejecuta `trackops quality release-readiness`
|
|
20
|
+
- antes de recomendar produccion, ejecuta `trackops quality promote-readiness --target production`
|
|
21
|
+
- si la readiness esta bloqueada, no inventes excepciones: explica el bloqueo real y los pasos para resolverlo
|
|
22
|
+
- solo usa waivers si existe una aprobacion humana explicita y con caducidad
|
|
23
|
+
|
|
24
|
+
## Prioridad
|
|
25
|
+
|
|
26
|
+
La calidad complementa OPERA; no sustituye backlog, contrato ni politica.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: "opera-quality-guard"
|
|
3
|
+
description: "Local quality guard for OPERA projects. Forces the agent to inspect quality status, run declared verification, and check readiness before release or deployment recommendations."
|
|
4
|
+
metadata:
|
|
5
|
+
version: "1.0"
|
|
6
|
+
type: "project"
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# OPERA Quality Guard
|
|
10
|
+
|
|
11
|
+
## Mission
|
|
12
|
+
|
|
13
|
+
Complement OPERA with continuous quality and production-readiness checks.
|
|
14
|
+
|
|
15
|
+
## Rules
|
|
16
|
+
|
|
17
|
+
- before closing a phase, run `trackops quality status`
|
|
18
|
+
- after relevant code, environment, build, or smoke changes, run `trackops quality verify`
|
|
19
|
+
- before `trackops release`, run `trackops quality release-readiness`
|
|
20
|
+
- before recommending production, run `trackops quality promote-readiness --target production`
|
|
21
|
+
- if readiness is blocked, report the real blocker and the concrete remediation steps
|
|
22
|
+
- only use waivers when there is explicit human approval and an expiry
|
|
23
|
+
|
|
24
|
+
## Priority
|
|
25
|
+
|
|
26
|
+
Quality complements OPERA; it does not replace backlog, contract, or policy.
|
|
@@ -51,6 +51,12 @@ Antes de asumir nada, ejecuta:
|
|
|
51
51
|
trackops status
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
+
Y antes de cerrar una fase, release o recomendacion de despliegue, ejecuta:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
trackops quality status
|
|
58
|
+
```
|
|
59
|
+
|
|
54
60
|
- si `trackops status` falla, no sigas
|
|
55
61
|
- si no existe TrackOps activo, redirige a la skill global `trackops`
|
|
56
62
|
- si OPERA no esta instalado, indica `trackops opera install`
|
|
@@ -202,6 +208,8 @@ Formalizar release, despliegue y automatizacion operativa con seguridad.
|
|
|
202
208
|
- `trackops status`
|
|
203
209
|
- `trackops workspace status`
|
|
204
210
|
- `trackops env status`
|
|
211
|
+
- `trackops quality release-readiness`
|
|
212
|
+
- `trackops quality promote-readiness --target production`
|
|
205
213
|
- `trackops sync`
|
|
206
214
|
|
|
207
215
|
### Skills delegables
|
|
@@ -51,6 +51,12 @@ Before assuming anything, run:
|
|
|
51
51
|
trackops status
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
+
And before closing a phase, a release, or a deployment recommendation, run:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
trackops quality status
|
|
58
|
+
```
|
|
59
|
+
|
|
54
60
|
- if `trackops status` fails, stop
|
|
55
61
|
- if there is no active TrackOps, redirect to the global `trackops` skill
|
|
56
62
|
- if OPERA is not installed, indicate `trackops opera install`
|
|
@@ -202,6 +208,8 @@ Formalize release, deployment, and operational automation with safety.
|
|
|
202
208
|
- `trackops status`
|
|
203
209
|
- `trackops workspace status`
|
|
204
210
|
- `trackops env status`
|
|
211
|
+
- `trackops quality release-readiness`
|
|
212
|
+
- `trackops quality promote-readiness --target production`
|
|
205
213
|
- `trackops sync`
|
|
206
214
|
|
|
207
215
|
### Delegable skills
|
package/ui/js/api.js
CHANGED
|
@@ -142,12 +142,12 @@ export async function createTask(payload) {
|
|
|
142
142
|
* @param {string} taskId
|
|
143
143
|
* @param {Object} payload
|
|
144
144
|
*/
|
|
145
|
-
export async function updateTask(taskId, payload) {
|
|
146
|
-
return call(`/api/tasks/${encodeURIComponent(taskId)}`, {
|
|
147
|
-
method: 'PUT',
|
|
148
|
-
body: JSON.stringify({ projectId: state.get('currentProjectId'), ...payload }),
|
|
149
|
-
});
|
|
150
|
-
}
|
|
145
|
+
export async function updateTask(taskId, payload, meta = {}) {
|
|
146
|
+
return call(`/api/tasks/${encodeURIComponent(taskId)}`, {
|
|
147
|
+
method: 'PUT',
|
|
148
|
+
body: JSON.stringify({ projectId: state.get('currentProjectId'), ...payload, ...meta }),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
151
|
|
|
152
152
|
/**
|
|
153
153
|
* Ejecuta una acción sobre una tarea (start, review, complete, block, pending, cancel)
|
|
@@ -155,14 +155,81 @@ export async function updateTask(taskId, payload) {
|
|
|
155
155
|
* @param {string} action
|
|
156
156
|
* @param {string} [note]
|
|
157
157
|
*/
|
|
158
|
-
export async function taskAction(taskId, action, note = '') {
|
|
159
|
-
return call(`/api/tasks/${encodeURIComponent(taskId)}/action`, {
|
|
160
|
-
method: 'POST',
|
|
161
|
-
body: JSON.stringify({ projectId: state.get('currentProjectId'), action, note }),
|
|
162
|
-
});
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// ───────────────────────────────
|
|
158
|
+
export async function taskAction(taskId, action, note = '', meta = {}) {
|
|
159
|
+
return call(`/api/tasks/${encodeURIComponent(taskId)}/action`, {
|
|
160
|
+
method: 'POST',
|
|
161
|
+
body: JSON.stringify({ projectId: state.get('currentProjectId'), action, note, ...meta }),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ─────────────────────────────── PLANS ──────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
export async function getPlans() {
|
|
168
|
+
return call('/api/plans');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function getPlan(sourceId) {
|
|
172
|
+
return call(`/api/plans/${encodeURIComponent(sourceId)}`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function scanPlans(scanPath = '') {
|
|
176
|
+
return call('/api/plans/scan', {
|
|
177
|
+
method: 'POST',
|
|
178
|
+
body: JSON.stringify({ projectId: state.get('currentProjectId'), path: scanPath || undefined }),
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function importPlan(payload) {
|
|
183
|
+
return call('/api/plans/import', {
|
|
184
|
+
method: 'POST',
|
|
185
|
+
body: JSON.stringify({ projectId: state.get('currentProjectId'), ...payload }),
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function applyPlan(sourceId, payload = {}) {
|
|
190
|
+
return call(`/api/plans/${encodeURIComponent(sourceId)}/apply`, {
|
|
191
|
+
method: 'POST',
|
|
192
|
+
body: JSON.stringify({ projectId: state.get('currentProjectId'), ...payload }),
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export async function unlinkPlan(sourceId, payload = {}) {
|
|
197
|
+
return call(`/api/plans/${encodeURIComponent(sourceId)}/unlink`, {
|
|
198
|
+
method: 'POST',
|
|
199
|
+
body: JSON.stringify({ projectId: state.get('currentProjectId'), ...payload }),
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ─────────────────────────────── QUALITY ────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
export async function getQuality() {
|
|
206
|
+
return call('/api/quality');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function verifyQuality(payload = {}) {
|
|
210
|
+
return call('/api/quality/verify', {
|
|
211
|
+
method: 'POST',
|
|
212
|
+
body: JSON.stringify({ projectId: state.get('currentProjectId'), ...payload }),
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function getPhaseReadiness(phase = 'current') {
|
|
217
|
+
return call(`/api/quality/phase-readiness?phase=${encodeURIComponent(phase)}`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export async function getReleaseReadiness() {
|
|
221
|
+
return call('/api/quality/release-readiness');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export async function getPromotionReadiness(target = 'production') {
|
|
225
|
+
return call(`/api/quality/promotion-readiness?target=${encodeURIComponent(target)}`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function getQualityWaivers() {
|
|
229
|
+
return call('/api/quality/waivers');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ─────────────────────────────── SYNC ───────────────────────────────────────
|
|
166
233
|
|
|
167
234
|
/**
|
|
168
235
|
* Sincroniza los docs del proyecto (task_plan.md, progress.md, findings.md)
|
|
@@ -180,12 +247,12 @@ export async function syncDocs() {
|
|
|
180
247
|
* Ejecuta un comando en el shell del proyecto
|
|
181
248
|
* @param {string} command
|
|
182
249
|
*/
|
|
183
|
-
export async function runCommand(command) {
|
|
184
|
-
return call('/api/commands', {
|
|
185
|
-
method: 'POST',
|
|
186
|
-
body: JSON.stringify({ projectId: state.get('currentProjectId'), command }),
|
|
187
|
-
});
|
|
188
|
-
}
|
|
250
|
+
export async function runCommand(command, meta = {}) {
|
|
251
|
+
return call('/api/commands', {
|
|
252
|
+
method: 'POST',
|
|
253
|
+
body: JSON.stringify({ projectId: state.get('currentProjectId'), command, ...meta }),
|
|
254
|
+
});
|
|
255
|
+
}
|
|
189
256
|
|
|
190
257
|
/**
|
|
191
258
|
* Crea un EventSource para hacer streaming de salida de una sesión
|
|
@@ -203,12 +270,12 @@ export function streamSession(sessionId) {
|
|
|
203
270
|
* @param {string} taskId
|
|
204
271
|
* @param {string} taskTitle
|
|
205
272
|
*/
|
|
206
|
-
export async function startTimeEntry(taskId, taskTitle) {
|
|
207
|
-
return call('/api/time/start', {
|
|
208
|
-
method: 'POST',
|
|
209
|
-
body: JSON.stringify({ projectId: state.get('currentProjectId'), taskId, taskTitle }),
|
|
210
|
-
});
|
|
211
|
-
}
|
|
273
|
+
export async function startTimeEntry(taskId, taskTitle, meta = {}) {
|
|
274
|
+
return call('/api/time/start', {
|
|
275
|
+
method: 'POST',
|
|
276
|
+
body: JSON.stringify({ projectId: state.get('currentProjectId'), taskId, taskTitle, ...meta }),
|
|
277
|
+
});
|
|
278
|
+
}
|
|
212
279
|
|
|
213
280
|
/**
|
|
214
281
|
* Detiene el time entry activo
|
package/ui/js/app.js
CHANGED
|
@@ -15,8 +15,9 @@ import * as keyboard from './keyboard.js';
|
|
|
15
15
|
// Vistas
|
|
16
16
|
import { render as renderSidebar } from './views/sidebar.js';
|
|
17
17
|
import { render as renderTopbar } from './views/topbar.js';
|
|
18
|
-
import * as dashboardView from './views/dashboard.js';
|
|
19
|
-
import * as
|
|
18
|
+
import * as dashboardView from './views/dashboard.js';
|
|
19
|
+
import * as plansView from './views/plans.js';
|
|
20
|
+
import * as tasksView from './views/tasks.js';
|
|
20
21
|
import * as executionView from './views/execution.js';
|
|
21
22
|
import * as projectsView from './views/projects.js';
|
|
22
23
|
import { render as renderTimeline } from './views/timeline.js';
|
|
@@ -37,11 +38,16 @@ async function init() {
|
|
|
37
38
|
setTimeout(() => dashboardView.bindEvents?.(), 50);
|
|
38
39
|
return html;
|
|
39
40
|
});
|
|
40
|
-
router.register('tasks', async () => {
|
|
41
|
-
const html = await tasksView.render();
|
|
42
|
-
setTimeout(() => tasksView.bindEvents?.(), 50);
|
|
43
|
-
return html;
|
|
44
|
-
});
|
|
41
|
+
router.register('tasks', async () => {
|
|
42
|
+
const html = await tasksView.render();
|
|
43
|
+
setTimeout(() => tasksView.bindEvents?.(), 50);
|
|
44
|
+
return html;
|
|
45
|
+
});
|
|
46
|
+
router.register('plans', async () => {
|
|
47
|
+
const html = await plansView.render();
|
|
48
|
+
setTimeout(() => plansView.bindEvents?.(), 50);
|
|
49
|
+
return html;
|
|
50
|
+
});
|
|
45
51
|
router.register('terminal', async () => {
|
|
46
52
|
const html = await executionView.render();
|
|
47
53
|
setTimeout(() => executionView.bindEvents(), 50);
|
package/ui/js/filters.js
CHANGED
|
@@ -11,7 +11,7 @@ import { t } from './i18n.js';
|
|
|
11
11
|
const STORAGE_PREFIX = 'ops-filters-';
|
|
12
12
|
|
|
13
13
|
/** Claves de filtro soportadas */
|
|
14
|
-
const FILTER_KEYS = ['status', 'priority', 'phase', 'stream', 'search'];
|
|
14
|
+
const FILTER_KEYS = ['status', 'priority', 'phase', 'stream', 'sourceId', 'rootId', 'search'];
|
|
15
15
|
|
|
16
16
|
// ─────────────────────────────── PERSISTENCE ─────────────────────────────────
|
|
17
17
|
|
|
@@ -67,15 +67,17 @@ export function apply(tasks, filters) {
|
|
|
67
67
|
return tasks.filter(task => {
|
|
68
68
|
if (filters.status && task.status !== filters.status) return false;
|
|
69
69
|
if (filters.priority && task.priority !== filters.priority) return false;
|
|
70
|
-
if (filters.phase && task.phase !== filters.phase) return false;
|
|
71
|
-
if (filters.stream && task.stream !== filters.stream) return false;
|
|
72
|
-
if (filters.
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
70
|
+
if (filters.phase && task.phase !== filters.phase) return false;
|
|
71
|
+
if (filters.stream && task.stream !== filters.stream) return false;
|
|
72
|
+
if (filters.sourceId && (task.sourceId || task.origin?.sourceId || '') !== filters.sourceId) return false;
|
|
73
|
+
if (filters.rootId && (task.rootId || task.id) !== filters.rootId) return false;
|
|
74
|
+
if (filters.search) {
|
|
75
|
+
const q = filters.search.toLowerCase();
|
|
76
|
+
const haystack = `${task.title} ${task.id} ${task.summary || ''} ${task.stream || ''} ${task.sourceId || task.origin?.sourceId || ''} ${task.rootTitle || ''}`.toLowerCase();
|
|
77
|
+
if (!haystack.includes(q)) return false;
|
|
78
|
+
}
|
|
79
|
+
return true;
|
|
80
|
+
});
|
|
79
81
|
}
|
|
80
82
|
|
|
81
83
|
// ─────────────────────────────── CONTEO ──────────────────────────────────────
|
|
@@ -99,16 +101,20 @@ export function count(filters) {
|
|
|
99
101
|
* @param {Object} options — Opciones de renderizado
|
|
100
102
|
* @param {Array} [options.statuses] — Estados disponibles
|
|
101
103
|
* @param {Array} [options.priorities] — Prioridades disponibles
|
|
102
|
-
* @param {Array} [options.phases] — Fases disponibles
|
|
103
|
-
* @param {Array} [options.streams] — Streams disponibles
|
|
104
|
-
* @
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const
|
|
110
|
-
const
|
|
111
|
-
const
|
|
104
|
+
* @param {Array} [options.phases] — Fases disponibles
|
|
105
|
+
* @param {Array} [options.streams] — Streams disponibles
|
|
106
|
+
* @param {Array} [options.sources] — Source ids disponibles
|
|
107
|
+
* @param {Array} [options.roots] — Root tasks disponibles
|
|
108
|
+
* @returns {string} HTML
|
|
109
|
+
*/
|
|
110
|
+
export function renderBar(viewId, filters, options = {}) {
|
|
111
|
+
const statusLabels = state.getStatusLabels?.() || {};
|
|
112
|
+
const phases = options.phases || state.getPhases?.() || [];
|
|
113
|
+
const statuses = options.statuses || ['pending', 'in_progress', 'in_review', 'blocked', 'completed', 'cancelled'];
|
|
114
|
+
const priorities = options.priorities || ['P0', 'P1', 'P2', 'P3'];
|
|
115
|
+
const streams = options.streams || [];
|
|
116
|
+
const sources = options.sources || [];
|
|
117
|
+
const roots = options.roots || [];
|
|
112
118
|
|
|
113
119
|
return `
|
|
114
120
|
<div class="filter-bar" role="search" aria-label="${t('ui.filters.label', {}, 'Filter tasks')}">
|
|
@@ -125,9 +131,17 @@ export function renderBar(viewId, filters, options = {}) {
|
|
|
125
131
|
value: p.id, label: `${p.id} · ${p.label}`
|
|
126
132
|
})), t('ui.filters.phase', {}, 'Phase')) : ''}
|
|
127
133
|
|
|
128
|
-
${streams.length ? _renderSelect('stream', filters.stream, streams.map(s => ({
|
|
129
|
-
value: s, label: s
|
|
130
|
-
})), t('ui.filters.stream', {}, 'Stream')) : ''}
|
|
134
|
+
${streams.length ? _renderSelect('stream', filters.stream, streams.map(s => ({
|
|
135
|
+
value: s, label: s
|
|
136
|
+
})), t('ui.filters.stream', {}, 'Stream')) : ''}
|
|
137
|
+
|
|
138
|
+
${sources.length ? _renderSelect('sourceId', filters.sourceId, sources.map(source => ({
|
|
139
|
+
value: source, label: source
|
|
140
|
+
})), t('ui.filters.source', {}, 'Source')) : ''}
|
|
141
|
+
|
|
142
|
+
${roots.length ? _renderSelect('rootId', filters.rootId, roots.map(root => ({
|
|
143
|
+
value: root.id, label: root.title
|
|
144
|
+
})), t('ui.filters.root', {}, 'Root')) : ''}
|
|
131
145
|
|
|
132
146
|
<div class="filter-search">
|
|
133
147
|
<input
|
|
@@ -177,12 +191,18 @@ function _renderActiveChips(filters, statusLabels, phases) {
|
|
|
177
191
|
const phase = phases.find(p => p.id === filters.phase);
|
|
178
192
|
chips.push({ key: 'phase', label: phase ? `${phase.id} · ${phase.label}` : filters.phase });
|
|
179
193
|
}
|
|
180
|
-
if (filters.stream) {
|
|
181
|
-
chips.push({ key: 'stream', label: filters.stream });
|
|
182
|
-
}
|
|
183
|
-
if (filters.
|
|
184
|
-
chips.push({ key: '
|
|
185
|
-
}
|
|
194
|
+
if (filters.stream) {
|
|
195
|
+
chips.push({ key: 'stream', label: filters.stream });
|
|
196
|
+
}
|
|
197
|
+
if (filters.sourceId) {
|
|
198
|
+
chips.push({ key: 'sourceId', label: filters.sourceId });
|
|
199
|
+
}
|
|
200
|
+
if (filters.rootId) {
|
|
201
|
+
chips.push({ key: 'rootId', label: filters.rootId });
|
|
202
|
+
}
|
|
203
|
+
if (filters.search) {
|
|
204
|
+
chips.push({ key: 'search', label: `"${filters.search}"` });
|
|
205
|
+
}
|
|
186
206
|
|
|
187
207
|
return chips.map(c => `
|
|
188
208
|
<span class="chip chip-active" data-filter-remove="${c.key}">
|
package/ui/js/time-tracker.js
CHANGED
|
@@ -18,32 +18,40 @@ let _startMs = null;
|
|
|
18
18
|
* @param {string} taskId
|
|
19
19
|
* @param {string} taskTitle
|
|
20
20
|
*/
|
|
21
|
-
export async function start(taskId, taskTitle) {
|
|
22
|
-
// Si hay uno en curso, detenerlo primero
|
|
23
|
-
if (state.get('activeEntry')) {
|
|
24
|
-
await stop();
|
|
25
|
-
}
|
|
21
|
+
export async function start(taskId, taskTitle) {
|
|
22
|
+
// Si hay uno en curso, detenerlo primero
|
|
23
|
+
if (state.get('activeEntry')) {
|
|
24
|
+
await stop();
|
|
25
|
+
}
|
|
26
26
|
|
|
27
|
-
try {
|
|
28
|
-
const result = await api.startTimeEntry(taskId, taskTitle
|
|
27
|
+
try {
|
|
28
|
+
const result = await api.startTimeEntry(taskId, taskTitle, {
|
|
29
|
+
actor: 'user',
|
|
30
|
+
source: 'time_tracker',
|
|
31
|
+
});
|
|
29
32
|
const entry = {
|
|
30
33
|
id: result.entry?.id || `local-${Date.now()}`,
|
|
31
34
|
taskId,
|
|
32
35
|
taskTitle,
|
|
33
36
|
startedAt: result.entry?.startedAt || new Date().toISOString(),
|
|
34
37
|
};
|
|
35
|
-
state.update('activeEntry', entry);
|
|
36
|
-
_startMs = Date.now() - (result.entry?.elapsedMs || 0);
|
|
37
|
-
_startInterval();
|
|
38
|
-
_updateTopbarTimer();
|
|
39
|
-
flash(`Timer iniciado: ${taskTitle}`, 'success');
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
38
|
+
state.update('activeEntry', entry);
|
|
39
|
+
_startMs = Date.now() - (result.entry?.elapsedMs || 0);
|
|
40
|
+
_startInterval();
|
|
41
|
+
_updateTopbarTimer();
|
|
42
|
+
flash(`Timer iniciado: ${taskTitle}`, 'success');
|
|
43
|
+
window.dispatchEvent(new CustomEvent('ops:refresh'));
|
|
44
|
+
} catch (err) {
|
|
45
|
+
if (!_shouldUseLocalFallback(err)) {
|
|
46
|
+
flash(err.message || 'No se pudo iniciar el timer.', 'error');
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
// Fallback local si el backend no tiene el endpoint todavía
|
|
50
|
+
const entry = {
|
|
51
|
+
id: `local-${Date.now()}`,
|
|
52
|
+
taskId,
|
|
53
|
+
taskTitle,
|
|
54
|
+
startedAt: new Date().toISOString(),
|
|
47
55
|
};
|
|
48
56
|
state.update('activeEntry', entry);
|
|
49
57
|
_startMs = Date.now();
|
|
@@ -72,9 +80,9 @@ export function resume() {
|
|
|
72
80
|
/**
|
|
73
81
|
* Detiene el timer y persiste en el backend
|
|
74
82
|
*/
|
|
75
|
-
export async function stop() {
|
|
76
|
-
const entry = state.get('activeEntry');
|
|
77
|
-
if (!entry) return;
|
|
83
|
+
export async function stop() {
|
|
84
|
+
const entry = state.get('activeEntry');
|
|
85
|
+
if (!entry) return;
|
|
78
86
|
|
|
79
87
|
_stopInterval();
|
|
80
88
|
const elapsed = _startMs ? Date.now() - _startMs : 0;
|
|
@@ -99,10 +107,11 @@ export async function stop() {
|
|
|
99
107
|
state.update('timeEntries', timeEntries.slice(0, 50));
|
|
100
108
|
}
|
|
101
109
|
|
|
102
|
-
state.update('activeEntry', null);
|
|
103
|
-
_startMs = null;
|
|
104
|
-
_updateTopbarTimer();
|
|
105
|
-
|
|
110
|
+
state.update('activeEntry', null);
|
|
111
|
+
_startMs = null;
|
|
112
|
+
_updateTopbarTimer();
|
|
113
|
+
window.dispatchEvent(new CustomEvent('ops:refresh'));
|
|
114
|
+
}
|
|
106
115
|
|
|
107
116
|
/**
|
|
108
117
|
* Obtiene el tiempo transcurrido actual en ms
|
|
@@ -144,13 +153,17 @@ function _startInterval() {
|
|
|
144
153
|
}, 1000);
|
|
145
154
|
}
|
|
146
155
|
|
|
147
|
-
function _stopInterval() {
|
|
156
|
+
function _stopInterval() {
|
|
148
157
|
if (_interval) {
|
|
149
158
|
clearInterval(_interval);
|
|
150
159
|
_interval = null;
|
|
151
160
|
}
|
|
152
161
|
state.update('timerInterval', null);
|
|
153
|
-
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function _shouldUseLocalFallback(err) {
|
|
165
|
+
return err?.status === 404 || err?.status === 405 || err?.status === 501;
|
|
166
|
+
}
|
|
154
167
|
|
|
155
168
|
function _updateTimerDisplays() {
|
|
156
169
|
const elapsed = getElapsed();
|
package/ui/js/views/board.js
CHANGED
|
@@ -145,15 +145,20 @@ function _renderCard(task, phases) {
|
|
|
145
145
|
<strong class="task-card-title">${esc(task.title)}</strong>
|
|
146
146
|
<span class="task-card-id">${esc(task.id)}</span>
|
|
147
147
|
<p class="task-card-summary">${esc(task.summary || t('ui.board.noDescription', {}, 'No description.'))}</p>
|
|
148
|
-
<div class="task-card-meta">
|
|
149
|
-
<span class="badge badge-${priorityVariant[task.priority] || 'muted'}">${esc(task.priority)}</span>
|
|
150
|
-
<span class="badge badge-muted">${esc(phaseInfo?.label || task.phase)}</span>
|
|
151
|
-
${task.stream ? `<span class="badge badge-muted">${esc(task.stream)}</span>` : ''}
|
|
152
|
-
${task.
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
}
|
|
148
|
+
<div class="task-card-meta">
|
|
149
|
+
<span class="badge badge-${priorityVariant[task.priority] || 'muted'}">${esc(task.priority)}</span>
|
|
150
|
+
<span class="badge badge-muted">${esc(phaseInfo?.label || task.phase)}</span>
|
|
151
|
+
${task.stream ? `<span class="badge badge-muted">${esc(task.stream)}</span>` : ''}
|
|
152
|
+
${task.executionOwner ? `<span class="badge badge-muted">${esc(`exec:${task.executionOwner}`)}</span>` : ''}
|
|
153
|
+
${task.sourceId ? `<span class="badge badge-muted">${esc(`plan:${task.sourceId}`)}</span>` : ''}
|
|
154
|
+
${task.rootId && task.rootId !== task.id ? `<span class="badge badge-muted">${esc(`root:${task.rootTitle}`)}</span>` : ''}
|
|
155
|
+
${task.awaitingUserConfirmation ? `<span class="badge badge-warning">${t('ui.tasks.awaitingUser', {}, 'Awaiting user')}</span>` : ''}
|
|
156
|
+
${task.verificationPending ? `<span class="badge badge-accent">${t('ui.tasks.verifyPending', {}, 'Verify state')}</span>` : ''}
|
|
157
|
+
${task.blocker ? `<span class="badge badge-danger" title="${esc(task.blocker)}">${icon('alertTriangle', 10)} ${t('status.blocked', {}, 'Blocked')}</span>` : ''}
|
|
158
|
+
</div>
|
|
159
|
+
</article>
|
|
160
|
+
`;
|
|
161
|
+
}
|
|
157
162
|
|
|
158
163
|
function _bindDragDrop(board) {
|
|
159
164
|
board.addEventListener('dragstart', e => {
|
|
@@ -214,11 +219,14 @@ function _bindDragDrop(board) {
|
|
|
214
219
|
const action = statusToAction[newStatus];
|
|
215
220
|
if (!action) return;
|
|
216
221
|
|
|
217
|
-
try {
|
|
218
|
-
await api.taskAction(taskId, action, t('ui.board.movedFromBoard', { status: newStatus }, `Moved to ${newStatus} from the board.`)
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
+
try {
|
|
223
|
+
await api.taskAction(taskId, action, t('ui.board.movedFromBoard', { status: newStatus }, `Moved to ${newStatus} from the board.`), {
|
|
224
|
+
actor: 'user',
|
|
225
|
+
source: 'board',
|
|
226
|
+
});
|
|
227
|
+
flash(t('ui.board.movedSuccess', { status: newStatus }, `Task moved to ${newStatus}.`), 'success');
|
|
228
|
+
window.dispatchEvent(new CustomEvent('ops:refresh'));
|
|
229
|
+
} catch (err) {
|
|
222
230
|
flash(err.message, 'error');
|
|
223
231
|
}
|
|
224
232
|
});
|