trackops 2.0.3 → 2.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 (45) hide show
  1. package/README.md +238 -0
  2. package/lib/init.js +2 -2
  3. package/lib/locale.js +41 -17
  4. package/lib/opera-bootstrap.js +68 -7
  5. package/lib/opera.js +10 -2
  6. package/lib/registry.js +18 -0
  7. package/lib/server.js +312 -207
  8. package/locales/en.json +4 -0
  9. package/locales/es.json +4 -0
  10. package/package.json +1 -1
  11. package/skills/trackops/locales/en/references/activation.md +15 -0
  12. package/skills/trackops/locales/en/references/troubleshooting.md +12 -0
  13. package/skills/trackops/references/activation.md +15 -0
  14. package/skills/trackops/references/troubleshooting.md +12 -0
  15. package/skills/trackops/skill.json +2 -2
  16. package/ui/css/base.css +19 -1
  17. package/ui/css/charts.css +106 -8
  18. package/ui/css/components.css +554 -17
  19. package/ui/css/onboarding.css +133 -0
  20. package/ui/css/panels.css +345 -406
  21. package/ui/css/terminal.css +125 -0
  22. package/ui/css/timeline.css +58 -0
  23. package/ui/css/tokens.css +170 -113
  24. package/ui/index.html +3 -0
  25. package/ui/js/api.js +49 -13
  26. package/ui/js/app.js +28 -32
  27. package/ui/js/charts.js +526 -0
  28. package/ui/js/filters.js +247 -0
  29. package/ui/js/icons.js +82 -57
  30. package/ui/js/keyboard.js +229 -0
  31. package/ui/js/onboarding.js +33 -42
  32. package/ui/js/router.js +20 -3
  33. package/ui/js/views/board.js +84 -114
  34. package/ui/js/views/dashboard.js +870 -0
  35. package/ui/js/views/projects.js +745 -0
  36. package/ui/js/views/scrum.js +476 -0
  37. package/ui/js/views/settings.js +197 -247
  38. package/ui/js/views/sidebar.js +37 -31
  39. package/ui/js/views/tasks.js +218 -101
  40. package/ui/js/views/timeline.js +265 -0
  41. package/ui/js/views/topbar.js +94 -107
  42. package/ui/app.js +0 -950
  43. package/ui/js/views/insights.js +0 -340
  44. package/ui/js/views/overview.js +0 -369
  45. package/ui/styles.css +0 -688
@@ -1,116 +1,236 @@
1
1
  /**
2
- * tasks.js — Editor de tareas (split: lista + formulario)
2
+ * tasks.js — Unified Task Management View (Kanban | Scrum | List)
3
+ *
4
+ * Three agile modes toggled via tab pills, with shared filter bar.
5
+ * List mode preserves the original split-view task editor.
3
6
  */
4
7
 
5
- import { icon } from '../icons.js';
6
8
  import * as state from '../state.js';
7
9
  import * as api from '../api.js';
10
+ import * as router from '../router.js';
11
+ import { icon } from '../icons.js';
12
+ import { t } from '../i18n.js';
13
+ import { esc, formatDate, formatDuration, splitLines, extractHistory } from '../utils.js';
14
+ import * as filters from '../filters.js';
15
+ import { renderKanban, bindKanbanEvents } from './board.js';
16
+ import { renderScrum, bindScrumEvents } from './scrum.js';
8
17
  import { flash } from './flash.js';
9
- import { esc, splitLines, formatDate } from '../utils.js';
10
18
  import * as timeTracker from '../time-tracker.js';
11
- import { t } from '../i18n.js';
19
+
20
+ /* ─────────────────────────── Mode state ─────────────────────────── */
21
+
22
+ let _activeMode = localStorage.getItem('ops-tasks-mode') || 'kanban';
23
+
24
+ /* ─────────────────────────── Public API ─────────────────────────── */
12
25
 
