trackops 1.0.0 → 1.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 +341 -232
- package/bin/trackops.js +102 -70
- package/lib/config.js +260 -35
- package/lib/control.js +518 -475
- package/lib/env.js +227 -0
- package/lib/i18n.js +61 -53
- package/lib/init.js +146 -55
- package/lib/locale.js +63 -0
- package/lib/opera-bootstrap.js +523 -0
- package/lib/opera.js +319 -170
- package/lib/registry.js +27 -13
- package/lib/release.js +56 -0
- package/lib/resources.js +42 -0
- package/lib/server.js +912 -418
- package/lib/skills.js +148 -124
- package/lib/workspace.js +260 -0
- package/locales/en.json +331 -139
- package/locales/es.json +331 -139
- package/package.json +14 -3
- package/scripts/skills-marketplace-smoke.js +124 -0
- package/scripts/smoke-tests.js +445 -0
- package/scripts/sync-skill-version.js +21 -0
- package/scripts/validate-skill.js +88 -0
- package/skills/trackops/SKILL.md +64 -0
- package/skills/trackops/agents/openai.yaml +3 -0
- package/skills/trackops/references/activation.md +39 -0
- package/skills/trackops/references/troubleshooting.md +34 -0
- package/skills/trackops/references/workflow.md +20 -0
- package/skills/trackops/scripts/bootstrap-trackops.js +201 -0
- package/skills/trackops/skill.json +29 -0
- package/templates/etapa/agent.md +2 -2
- package/templates/etapa/references/etapa-cycle.md +1 -1
- package/templates/opera/agent.md +1 -1
- package/templates/opera/en/agent.md +26 -0
- package/templates/opera/en/genesis.md +79 -0
- package/templates/opera/en/references/autonomy-and-recovery.md +23 -0
- package/templates/opera/en/references/opera-cycle.md +62 -0
- package/templates/opera/en/registry.md +28 -0
- package/templates/opera/en/router.md +39 -0
- package/templates/opera/genesis.md +79 -94
- package/templates/skills/changelog-updater/locales/en/SKILL.md +11 -0
- package/templates/skills/commiter/locales/en/SKILL.md +11 -0
- package/templates/skills/project-starter-skill/SKILL.md +5 -3
- package/templates/skills/project-starter-skill/locales/en/SKILL.md +24 -0
- package/ui/css/base.css +266 -0
- package/ui/css/charts.css +327 -0
- package/ui/css/components.css +570 -0
- package/ui/css/panels.css +956 -0
- package/ui/css/tokens.css +227 -0
- package/ui/favicon.svg +5 -0
- package/ui/index.html +91 -351
- package/ui/js/api.js +220 -0
- package/ui/js/app.js +200 -0
- package/ui/js/console-logger.js +172 -0
- package/ui/js/i18n.js +14 -0
- package/ui/js/icons.js +104 -0
- package/ui/js/onboarding.js +439 -0
- package/ui/js/router.js +125 -0
- package/ui/js/state.js +130 -0
- package/ui/js/theme.js +100 -0
- package/ui/js/time-tracker.js +248 -0
- package/ui/js/utils.js +175 -0
- package/ui/js/views/board.js +255 -0
- package/ui/js/views/execution.js +256 -0
- package/ui/js/views/flash.js +47 -0
- package/ui/js/views/insights.js +340 -0
- package/ui/js/views/overview.js +365 -0
- package/ui/js/views/settings.js +381 -0
- package/ui/js/views/sidebar.js +131 -0
- package/ui/js/views/skills.js +163 -0
- package/ui/js/views/tasks.js +406 -0
- package/ui/js/views/topbar.js +239 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import * as api from '../api.js';
|
|
2
|
+
import * as flash from './flash.js';
|
|
3
|
+
import { t } from '../i18n.js';
|
|
4
|
+
|
|
5
|
+
export function init() {
|
|
6
|
+
// Sin estado local init por ahora
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function render() {
|
|
10
|
+
return `
|
|
11
|
+
<div class="view-enter">
|
|
12
|
+
<header class="section-header">
|
|
13
|
+
<div>
|
|
14
|
+
<h2 class="section-title">${t('ui.skills.title', {}, 'Skill hub')}</h2>
|
|
15
|
+
<p class="section-desc">${t('ui.skills.desc', {}, 'Manage your copilot skills and install new community workflows.')}</p>
|
|
16
|
+
</div>
|
|
17
|
+
<div class="actions">
|
|
18
|
+
<button class="btn btn-ghost btn-sm" id="btn-refresh-skills">
|
|
19
|
+
<svg viewBox="0 0 24 24" fill="none" class="icon" style="width:16px;height:16px"><path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
20
|
+
${t('ui.skills.refresh', {}, 'Refresh')}
|
|
21
|
+
</button>
|
|
22
|
+
</div>
|
|
23
|
+
</header>
|
|
24
|
+
|
|
25
|
+
<div class="grid-split" style="grid-template-columns: 1fr 1fr; gap: var(--space-6);">
|
|
26
|
+
|
|
27
|
+
<!-- Panel Izquierdo: Capacidades Locales -->
|
|
28
|
+
<div class="panel">
|
|
29
|
+
<div class="panel-header">
|
|
30
|
+
<h3 class="panel-title">${t('ui.skills.installed', {}, 'Installed capabilities')}</h3>
|
|
31
|
+
</div>
|
|
32
|
+
<div id="skills-local-list" class="stack" style="gap: var(--space-4); max-height: calc(100vh - 200px); overflow-y: auto; padding: 2px;">
|
|
33
|
+
<div class="card"><div class="card-body"><p class="text-sm color-muted">${t('ui.skills.loading', {}, 'Loading...')}</p></div></div>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<!-- Panel Derecho: Discover (skills.sh) -->
|
|
38
|
+
<div class="panel" style="border-color: var(--accent);">
|
|
39
|
+
<div class="panel-header" style="justify-content: space-between;">
|
|
40
|
+
<h3 class="panel-title" style="color: var(--accent);">✨ ${t('ui.skills.discover', {}, 'Discover')}</h3>
|
|
41
|
+
<span class="badge badge-success">${t('ui.skills.recommended', {}, 'Recommended')}</span>
|
|
42
|
+
</div>
|
|
43
|
+
<div id="skills-discover-list" class="stack" style="gap: var(--space-4); max-height: calc(100vh - 200px); overflow-y: auto; padding: 2px;">
|
|
44
|
+
<div class="card"><div class="card-body"><p class="text-sm color-muted">${t('ui.skills.connecting', {}, 'Connecting...')}</p></div></div>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function bindEvents() {
|
|
54
|
+
const btn = document.getElementById('btn-refresh-skills');
|
|
55
|
+
if (btn) {
|
|
56
|
+
btn.addEventListener('click', loadData);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function loadData() {
|
|
61
|
+
const localEl = document.getElementById('skills-local-list');
|
|
62
|
+
const discoverEl = document.getElementById('skills-discover-list');
|
|
63
|
+
|
|
64
|
+
if (!localEl || !discoverEl) return;
|
|
65
|
+
|
|
66
|
+
localEl.innerHTML = `<p class="text-sm text-muted">${t('ui.skills.loadingLocal', {}, 'Loading local data...')}</p>`;
|
|
67
|
+
discoverEl.innerHTML = `<p class="text-sm text-muted">${t('ui.skills.connectingRemote', {}, 'Connecting to skills.sh...')}</p>`;
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const [localRes, discoverRes] = await Promise.all([
|
|
71
|
+
api.fetchSkillsLocal(),
|
|
72
|
+
api.fetchSkillsDiscover()
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
// DOM Locales
|
|
76
|
+
if (localRes.ok && localRes.skills) {
|
|
77
|
+
if (localRes.skills.length === 0) {
|
|
78
|
+
localEl.innerHTML = `
|
|
79
|
+
<div class="empty-state">
|
|
80
|
+
<div class="empty-icon">🧠</div>
|
|
81
|
+
<p class="empty-title">Sin habilidades locales</p>
|
|
82
|
+
<p class="empty-desc" style="font-size:var(--text-xs); color:var(--text-muted)">Tu agente no tiene skills especializadas. Instala alguna desde el panel derecho.</p>
|
|
83
|
+
</div>
|
|
84
|
+
`;
|
|
85
|
+
} else {
|
|
86
|
+
localEl.innerHTML = localRes.skills.map(s => `
|
|
87
|
+
<div class="card">
|
|
88
|
+
<div class="card-body">
|
|
89
|
+
<div style="display: flex; gap: 12px; align-items: center;">
|
|
90
|
+
<div style="font-size: 24px;">⚙️</div>
|
|
91
|
+
<div>
|
|
92
|
+
<h4 style="font-size: var(--text-sm); font-weight: 600; margin-bottom: 4px;">${s.title}</h4>
|
|
93
|
+
<p style="font-size: var(--text-xs); color: var(--text-secondary);">${s.description || 'Sin descripción'}</p>
|
|
94
|
+
<code style="font-size: 10px; margin-top: 8px; display: inline-block; background: var(--surface-3); padding: 2px 4px; border-radius: 4px;">${s.path}</code>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
`).join('');
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// DOM Discover
|
|
104
|
+
if (discoverRes.ok && discoverRes.catalog) {
|
|
105
|
+
// Filter out already installed
|
|
106
|
+
const installedIds = (localRes.skills || []).map(s => s.id);
|
|
107
|
+
const available = discoverRes.catalog.filter(c => !installedIds.includes(c.id));
|
|
108
|
+
|
|
109
|
+
if (available.length === 0) {
|
|
110
|
+
discoverEl.innerHTML = `
|
|
111
|
+
<div class="empty-state">
|
|
112
|
+
<div class="empty-icon">✨</div>
|
|
113
|
+
<p class="empty-title" style="margin-bottom:var(--space-2)">Todo instalado</p>
|
|
114
|
+
<p class="empty-desc" style="font-size:var(--text-xs); color:var(--text-muted)">Ya tienes instaladas todas las habilidades recomendadas.</p>
|
|
115
|
+
</div>
|
|
116
|
+
`;
|
|
117
|
+
} else {
|
|
118
|
+
discoverEl.innerHTML = available.map(s => `
|
|
119
|
+
<div class="card" style="border: 1px dashed var(--border-accent);">
|
|
120
|
+
<div class="card-body">
|
|
121
|
+
<div style="display: flex; justify-content: space-between; align-items: flex-start; gap: 12px;">
|
|
122
|
+
<div style="flex:1;">
|
|
123
|
+
<h4 style="font-size: var(--text-sm); font-weight: 600; color: var(--text-primary); margin-bottom: 4px;">${s.title}</h4>
|
|
124
|
+
<p style="font-size: var(--text-xs); color: var(--text-secondary);">${s.description}</p>
|
|
125
|
+
</div>
|
|
126
|
+
<button class="btn btn-primary btn-sm btn-install-skill" data-id="${s.id}" style="padding: 4px 12px; font-size: 11px;">Instalar</button>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
`).join('');
|
|
131
|
+
|
|
132
|
+
// Bind events
|
|
133
|
+
discoverEl.querySelectorAll('.btn-install-skill').forEach(btn => {
|
|
134
|
+
btn.addEventListener('click', async (e) => {
|
|
135
|
+
const id = e.target.dataset.id;
|
|
136
|
+
const originalText = e.target.textContent;
|
|
137
|
+
e.target.textContent = 'Instalando...';
|
|
138
|
+
e.target.disabled = true;
|
|
139
|
+
|
|
140
|
+
const res = await api.installSkill(id).catch(err => {
|
|
141
|
+
flash.show('error', err.message);
|
|
142
|
+
return null;
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
if (res && res.ok) {
|
|
146
|
+
flash.show('success', `Skill ${id} instalada correctamente en /.agents/skills`);
|
|
147
|
+
loadData(); // recargar
|
|
148
|
+
} else {
|
|
149
|
+
e.target.textContent = originalText;
|
|
150
|
+
e.target.disabled = false;
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
} catch (err) {
|
|
158
|
+
console.error(err);
|
|
159
|
+
flash.show('error', 'Error conectando con la API de Skills.');
|
|
160
|
+
localEl.innerHTML = `<p class="color-danger text-sm">Error cargando.</p>`;
|
|
161
|
+
discoverEl.innerHTML = `<p class="color-danger text-sm">Error conectando.</p>`;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tasks.js — Editor de tareas (split: lista + formulario)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { icon } from '../icons.js';
|
|
6
|
+
import * as state from '../state.js';
|
|
7
|
+
import * as api from '../api.js';
|
|
8
|
+
import { flash } from './flash.js';
|
|
9
|
+
import { esc, splitLines, formatDate } from '../utils.js';
|
|
10
|
+
import * as timeTracker from '../time-tracker.js';
|
|
11
|
+
import { t } from '../i18n.js';
|
|
12
|
+
|
|
13
|
+
export async function render() {
|
|
14
|
+
const payload = state.getPayload();
|
|
15
|
+
if (!payload) return `<div class="empty-state" style="margin:3rem">${t('ui.tasks.noData', {}, 'No project data.')}</div>`;
|
|
16
|
+
|
|
17
|
+
const tasks = _filterTasks(payload.derived.tasks);
|
|
18
|
+
const selTask = state.findTask(state.get('selectedTaskId'));
|
|
19
|
+
const phases = state.getPhases();
|
|
20
|
+
const statusLabels = state.getStatusLabels();
|
|
21
|
+
|
|
22
|
+
const html = `
|
|
23
|
+
<div class="view-enter">
|
|
24
|
+
<div class="section-header">
|
|
25
|
+
<div class="section-header-left">
|
|
26
|
+
<p class="eyebrow">Task Studio</p>
|
|
27
|
+
<h2>${t('ui.tasks.title', {}, 'Task Management')}</h2>
|
|
28
|
+
</div>
|
|
29
|
+
<button class="btn btn-primary btn-sm" id="new-task-btn-top" type="button">
|
|
30
|
+
${icon('plus', 14)} ${t('ui.tasks.new', {}, 'New task')}
|
|
31
|
+
</button>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<div class="grid-split">
|
|
35
|
+
|
|
36
|
+
<!-- Lista de tareas -->
|
|
37
|
+
<div style="display:flex;flex-direction:column;gap:var(--space-3)">
|
|
38
|
+
<!-- Quick filter -->
|
|
39
|
+
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap" role="group" aria-label="${t('ui.tasks.filters', {}, 'Status filters')}">
|
|
40
|
+
${_renderStatusFilters(statusLabels, payload.derived.totals)}
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<div class="stack stack-sm" id="task-list" aria-label="${t('ui.tasks.list', {}, 'Task list')}" role="list">
|
|
44
|
+
${_renderTaskList(tasks, statusLabels, phases)}
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<!-- Editor de tarea -->
|
|
49
|
+
<div class="panel" id="task-editor" aria-label="${t('ui.tasks.editor', {}, 'Task editor')}" aria-live="polite">
|
|
50
|
+
${_renderEditor(selTask, phases)}
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
`;
|
|
56
|
+
|
|
57
|
+
setTimeout(() => _bindEvents(), 0);
|
|
58
|
+
return html;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function _filterTasks(tasks) {
|
|
62
|
+
const query = state.get('searchQuery')?.toLowerCase();
|
|
63
|
+
const filter = sessionStorage.getItem('tasks-filter') || '';
|
|
64
|
+
let list = [...tasks];
|
|
65
|
+
if (query) {
|
|
66
|
+
list = list.filter(t =>
|
|
67
|
+
t.title.toLowerCase().includes(query) ||
|
|
68
|
+
(t.summary || '').toLowerCase().includes(query) ||
|
|
69
|
+
t.id.toLowerCase().includes(query)
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
if (filter) list = list.filter(t => t.status === filter);
|
|
73
|
+
return list;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function _renderStatusFilters(statusLabels, totals) {
|
|
77
|
+
const filters = [
|
|
78
|
+
{ id: '', label: t('ui.tasks.all', {}, 'All'), count: totals.all },
|
|
79
|
+
{ id: 'pending', label: statusLabels.pending || t('status.pending', {}, 'Pending'), count: totals.pending },
|
|
80
|
+
{ id: 'in_progress', label: statusLabels.in_progress || t('status.in_progress', {}, 'In progress'), count: totals.inProgress },
|
|
81
|
+
{ id: 'in_review', label: statusLabels.in_review || t('status.in_review', {}, 'In review'), count: totals.inReview },
|
|
82
|
+
{ id: 'blocked', label: statusLabels.blocked || t('status.blocked', {}, 'Blocked'), count: totals.blocked },
|
|
83
|
+
{ id: 'completed', label: statusLabels.completed || t('status.completed', {}, 'Completed'), count: totals.completed },
|
|
84
|
+
];
|
|
85
|
+
const active = sessionStorage.getItem('tasks-filter') || '';
|
|
86
|
+
return filters.map(f => `
|
|
87
|
+
<button class="chip ${f.id === active ? 'is-active' : ''}"
|
|
88
|
+
type="button" data-task-filter="${esc(f.id)}"
|
|
89
|
+
aria-pressed="${f.id === active}">
|
|
90
|
+
${esc(f.label)} <span class="badge badge-muted" style="font-size:0.65rem">${f.count}</span>
|
|
91
|
+
</button>
|
|
92
|
+
`).join('');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function _renderTaskList(tasks, statusLabels, phases) {
|
|
96
|
+
if (!tasks.length) return `<div class="empty-state">${t('ui.tasks.noMatch', {}, 'No matching tasks.')}</div>`;
|
|
97
|
+
const selectedId = state.get('selectedTaskId');
|
|
98
|
+
const priorityVariant = { P0: 'danger', P1: 'warning', P2: 'accent', P3: 'muted' };
|
|
99
|
+
|
|
100
|
+
return tasks.map(t => {
|
|
101
|
+
const phase = phases.find(p => p.id === t.phase);
|
|
102
|
+
return `
|
|
103
|
+
<div class="task-card ${t.id === selectedId ? 'is-selected' : ''}"
|
|
104
|
+
data-task-id="${esc(t.id)}"
|
|
105
|
+
role="listitem" tabindex="0"
|
|
106
|
+
aria-selected="${t.id === selectedId}"
|
|
107
|
+
aria-label="${esc(t.title)}"
|
|
108
|
+
>
|
|
109
|
+
<strong class="task-card-title">${esc(t.title)}</strong>
|
|
110
|
+
<span class="task-card-id">${esc(t.id)}</span>
|
|
111
|
+
<div class="task-card-meta" style="margin-top:var(--space-2)">
|
|
112
|
+
<span class="badge badge-${priorityVariant[t.priority] || 'muted'}">${esc(t.priority)}</span>
|
|
113
|
+
<span class="badge status-${t.status}">${esc(statusLabels[t.status] || t.status)}</span>
|
|
114
|
+
${phase ? `<span class="badge badge-muted">${esc(phase.label)}</span>` : ''}
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
`;
|
|
118
|
+
}).join('');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function _renderEditor(task, phases) {
|
|
122
|
+
const isNew = !task;
|
|
123
|
+
const phases_opts = phases.map(p =>
|
|
124
|
+
`<option value="${esc(p.id)}" ${!isNew && task.phase === p.id ? 'selected' : ''}>${esc(p.id)} — ${esc(p.label)}</option>`
|
|
125
|
+
).join('');
|
|
126
|
+
|
|
127
|
+
const statuses = [
|
|
128
|
+
{ id: 'pending', label: t('status.pending', {}, 'Pending') },
|
|
129
|
+
{ id: 'in_progress', label: t('status.in_progress', {}, 'In progress') },
|
|
130
|
+
{ id: 'in_review', label: t('status.in_review', {}, 'In review') },
|
|
131
|
+
{ id: 'blocked', label: t('status.blocked', {}, 'Blocked') },
|
|
132
|
+
{ id: 'completed', label: t('status.completed', {}, 'Completed') },
|
|
133
|
+
{ id: 'cancelled', label: t('status.cancelled', {}, 'Cancelled') },
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
return `
|
|
137
|
+
<div class="panel-header">
|
|
138
|
+
<div class="panel-header-left">
|
|
139
|
+
<p class="eyebrow">Task Studio</p>
|
|
140
|
+
<h3 class="panel-title" id="editor-title">${isNew ? t('ui.tasks.new', {}, 'New task') : esc(task.title)}</h3>
|
|
141
|
+
</div>
|
|
142
|
+
<div class="panel-header-right">
|
|
143
|
+
${!isNew ? `
|
|
144
|
+
<button class="btn btn-ghost btn-sm" id="timer-quick-btn" type="button" title="${t('ui.tasks.timerTitle', {}, 'Start timer for this task')}">
|
|
145
|
+
${icon('timer', 14)} Timer
|
|
146
|
+
</button>
|
|
147
|
+
<button class="btn btn-ghost btn-sm" id="duplicate-btn" type="button" aria-label="${t('ui.tasks.duplicate', {}, 'Duplicate task')}">
|
|
148
|
+
${icon('copy', 14)}
|
|
149
|
+
</button>
|
|
150
|
+
` : ''}
|
|
151
|
+
<button class="btn btn-ghost btn-sm" id="clear-task-btn" type="button" aria-label="${t('ui.tasks.clear', {}, 'Clear form')}">
|
|
152
|
+
${icon('x', 14)}
|
|
153
|
+
</button>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
<!-- Action strip -->
|
|
158
|
+
${!isNew ? `
|
|
159
|
+
<div class="panel-footer" style="display:flex;gap:var(--space-2);flex-wrap:wrap" role="group" aria-label="${t('ui.tasks.quickActions', {}, 'Quick task actions')}">
|
|
160
|
+
<button class="chip is-active" type="button" data-task-action="start" aria-label="${t('ui.tasks.start', {}, 'Start task')}">${t('ui.tasks.startLabel', {}, 'Start')}</button>
|
|
161
|
+
<button class="chip" type="button" data-task-action="review" aria-label="${t('ui.tasks.review', {}, 'Send to review')}">${t('ui.tasks.reviewLabel', {}, 'Review')}</button>
|
|
162
|
+
<button class="chip" type="button" data-task-action="complete" aria-label="${t('ui.tasks.complete', {}, 'Complete task')}">${t('ui.tasks.completeLabel', {}, 'Complete')}</button>
|
|
163
|
+
<button class="chip" type="button" data-task-action="block" aria-label="${t('ui.tasks.block', {}, 'Block task')}">${t('ui.tasks.blockLabel', {}, 'Block')}</button>
|
|
164
|
+
<button class="chip" type="button" data-task-action="pending" aria-label="${t('ui.tasks.pending', {}, 'Return to pending')}">${t('status.pending', {}, 'Pending')}</button>
|
|
165
|
+
</div>
|
|
166
|
+
` : ''}
|
|
167
|
+
|
|
168
|
+
<div class="panel-body">
|
|
169
|
+
<form id="task-form" class="stack stack-md" novalidate>
|
|
170
|
+
|
|
171
|
+
<div class="field">
|
|
172
|
+
<label for="task-title">${t('ui.tasks.field.title', {}, 'Title')} <span aria-hidden="true" style="color:var(--danger)">*</span></label>
|
|
173
|
+
<input id="task-title" name="title" type="text" required
|
|
174
|
+
value="${isNew ? '' : esc(task.title)}"
|
|
175
|
+
placeholder="${t('ui.tasks.placeholder.title', {}, 'Describe the task')}"
|
|
176
|
+
aria-required="true" />
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
<div class="field-row">
|
|
180
|
+
<div class="field">
|
|
181
|
+
<label for="task-phase">${t('ui.tasks.field.phase', {}, 'Phase')}</label>
|
|
182
|
+
<select id="task-phase" name="phase">${phases_opts}</select>
|
|
183
|
+
</div>
|
|
184
|
+
<div class="field">
|
|
185
|
+
<label for="task-priority">${t('ui.tasks.field.priority', {}, 'Priority')}</label>
|
|
186
|
+
<select id="task-priority" name="priority">
|
|
187
|
+
${['P0','P1','P2','P3'].map(p => `<option value="${p}" ${!isNew && task.priority === p ? 'selected' : ''}>${p}</option>`).join('')}
|
|
188
|
+
</select>
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
|
|
192
|
+
<div class="field-row">
|
|
193
|
+
<div class="field">
|
|
194
|
+
<label for="task-status">${t('ui.tasks.field.status', {}, 'Status')}</label>
|
|
195
|
+
<select id="task-status" name="status">
|
|
196
|
+
${statuses.map(s => `<option value="${s.id}" ${!isNew && task.status === s.id ? 'selected' : ''}>${s.label}</option>`).join('')}
|
|
197
|
+
</select>
|
|
198
|
+
</div>
|
|
199
|
+
<div class="field">
|
|
200
|
+
<label for="task-stream">${t('ui.tasks.field.stream', {}, 'Stream')}</label>
|
|
201
|
+
<input id="task-stream" name="stream" type="text"
|
|
202
|
+
value="${isNew ? 'Operations' : esc(task.stream || '')}"
|
|
203
|
+
placeholder="Operations" />
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
|
|
207
|
+
<div class="checkbox-field">
|
|
208
|
+
<input id="task-required" type="checkbox" name="required" ${isNew || task.required !== false ? 'checked' : ''} />
|
|
209
|
+
<label for="task-required">${t('ui.tasks.field.required', {}, 'Required for delivery')}</label>
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
<div class="field">
|
|
213
|
+
<label for="task-summary">${t('ui.tasks.field.summary', {}, 'Summary')}</label>
|
|
214
|
+
<textarea id="task-summary" name="summary" rows="3"
|
|
215
|
+
placeholder="${t('ui.tasks.placeholder.summary', {}, 'Short description of the task')}">${isNew ? '' : esc(task.summary || '')}</textarea>
|
|
216
|
+
</div>
|
|
217
|
+
|
|
218
|
+
<div class="field">
|
|
219
|
+
<label for="task-acceptance">${t('ui.tasks.field.acceptance', {}, 'Acceptance criteria')}</label>
|
|
220
|
+
<textarea id="task-acceptance" name="acceptance" rows="3"
|
|
221
|
+
placeholder="${t('ui.tasks.placeholder.acceptance', {}, 'One criterion per line')}">${isNew ? '' : esc((task.acceptance || []).join('\n'))}</textarea>
|
|
222
|
+
</div>
|
|
223
|
+
|
|
224
|
+
<div class="field">
|
|
225
|
+
<label for="task-depends">${t('ui.tasks.field.depends', {}, 'Dependencies')}</label>
|
|
226
|
+
<textarea id="task-depends" name="dependsOn" rows="2"
|
|
227
|
+
placeholder="${t('ui.tasks.placeholder.depends', {}, 'Dependent task ID, one per line')}">${isNew ? '' : esc((task.dependsOn || []).join('\n'))}</textarea>
|
|
228
|
+
</div>
|
|
229
|
+
|
|
230
|
+
<div class="field">
|
|
231
|
+
<label for="task-blocker">${t('ui.tasks.field.blocker', {}, 'Blocker')}</label>
|
|
232
|
+
<textarea id="task-blocker" name="blocker" rows="2"
|
|
233
|
+
placeholder="${t('ui.tasks.placeholder.blocker', {}, 'Describe the blocker if applicable')}">${isNew ? '' : esc(task.blocker || '')}</textarea>
|
|
234
|
+
</div>
|
|
235
|
+
|
|
236
|
+
<div class="field">
|
|
237
|
+
<label for="task-note">${t('ui.tasks.field.note', {}, 'Update note')}</label>
|
|
238
|
+
<textarea id="task-note" name="note" rows="2"
|
|
239
|
+
placeholder="${t('ui.tasks.placeholder.note', {}, 'Optional note to append to history')}"></textarea>
|
|
240
|
+
</div>
|
|
241
|
+
|
|
242
|
+
<div class="form-actions">
|
|
243
|
+
<button class="btn btn-primary" type="submit" id="save-task-btn">
|
|
244
|
+
${icon('check', 16)} ${isNew ? t('ui.tasks.create', {}, 'Create task') : t('ui.tasks.save', {}, 'Save changes')}
|
|
245
|
+
</button>
|
|
246
|
+
</div>
|
|
247
|
+
|
|
248
|
+
</form>
|
|
249
|
+
|
|
250
|
+
${!isNew && task.history?.length ? `
|
|
251
|
+
<div style="margin-top:var(--space-6)">
|
|
252
|
+
<p class="eyebrow" style="margin-bottom:var(--space-3)">${t('ui.tasks.history', {}, 'History')}</p>
|
|
253
|
+
<div class="stack stack-sm">
|
|
254
|
+
${task.history.slice(-5).reverse().map(h => `
|
|
255
|
+
<div class="info-row">
|
|
256
|
+
<p class="label-sm">${formatDate(h.at)}</p>
|
|
257
|
+
<p class="value">${esc(h.action)}${h.note ? ` — ${esc(h.note)}` : ''}</p>
|
|
258
|
+
</div>
|
|
259
|
+
`).join('')}
|
|
260
|
+
</div>
|
|
261
|
+
</div>
|
|
262
|
+
` : ''}
|
|
263
|
+
</div>
|
|
264
|
+
`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function _bindEvents() {
|
|
268
|
+
// Seleccionar tarea de la lista
|
|
269
|
+
document.getElementById('task-list')?.addEventListener('click', e => {
|
|
270
|
+
const card = e.target.closest('[data-task-id]');
|
|
271
|
+
if (!card) return;
|
|
272
|
+
const id = card.dataset.taskId;
|
|
273
|
+
state.update('selectedTaskId', id);
|
|
274
|
+
const editor = document.getElementById('task-editor');
|
|
275
|
+
if (editor) {
|
|
276
|
+
const phases = state.getPhases();
|
|
277
|
+
const selTask = state.findTask(id);
|
|
278
|
+
editor.innerHTML = _renderEditor(selTask, phases);
|
|
279
|
+
_bindEditorForm();
|
|
280
|
+
}
|
|
281
|
+
// Actualizar selección en lista
|
|
282
|
+
document.querySelectorAll('[data-task-id]').forEach(c => {
|
|
283
|
+
c.classList.toggle('is-selected', c.dataset.taskId === id);
|
|
284
|
+
c.setAttribute('aria-selected', c.dataset.taskId === id);
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// Filtros de estado
|
|
289
|
+
document.querySelectorAll('[data-task-filter]').forEach(btn => {
|
|
290
|
+
btn.addEventListener('click', () => {
|
|
291
|
+
sessionStorage.setItem('tasks-filter', btn.dataset.taskFilter);
|
|
292
|
+
import('../router.js').then(r => r.refresh());
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// Nueva tarea
|
|
297
|
+
document.getElementById('new-task-btn-top')?.addEventListener('click', () => {
|
|
298
|
+
state.update('selectedTaskId', null);
|
|
299
|
+
const editor = document.getElementById('task-editor');
|
|
300
|
+
if (editor) {
|
|
301
|
+
editor.innerHTML = _renderEditor(null, state.getPhases());
|
|
302
|
+
_bindEditorForm();
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
_bindEditorForm();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function _bindEditorForm() {
|
|
310
|
+
// Clear button
|
|
311
|
+
document.getElementById('clear-task-btn')?.addEventListener('click', () => {
|
|
312
|
+
state.update('selectedTaskId', null);
|
|
313
|
+
const editor = document.getElementById('task-editor');
|
|
314
|
+
if (editor) editor.innerHTML = _renderEditor(null, state.getPhases());
|
|
315
|
+
_bindEditorForm();
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// Duplicate button
|
|
319
|
+
document.getElementById('duplicate-btn')?.addEventListener('click', () => {
|
|
320
|
+
const task = state.findTask(state.get('selectedTaskId'));
|
|
321
|
+
if (!task) return;
|
|
322
|
+
state.update('selectedTaskId', null);
|
|
323
|
+
const editor = document.getElementById('task-editor');
|
|
324
|
+
if (editor) {
|
|
325
|
+
editor.innerHTML = _renderEditor({ ...task, title: `${task.title} (${t('ui.tasks.copySuffix', {}, 'copy')})`, status: 'pending', history: [] }, state.getPhases());
|
|
326
|
+
_bindEditorForm();
|
|
327
|
+
document.getElementById('task-title')?.focus();
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// Timer quick start
|
|
332
|
+
document.getElementById('timer-quick-btn')?.addEventListener('click', async () => {
|
|
333
|
+
const task = state.findTask(state.get('selectedTaskId'));
|
|
334
|
+
if (!task) return;
|
|
335
|
+
await timeTracker.start(task.id, task.title);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// Action strip
|
|
339
|
+
document.querySelectorAll('[data-task-action]').forEach(btn => {
|
|
340
|
+
btn.addEventListener('click', async () => {
|
|
341
|
+
const taskId = state.get('selectedTaskId');
|
|
342
|
+
if (!taskId) { flash(t('ui.tasks.selectFirst', {}, 'Select a task first.'), 'warning'); return; }
|
|
343
|
+
const action = btn.dataset.taskAction;
|
|
344
|
+
const note = document.getElementById('task-note')?.value?.trim() || '';
|
|
345
|
+
try {
|
|
346
|
+
await api.taskAction(taskId, action, note || t('ui.tasks.defaultActionNote', { action }, `Change to "${action}" from the board.`));
|
|
347
|
+
flash(t('ui.tasks.updated', {}, 'Status updated.'), 'success');
|
|
348
|
+
window.dispatchEvent(new CustomEvent('ops:refresh'));
|
|
349
|
+
} catch (err) {
|
|
350
|
+
flash(err.message, 'error');
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// Submit form
|
|
356
|
+
const form = document.getElementById('task-form');
|
|
357
|
+
form?.addEventListener('submit', async e => {
|
|
358
|
+
e.preventDefault();
|
|
359
|
+
const btn = document.getElementById('save-task-btn');
|
|
360
|
+
if (btn) btn.disabled = true;
|
|
361
|
+
try {
|
|
362
|
+
await _submitForm();
|
|
363
|
+
} finally {
|
|
364
|
+
if (btn) btn.disabled = false;
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function _submitForm() {
|
|
370
|
+
const get = id => document.getElementById(id);
|
|
371
|
+
|
|
372
|
+
const payload = {
|
|
373
|
+
title: get('task-title')?.value.trim(),
|
|
374
|
+
phase: get('task-phase')?.value,
|
|
375
|
+
priority: get('task-priority')?.value,
|
|
376
|
+
status: get('task-status')?.value,
|
|
377
|
+
stream: get('task-stream')?.value.trim(),
|
|
378
|
+
required: get('task-required')?.checked,
|
|
379
|
+
summary: get('task-summary')?.value.trim(),
|
|
380
|
+
acceptance: splitLines(get('task-acceptance')?.value || ''),
|
|
381
|
+
dependsOn: splitLines(get('task-depends')?.value || ''),
|
|
382
|
+
blocker: get('task-blocker')?.value.trim(),
|
|
383
|
+
note: get('task-note')?.value.trim(),
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
if (!payload.title) {
|
|
387
|
+
flash('El título es obligatorio.', 'error');
|
|
388
|
+
get('task-title')?.focus();
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const selectedId = state.get('selectedTaskId');
|
|
393
|
+
try {
|
|
394
|
+
if (selectedId) {
|
|
395
|
+
await api.updateTask(selectedId, payload);
|
|
396
|
+
flash('Tarea actualizada.', 'success');
|
|
397
|
+
} else {
|
|
398
|
+
const result = await api.createTask(payload);
|
|
399
|
+
state.update('selectedTaskId', result.task?.id);
|
|
400
|
+
flash('Tarea creada.', 'success');
|
|
401
|
+
}
|
|
402
|
+
window.dispatchEvent(new CustomEvent('ops:refresh'));
|
|
403
|
+
} catch (err) {
|
|
404
|
+
flash(err.message, 'error');
|
|
405
|
+
}
|
|
406
|
+
}
|