13
26
  export async function render() {
14
27
  const payload = state.getPayload();
15
28
  if (!payload) return `<div class="empty-state" style="margin:3rem">${t('ui.tasks.noData', {}, 'No project data.')}</div>`;
16
29
 
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();
30
+ const projectName = payload.control?.project?.name || payload.control?.name || '';
31
+ const activeFilters = filters.load('tasks');
32
+ const allTasks = payload.derived?.tasks || [];
33
+ const filteredTasks = filters.apply(allTasks, activeFilters);
34
+ const phases = state.getPhases();
35
+ const streams = [...new Set(allTasks.map(t => t.stream).filter(Boolean))];
21
36
 
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>
37
+ const modeContent = _renderModeContent(filteredTasks, phases, payload);
38
+
39
+ return `
40
+ <section class="view-section view-enter">
41
+ <div class="section-header" style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:var(--space-3)">
42
+ <div>
43
+ ${projectName ? `<p class="section-eyebrow">${esc(projectName)}</p>` : ''}
44
+ <h2 class="section-title">${t('ui.tasks.title', {}, 'Tasks')}</h2>
45
+ </div>
46
+ <div style="display:flex;align-items:center;gap:var(--space-3)">
47
+ <div class="tab-pills">
48
+ <button class="tab-pill ${_activeMode === 'kanban' ? 'is-active' : ''}" data-mode="kanban">Kanban</button>
49
+ <button class="tab-pill ${_activeMode === 'scrum' ? 'is-active' : ''}" data-mode="scrum">Scrum</button>
50
+ <button class="tab-pill ${_activeMode === 'list' ? 'is-active' : ''}" data-mode="list">List</button>
51
+ </div>
52
+ <button class="btn btn-primary btn-sm" id="new-task-btn" type="button">
53
+ ${icon('plus', 14)} ${t('ui.tasks.new', {}, 'New task')}
54
+ </button>
28
55
  </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
56
  </div>
33
57
 
34
- <div class="grid-split">
58
+ <div id="tasks-filter-bar">
59
+ ${filters.renderBar('tasks', activeFilters, { phases, streams })}
60
+ </div>
35
61
 
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>
62
+ <div id="tasks-mode-content">
63
+ ${modeContent}
64
+ </div>
65
+ </section>
66
+ `;
67
+ }
42
68
 
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>
69
+ export function bindEvents() {
70
+ // Filter bar
71
+ filters.bindBar('tasks', () => _refreshModeContent());
72
+
73
+ // Mode toggle pills
74
+ document.querySelectorAll('[data-mode]').forEach(btn => {
75
+ btn.addEventListener('click', () => {
76
+ const mode = btn.dataset.mode;
77
+ if (mode === _activeMode) return;
78
+ _activeMode = mode;
79
+ localStorage.setItem('ops-tasks-mode', mode);
80
+
81
+ // Toggle active class on pills
82
+ document.querySelectorAll('.tab-pill[data-mode]').forEach(p => {
83
+ p.classList.toggle('is-active', p.dataset.mode === mode);
84
+ });
85
+
86
+ _refreshModeContent();
87
+ });
88
+ });
89
+
90
+ // New Task button — switch to list mode and open blank editor
91
+ document.getElementById('new-task-btn')?.addEventListener('click', () => {
92
+ _activeMode = 'list';
93
+ localStorage.setItem('ops-tasks-mode', 'list');
94
+
95
+ document.querySelectorAll('.tab-pill[data-mode]').forEach(p => {
96
+ p.classList.toggle('is-active', p.dataset.mode === 'list');
97
+ });
98
+
99
+ state.update('selectedTaskId', null);
100
+ _refreshModeContent();
101
+
102
+ // Focus the title field once rendered
103
+ setTimeout(() => document.getElementById('task-title')?.focus(), 0);
104
+ });
105
+
106
+ // Bind active mode events
107
+ _bindActiveModeEvents();
108
+ }
109
+
110
+ /* ────────────────────── Mode content rendering ──────────────────── */
111
+
112
+ function _renderModeContent(filteredTasks, phases, payload) {
113
+ switch (_activeMode) {
114
+ case 'kanban':
115
+ return renderKanban(filteredTasks, phases, { showCompleted: false });
116
+ case 'scrum': {
117
+ const milestones = payload?.control?.milestones || [];
118
+ return renderScrum(filteredTasks, milestones, phases, {});
119
+ }
120
+ case 'list':
121
+ return _renderListMode(filteredTasks, phases);
122
+ default:
123
+ return renderKanban(filteredTasks, phases, { showCompleted: false });
124
+ }
125
+ }
126
+
127
+ function _refreshModeContent() {
128
+ const payload = state.getPayload();
129
+ if (!payload) return;
130
+
131
+ const activeFilters = filters.load('tasks');
132
+ const allTasks = payload.derived?.tasks || [];
133
+ const filteredTasks = filters.apply(allTasks, activeFilters);
134
+ const phases = state.getPhases();
135
+
136
+ const container = document.getElementById('tasks-mode-content');
137
+ if (container) {
138
+ container.innerHTML = _renderModeContent(filteredTasks, phases, payload);
139
+ }
140
+
141
+ _bindActiveModeEvents();
142
+ }
143
+
144
+ function _bindActiveModeEvents() {
145
+ switch (_activeMode) {
146
+ case 'kanban':
147
+ bindKanbanEvents();
148
+ break;
149
+ case 'scrum':
150
+ bindScrumEvents();
151
+ break;
152
+ case 'list':
153
+ _bindListEvents();
154
+ break;
155
+ }
156
+ }
157
+
158
+ /* ═══════════════════════════════════════════════════════════════════
159
+ LIST MODE — Split-view task list + editor
160
+ ═══════════════════════════════════════════════════════════════════ */
161
+
162
+ function _renderListMode(tasks, phases) {
163
+ const selTask = state.findTask(state.get('selectedTaskId'));
164
+ const statusLabels = state.getStatusLabels();
165
+ const payload = state.getPayload();
166
+ const totals = payload?.derived?.totals || {};
167
+
168
+ return `
169
+ <div class="grid-split">
170
+
171
+ <!-- Task list panel -->
172
+ <div style="display:flex;flex-direction:column;gap:var(--space-3)">
173
+ <!-- Quick status filter chips -->
174
+ <div style="display:flex;gap:var(--space-2);flex-wrap:wrap" role="group" aria-label="${t('ui.tasks.filters', {}, 'Status filters')}">
175
+ ${_renderStatusFilters(statusLabels, totals)}
46
176
  </div>
47
177
 
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)}
178
+ <div class="stack stack-sm" id="task-list" aria-label="${t('ui.tasks.list', {}, 'Task list')}" role="list">
179
+ ${_renderTaskList(tasks, statusLabels, phases)}
51
180
  </div>
181
+ </div>
52
182
 
183
+ <!-- Task editor panel -->
184
+ <div class="panel" id="task-editor" aria-label="${t('ui.tasks.editor', {}, 'Task editor')}" aria-live="polite">
185
+ ${_renderEditor(selTask, phases)}
53
186
  </div>
187
+
54
188
  </div>
55
189
  `;
56
-
57
- setTimeout(() => _bindEvents(), 0);
58
- return html;
59
190
  }
60
191
 
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
- }
192
+ /* ─── Status filter chips ─── */
75
193
 
76
194
  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 },
195
+ const chipFilters = [
196
+ { id: '', label: t('ui.tasks.all', {}, 'All'), count: totals.all },
197
+ { id: 'pending', label: statusLabels.pending || t('status.pending', {}, 'Pending'), count: totals.pending },
198
+ { id: 'in_progress', label: statusLabels.in_progress || t('status.in_progress', {}, 'In progress'), count: totals.inProgress },
199
+ { id: 'in_review', label: statusLabels.in_review || t('status.in_review', {}, 'In review'), count: totals.inReview },
200
+ { id: 'blocked', label: statusLabels.blocked || t('status.blocked', {}, 'Blocked'), count: totals.blocked },
201
+ { id: 'completed', label: statusLabels.completed || t('status.completed', {}, 'Completed'), count: totals.completed },
84
202
  ];
85
203
  const active = sessionStorage.getItem('tasks-filter') || '';
86
- return filters.map(f => `
204
+ return chipFilters.map(f => `
87
205
  <button class="chip ${f.id === active ? 'is-active' : ''}"
88
206
  type="button" data-task-filter="${esc(f.id)}"
89
207
  aria-pressed="${f.id === active}">
90
- ${esc(f.label)} <span class="badge badge-muted" style="font-size:0.65rem">${f.count}</span>
208
+ ${esc(f.label)} <span class="badge badge-muted" style="font-size:0.65rem">${f.count ?? 0}</span>
91
209
  </button>
92
210
  `).join('');
93
211
  }
94
212
 
213
+ /* ─── Task list cards ─── */
214
+
95
215
  function _renderTaskList(tasks, statusLabels, phases) {
96
216
  if (!tasks.length) return `<div class="empty-state">${t('ui.tasks.noMatch', {}, 'No matching tasks.')}</div>`;
97
217
  const selectedId = state.get('selectedTaskId');
98
218
  const priorityVariant = { P0: 'danger', P1: 'warning', P2: 'accent', P3: 'muted' };
99
219
 
100
- return tasks.map(t => {
101
- const phase = phases.find(p => p.id === t.phase);
220
+ return tasks.map(tk => {
221
+ const phase = phases.find(p => p.id === tk.phase);
102
222
  return `
103
- <div class="task-card ${t.id === selectedId ? 'is-selected' : ''}"
104
- data-task-id="${esc(t.id)}"
223
+ <div class="task-card ${tk.id === selectedId ? 'is-selected' : ''}"
224
+ data-task-id="${esc(tk.id)}"
105
225
  role="listitem" tabindex="0"
106
- aria-selected="${t.id === selectedId}"
107
- aria-label="${esc(t.title)}"
226
+ aria-selected="${tk.id === selectedId}"
227
+ aria-label="${esc(tk.title)}"
108
228
  >
109
- <strong class="task-card-title">${esc(t.title)}</strong>
110
- <span class="task-card-id">${esc(t.id)}</span>
229
+ <strong class="task-card-title">${esc(tk.title)}</strong>
230
+ <span class="task-card-id">${esc(tk.id)}</span>
111
231
  <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>
232
+ <span class="badge badge-${priorityVariant[tk.priority] || 'muted'}">${esc(tk.priority)}</span>
233
+ <span class="badge status-${tk.status}">${esc(statusLabels[tk.status] || tk.status)}</span>
114
234
  ${phase ? `<span class="badge badge-muted">${esc(phase.label)}</span>` : ''}
115
235
  </div>
116
236
  </div>
@@ -118,19 +238,21 @@ function _renderTaskList(tasks, statusLabels, phases) {
118
238
  }).join('');
119
239
  }
120
240
 
241
+ /* ─── Task editor ─── */
242
+
121
243
  function _renderEditor(task, phases) {
122
244
  const isNew = !task;
123
- const phases_opts = phases.map(p =>
245
+ const phasesOpts = phases.map(p =>
124
246
  `<option value="${esc(p.id)}" ${!isNew && task.phase === p.id ? 'selected' : ''}>${esc(p.id)} — ${esc(p.label)}</option>`
125
247
  ).join('');
126
248
 
127
249
  const statuses = [
128
- { id: 'pending', label: t('status.pending', {}, 'Pending') },
250
+ { id: 'pending', label: t('status.pending', {}, 'Pending') },
129
251
  { 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') },
252
+ { id: 'in_review', label: t('status.in_review', {}, 'In review') },
253
+ { id: 'blocked', label: t('status.blocked', {}, 'Blocked') },
254
+ { id: 'completed', label: t('status.completed', {}, 'Completed') },
255
+ { id: 'cancelled', label: t('status.cancelled', {}, 'Cancelled') },
134
256
  ];
135
257
 
136
258
  return `
@@ -154,7 +276,7 @@ function _renderEditor(task, phases) {
154
276
  </div>
155
277
  </div>
156
278
 
157
- <!-- Action strip -->
279
+ <!-- Quick action strip -->
158
280
  ${!isNew ? `
159
281
  <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
282
  <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>
@@ -179,7 +301,7 @@ function _renderEditor(task, phases) {
179
301
  <div class="field-row">
180
302
  <div class="field">
181
303
  <label for="task-phase">${t('ui.tasks.field.phase', {}, 'Phase')}</label>
182
- <select id="task-phase" name="phase">${phases_opts}</select>
304
+ <select id="task-phase" name="phase">${phasesOpts}</select>
183
305
  </div>
184
306
  <div class="field">
185
307
  <label for="task-priority">${t('ui.tasks.field.priority', {}, 'Priority')}</label>
@@ -264,8 +386,10 @@ function _renderEditor(task, phases) {
264
386
  `;
265
387
  }
266
388
 
267
- function _bindEvents() {
268
- // Seleccionar tarea de la lista
389
+ /* ─── List mode event binding ─── */
390
+
391
+ function _bindListEvents() {
392
+ // Select task from list
269
393
  document.getElementById('task-list')?.addEventListener('click', e => {
270
394
  const card = e.target.closest('[data-task-id]');
271
395
  if (!card) return;
@@ -273,36 +397,26 @@ function _bindEvents() {
273
397
  state.update('selectedTaskId', id);
274
398
  const editor = document.getElementById('task-editor');
275
399
  if (editor) {
276
- const phases = state.getPhases();
277
- const selTask = state.findTask(id);
400
+ const phases = state.getPhases();
401
+ const selTask = state.findTask(id);
278
402
  editor.innerHTML = _renderEditor(selTask, phases);
279
403
  _bindEditorForm();
280
404
  }
281
- // Actualizar selección en lista
282
- document.querySelectorAll('[data-task-id]').forEach(c => {
405
+ // Update selection highlight in list
406
+ document.querySelectorAll('#task-list [data-task-id]').forEach(c => {
283
407
  c.classList.toggle('is-selected', c.dataset.taskId === id);
284
408
  c.setAttribute('aria-selected', c.dataset.taskId === id);
285
409
  });
286
410
  });
287
411
 
288
- // Filtros de estado
412
+ // Status filter chips
289
413
  document.querySelectorAll('[data-task-filter]').forEach(btn => {
290
414
  btn.addEventListener('click', () => {
291
415
  sessionStorage.setItem('tasks-filter', btn.dataset.taskFilter);
292
- import('../router.js').then(r => r.refresh());
416
+ _refreshModeContent();
293
417
  });
294
418
  });
295
419
 
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
420
  _bindEditorForm();
307
421
  }
308
422
 
@@ -322,7 +436,10 @@ function _bindEditorForm() {
322
436
  state.update('selectedTaskId', null);
323
437
  const editor = document.getElementById('task-editor');
324
438
  if (editor) {
325
- editor.innerHTML = _renderEditor({ ...task, title: `${task.title} (${t('ui.tasks.copySuffix', {}, 'copy')})`, status: 'pending', history: [] }, state.getPhases());
439
+ editor.innerHTML = _renderEditor(
440
+ { ...task, title: `${task.title} (${t('ui.tasks.copySuffix', {}, 'copy')})`, status: 'pending', history: [] },
441
+ state.getPhases()
442
+ );
326
443
  _bindEditorForm();
327
444
  document.getElementById('task-title')?.focus();
328
445
  }
@@ -330,18 +447,18 @@ function _bindEditorForm() {
330
447
 
331
448
  // Timer quick start
332
449
  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);
450
+ const task = state.findTask(state.get('selectedTaskId'));
451
+ if (!task) return;
452
+ await timeTracker.start(task.id, task.title);
336
453
  });
337
454
 
338
- // Action strip
455
+ // Quick action strip
339
456
  document.querySelectorAll('[data-task-action]').forEach(btn => {
340
457
  btn.addEventListener('click', async () => {
341
458
  const taskId = state.get('selectedTaskId');
342
459
  if (!taskId) { flash(t('ui.tasks.selectFirst', {}, 'Select a task first.'), 'warning'); return; }
343
460
  const action = btn.dataset.taskAction;
344
- const note = document.getElementById('task-note')?.value?.trim() || '';
461
+ const note = document.getElementById('task-note')?.value?.trim() || '';
345
462
  try {
346
463
  await api.taskAction(taskId, action, note || t('ui.tasks.defaultActionNote', { action }, `Change to "${action}" from the board.`));
347
464
  flash(t('ui.tasks.updated', {}, 'Status updated.'), 'success');
@@ -384,7 +501,7 @@ async function _submitForm() {
384
501
  };
385
502
 
386
503
  if (!payload.title) {
387
- flash('El título es obligatorio.', 'error');
504
+ flash(t('ui.tasks.titleRequired', {}, 'Title is required.'), 'error');
388
505
  get('task-title')?.focus();
389
506
  return;
390
507
  }
@@ -393,11 +510,11 @@ async function _submitForm() {
393
510
  try {
394
511
  if (selectedId) {
395
512
  await api.updateTask(selectedId, payload);
396
- flash('Tarea actualizada.', 'success');
513
+ flash(t('ui.tasks.taskUpdated', {}, 'Task updated.'), 'success');
397
514
  } else {
398
515
  const result = await api.createTask(payload);
399
516
  state.update('selectedTaskId', result.task?.id);
400
- flash('Tarea creada.', 'success');
517
+ flash(t('ui.tasks.taskCreated', {}, 'Task created.'), 'success');
401
518
  }
402
519
  window.dispatchEvent(new CustomEvent('ops:refresh'));
403
520
  } catch (err) {