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
@@ -0,0 +1,265 @@
1
+ /**
2
+ * timeline.js — Vista de línea temporal con milestones y tareas
3
+ * Muestra barras horizontales por tarea y diamantes para milestones.
4
+ */
5
+
6
+ import { icon } from '../icons.js';
7
+ import * as state from '../state.js';
8
+ import { esc, formatDate } from '../utils.js';
9
+ import { t } from '../i18n.js';
10
+
11
+ /** Constantes de layout */
12
+ const ROW_HEIGHT = 32;
13
+ const ROW_GAP = 6;
14
+ const LABEL_WIDTH = 200;
15
+ const DAY_WIDTH = 28;
16
+ const HEADER_HEIGHT = 40;
17
+ const MILESTONE_SIZE = 14;
18
+
19
+ export async function render() {
20
+ const payload = state.getPayload();
21
+ if (!payload) {
22
+ return `<div class="empty-state" style="margin:3rem auto;max-width:440px">
23
+ ${icon('alertCircle', 32)}
24
+ <p>${t('ui.timeline.noData', {}, 'No project data available.')}</p>
25
+ </div>`;
26
+ }
27
+
28
+ const { control, derived } = payload;
29
+ const tasks = (control.tasks || []).filter(t => t.status !== 'cancelled');
30
+ const milestones = control.milestones || [];
31
+
32
+ if (tasks.length === 0 && milestones.length === 0) {
33
+ return `<div class="empty-state" style="margin:3rem auto;max-width:440px">
34
+ ${icon('timeline', 32)}
35
+ <h3>${t('ui.timeline.empty', {}, 'No timeline data')}</h3>
36
+ <p style="font-size:var(--text-sm);color:var(--text-secondary)">${t('ui.timeline.emptyDesc', {}, 'Create tasks and milestones to see the timeline.')}</p>
37
+ </div>`;
38
+ }
39
+
40
+ // Calcular rango de fechas
41
+ const { startDate, endDate, days } = _computeDateRange(tasks, milestones);
42
+ const totalWidth = LABEL_WIDTH + days.length * DAY_WIDTH;
43
+ const totalHeight = HEADER_HEIGHT + (tasks.length + milestones.length) * (ROW_HEIGHT + ROW_GAP) + 40;
44
+
45
+ return `
46
+ <div class="view-enter">
47
+ <div class="section-header">
48
+ <div class="section-header-left">
49
+ <p class="eyebrow">${t('ui.timeline.eyebrow', {}, 'Timeline')}</p>
50
+ <h2>${t('ui.timeline.title', {}, 'Project timeline')}</h2>
51
+ </div>
52
+ <div class="section-header-right">
53
+ <span class="badge badge-muted">${tasks.length} ${t('ui.timeline.tasks', {}, 'tasks')}</span>
54
+ <span class="badge badge-accent">${milestones.length} ${t('ui.timeline.milestones', {}, 'milestones')}</span>
55
+ </div>
56
+ </div>
57
+
58
+ <div class="timeline-container" role="img" aria-label="${t('ui.timeline.ariaLabel', {}, 'Project timeline chart')}">
59
+ <div class="timeline-scroll" style="overflow-x:auto;overflow-y:visible">
60
+ <svg width="${totalWidth}" height="${totalHeight}" viewBox="0 0 ${totalWidth} ${totalHeight}" class="timeline-svg">
61
+ <defs>
62
+ <style>
63
+ .tl-grid { stroke: var(--border); stroke-width: 0.5; }
64
+ .tl-today { stroke: var(--accent); stroke-width: 1.5; stroke-dasharray: 4 2; }
65
+ .tl-label { fill: var(--text-secondary); font-family: var(--font-ui); font-size: 11px; }
66
+ .tl-date { fill: var(--text-muted); font-family: var(--font-mono); font-size: 10px; }
67
+ .tl-bar { rx: 4; ry: 4; cursor: pointer; transition: opacity 0.15s; }
68
+ .tl-bar:hover { opacity: 0.85; }
69
+ .tl-milestone { fill: var(--accent); }
70
+ .tl-milestone-label { fill: var(--accent); font-family: var(--font-heading); font-size: 11px; font-weight: 700; }
71
+ </style>
72
+ </defs>
73
+
74
+ <!-- Date header -->
75
+ ${_renderDateHeader(days, startDate)}
76
+
77
+ <!-- Grid lines -->
78
+ ${_renderGrid(days, totalHeight)}
79
+
80
+ <!-- Today marker -->
81
+ ${_renderTodayLine(days, startDate, totalHeight)}
82
+
83
+ <!-- Task bars -->
84
+ ${_renderTaskBars(tasks, days, startDate)}
85
+
86
+ <!-- Milestones -->
87
+ ${_renderMilestones(milestones, tasks.length, days, startDate)}
88
+ </svg>
89
+ </div>
90
+ </div>
91
+
92
+ <!-- Legend -->
93
+ <div class="timeline-legend" style="margin-top:var(--space-4);display:flex;gap:var(--space-5);flex-wrap:wrap">
94
+ <div class="donut-legend-item"><span class="donut-legend-dot" style="background:var(--success)"></span> ${t('status.completed', {}, 'Completed')}</div>
95
+ <div class="donut-legend-item"><span class="donut-legend-dot" style="background:var(--accent)"></span> ${t('status.in_progress', {}, 'In progress')}</div>
96
+ <div class="donut-legend-item"><span class="donut-legend-dot" style="background:var(--warning)"></span> ${t('status.pending', {}, 'Pending')}</div>
97
+ <div class="donut-legend-item"><span class="donut-legend-dot" style="background:var(--danger)"></span> ${t('status.blocked', {}, 'Blocked')}</div>
98
+ <div class="donut-legend-item"><span class="donut-legend-dot" style="background:var(--info)"></span> ${t('status.in_review', {}, 'In review')}</div>
99
+ <div class="donut-legend-item">
100
+ <svg width="12" height="12"><polygon points="6,0 12,6 6,12 0,6" fill="var(--accent)"/></svg>
101
+ ${t('ui.timeline.milestone', {}, 'Milestone')}
102
+ </div>
103
+ </div>
104
+ </div>
105
+ `;
106
+ }
107
+
108
+ // ─────────────────────────────── DATE COMPUTATION ────────────────────────────
109
+
110
+ function _computeDateRange(tasks, milestones) {
111
+ const today = new Date();
112
+ let min = new Date(today);
113
+ let max = new Date(today);
114
+
115
+ // From task history
116
+ tasks.forEach(task => {
117
+ (task.history || []).forEach(h => {
118
+ const d = new Date(h.at);
119
+ if (d < min) min = new Date(d);
120
+ if (d > max) max = new Date(d);
121
+ });
122
+ });
123
+
124
+ // From milestones
125
+ milestones.forEach(m => {
126
+ if (m.date) {
127
+ const d = new Date(m.date);
128
+ if (d < min) min = new Date(d);
129
+ if (d > max) max = new Date(d);
130
+ }
131
+ });
132
+
133
+ // Padding: 3 days before, 7 days after
134
+ min.setDate(min.getDate() - 3);
135
+ max.setDate(max.getDate() + 7);
136
+
137
+ const days = [];
138
+ for (let d = new Date(min); d <= max; d.setDate(d.getDate() + 1)) {
139
+ days.push(new Date(d));
140
+ }
141
+
142
+ return { startDate: min, endDate: max, days };
143
+ }
144
+
145
+ function _dayIndex(days, dateStr) {
146
+ const target = dateStr.slice(0, 10);
147
+ return days.findIndex(d => d.toISOString().slice(0, 10) === target);
148
+ }
149
+
150
+ // ─────────────────────────────── SVG RENDERS ─────────────────────────────────
151
+
152
+ function _renderDateHeader(days, startDate) {
153
+ const labels = [];
154
+ let lastMonth = '';
155
+
156
+ days.forEach((d, i) => {
157
+ const x = LABEL_WIDTH + i * DAY_WIDTH;
158
+ const dayNum = d.getDate();
159
+ const month = d.toLocaleString('default', { month: 'short' });
160
+
161
+ // Show day number for 1st, 5th, 10th, 15th, 20th, 25th
162
+ if (dayNum === 1 || dayNum === 5 || dayNum === 10 || dayNum === 15 || dayNum === 20 || dayNum === 25) {
163
+ labels.push(`<text x="${x + DAY_WIDTH / 2}" y="14" text-anchor="middle" class="tl-date">${dayNum}</text>`);
164
+ }
165
+
166
+ // Month label on 1st
167
+ if (month !== lastMonth) {
168
+ labels.push(`<text x="${x + 2}" y="30" class="tl-date" font-weight="700">${month}</text>`);
169
+ lastMonth = month;
170
+ }
171
+ });
172
+
173
+ return labels.join('\n');
174
+ }
175
+
176
+ function _renderGrid(days, totalHeight) {
177
+ return days.map((d, i) => {
178
+ const x = LABEL_WIDTH + i * DAY_WIDTH;
179
+ const isWeekend = d.getDay() === 0 || d.getDay() === 6;
180
+ const opacity = isWeekend ? 0.06 : 0;
181
+ return `
182
+ ${opacity > 0 ? `<rect x="${x}" y="${HEADER_HEIGHT}" width="${DAY_WIDTH}" height="${totalHeight - HEADER_HEIGHT}" fill="var(--text-muted)" opacity="${opacity}"/>` : ''}
183
+ ${d.getDay() === 1 ? `<line x1="${x}" y1="${HEADER_HEIGHT}" x2="${x}" y2="${totalHeight}" class="tl-grid"/>` : ''}
184
+ `;
185
+ }).join('');
186
+ }
187
+
188
+ function _renderTodayLine(days, startDate, totalHeight) {
189
+ const todayStr = new Date().toISOString().slice(0, 10);
190
+ const idx = _dayIndex(days, todayStr);
191
+ if (idx < 0) return '';
192
+ const x = LABEL_WIDTH + idx * DAY_WIDTH + DAY_WIDTH / 2;
193
+ return `<line x1="${x}" y1="${HEADER_HEIGHT}" x2="${x}" y2="${totalHeight}" class="tl-today"/>`;
194
+ }
195
+
196
+ function _renderTaskBars(tasks, days, startDate) {
197
+ const statusColor = {
198
+ completed: 'var(--success)',
199
+ in_progress: 'var(--accent)',
200
+ in_review: 'var(--info)',
201
+ blocked: 'var(--danger)',
202
+ pending: 'var(--warning)',
203
+ cancelled: 'var(--text-muted)',
204
+ };
205
+
206
+ return tasks.map((task, i) => {
207
+ const y = HEADER_HEIGHT + i * (ROW_HEIGHT + ROW_GAP);
208
+ const color = statusColor[task.status] || 'var(--text-muted)';
209
+
210
+ // Find start and end dates from history
211
+ const history = task.history || [];
212
+ const createEntry = history.find(h => h.action === 'create') || history[0];
213
+ const completeEntry = history.find(h => h.action === 'complete');
214
+ const startEntry = history.find(h => h.action === 'start') || createEntry;
215
+
216
+ if (!startEntry) {
217
+ // No history, just show label
218
+ return `<text x="8" y="${y + ROW_HEIGHT / 2 + 4}" class="tl-label">${_truncate(task.title, 24)}</text>`;
219
+ }
220
+
221
+ const startIdx = Math.max(0, _dayIndex(days, startEntry.at));
222
+ const endIdx = completeEntry
223
+ ? Math.max(startIdx + 1, _dayIndex(days, completeEntry.at) + 1)
224
+ : Math.max(startIdx + 1, _dayIndex(days, new Date().toISOString()));
225
+
226
+ const barX = LABEL_WIDTH + startIdx * DAY_WIDTH;
227
+ const barW = Math.max(DAY_WIDTH, (endIdx - startIdx) * DAY_WIDTH);
228
+
229
+ return `
230
+ <g>
231
+ <text x="8" y="${y + ROW_HEIGHT / 2 + 4}" class="tl-label">${_truncate(task.title, 24)}</text>
232
+ <rect x="${barX}" y="${y + 4}" width="${barW}" height="${ROW_HEIGHT - 8}" fill="${color}" class="tl-bar" opacity="0.7">
233
+ <title>${esc(task.title)} (${task.status})</title>
234
+ </rect>
235
+ </g>
236
+ `;
237
+ }).join('');
238
+ }
239
+
240
+ function _renderMilestones(milestones, taskCount, days, startDate) {
241
+ return milestones.map((m, i) => {
242
+ if (!m.date) return '';
243
+ const idx = _dayIndex(days, m.date);
244
+ if (idx < 0) return '';
245
+
246
+ const y = HEADER_HEIGHT + (taskCount + i) * (ROW_HEIGHT + ROW_GAP) + ROW_HEIGHT / 2;
247
+ const x = LABEL_WIDTH + idx * DAY_WIDTH + DAY_WIDTH / 2;
248
+ const half = MILESTONE_SIZE / 2;
249
+
250
+ return `
251
+ <g>
252
+ <text x="8" y="${y + 4}" class="tl-milestone-label">${_truncate(m.title, 24)}</text>
253
+ <polygon points="${x},${y - half} ${x + half},${y} ${x},${y + half} ${x - half},${y}" class="tl-milestone">
254
+ <title>${esc(m.title)} — ${formatDate(m.date, 'date')}</title>
255
+ </polygon>
256
+ </g>
257
+ `;
258
+ }).join('');
259
+ }
260
+
261
+ function _truncate(str, max) {
262
+ if (!str) return '';
263
+ const s = String(str);
264
+ return s.length > max ? esc(s.slice(0, max - 1)) + '…' : esc(s);
265
+ }
@@ -1,5 +1,8 @@
1
1
  /**
2
- * topbar.js — Barra superior del dashboard
2
+ * topbar.js — Top bar (glassmorphism redesign)
3
+ *
4
+ * Simplified: project selector and locale selector moved out.
5
+ * Keeps hamburger, search, timer, repo badge, sync, theme, refresh.
3
6
  */
4
7
 
5
8
  import { icon } from '../icons.js';
@@ -10,24 +13,28 @@ import { esc, debounce } from '../utils.js';
10
13
  import * as theme from '../theme.js';
11
14
  import { t } from '../i18n.js';
12
15
 
13
- /** Renderiza el topbar */
16
+ /** Render the topbar into #topbar */
14
17
  export function render() {
15
18
  const el = document.getElementById('topbar');
16
19
  if (!el) return;
17
20
 
18
- const payload = state.getPayload();
19
- const projects = state.get('projects');
20
- const currentId = state.get('currentProjectId');
21
- const currentLocale = state.get('locale') || payload?.i18n?.locale || 'es';
22
- const runtime = payload?.runtime;
21
+ const payload = state.getPayload();
22
+ const runtime = payload?.runtime;
23
23
 
24
24
  el.innerHTML = `
25
25
  <div class="topbar">
26
26
  <!-- Hamburger (mobile) -->
27
- <button class="topbar-hamburger" type="button" id="sidebar-toggle" aria-label="${t('ui.topbar.openMenu', {}, 'Open menu')}" aria-expanded="false" aria-controls="sidebar">
27
+ <button class="topbar-hamburger" type="button" id="sidebar-toggle"
28
+ aria-label="${t('ui.topbar.openMenu', {}, 'Open menu')}"
29
+ aria-expanded="false" aria-controls="sidebar">
28
30
  <span></span><span></span><span></span>
29
31
  </button>
30
32
 
33
+ <!-- Active project name -->
34
+ <div class="topbar-project-name" id="topbar-project-name">
35
+ ${_renderProjectName()}
36
+ </div>
37
+
31
38
  <!-- Search -->
32
39
  <div class="topbar-search">
33
40
  <div class="search-wrapper" role="search">
@@ -36,45 +43,53 @@ export function render() {
36
43
  type="search"
37
44
  id="global-search"
38
45
  aria-label="${t('ui.topbar.searchAria', {}, 'Search tasks')}"
39
- placeholder="${t('ui.topbar.searchPlaceholder', {}, 'Search tasks')}"
46
+ placeholder="${t('ui.topbar.searchPlaceholder', {}, 'Search tasks\u2026')}"
40
47
  autocomplete="off"
41
48
  value="${esc(state.get('searchQuery'))}"
42
49
  />
43
- <span class="search-kbd" aria-hidden="true">⌘F</span>
50
+ <span class="search-kbd" aria-hidden="true">\u2318F</span>
44
51
  </div>
45
52
  </div>
46
53
 
47
- <!-- Derecha -->
54
+ <!-- Right -->
48
55
  <div class="topbar-right">
49
-
50
- <!-- Timer activo -->
51
- <div class="topbar-timer" id="topbar-timer" aria-label="${t('ui.topbar.timer', {}, 'Time tracking')}" aria-live="polite">
56
+ <!-- Timer -->
57
+ <div class="topbar-timer" id="topbar-timer"
58
+ aria-label="${t('ui.topbar.timer', {}, 'Time tracking')}" aria-live="polite">
52
59
  <span class="topbar-timer-dot" aria-hidden="true"></span>
53
60
  <span id="topbar-timer-display">00:00:00</span>
54
61
  </div>
55
62
 
56
- <!-- Repo status -->
63
+ <!-- Repo badge -->
57
64
  ${runtime ? _renderRepoBadge(runtime) : ''}
58
65
 
59
- <!-- Project selector -->
60
- ${projects.length > 0 ? _renderProjectSelector(projects, currentId) : ''}
61
-
62
- <!-- Locale selector -->
63
- ${_renderLocaleSelector(currentLocale)}
64
-
65
- <!-- Sync button -->
66
- <button class="btn btn-ghost btn-sm" id="sync-btn" type="button" aria-label="${t('ui.topbar.syncAria', {}, 'Sync documentation')}">
66
+ <!-- Sync -->
67
+ <button class="btn btn-ghost btn-sm" id="sync-btn" type="button"
68
+ aria-label="${t('ui.topbar.syncAria', {}, 'Sync documentation')}">
67
69
  ${icon('sync', 16)} ${t('ui.topbar.sync', {}, 'Sync')}
68
70
  </button>
69
71
 
72
+ <!-- Language selector -->
73
+ <div class="topbar-locale" id="topbar-locale">
74
+ <button class="btn btn-ghost btn-sm btn-icon" type="button" id="locale-toggle-btn"
75
+ aria-label="${t('ui.topbar.locale', {}, 'Change language')}"
76
+ title="${t('ui.topbar.locale', {}, 'Change language')}">
77
+ ${icon('globe', 16)}
78
+ </button>
79
+ <div class="locale-dropdown is-hidden" id="locale-dropdown">
80
+ <button class="locale-option ${_currentLocale() === 'es' ? 'is-active' : ''}" data-locale="es" type="button">Español</button>
81
+ <button class="locale-option ${_currentLocale() === 'en' ? 'is-active' : ''}" data-locale="en" type="button">English</button>
82
+ </div>
83
+ </div>
84
+
70
85
  <!-- Theme toggle -->
71
86
  ${theme.renderButton()}
72
87
 
73
88
  <!-- Refresh -->
74
- <button class="btn btn-ghost btn-sm btn-icon" id="refresh-btn" type="button" aria-label="${t('ui.topbar.refresh', {}, 'Refresh state')}">
89
+ <button class="btn btn-ghost btn-sm btn-icon" id="refresh-btn" type="button"
90
+ aria-label="${t('ui.topbar.refresh', {}, 'Refresh state')}">
75
91
  ${icon('refresh', 16)}
76
92
  </button>
77
-
78
93
  </div>
79
94
  </div>
80
95
  `;
@@ -82,78 +97,55 @@ export function render() {
82
97
  _bindEvents();
83
98
  }
84
99
 
100
+ /* ── Helpers ─────────────────────────────────────────────────────────── */
101
+
102
+ function _renderProjectName() {
103
+ const projects = state.get('projects') || [];
104
+ const currentId = state.get('currentProjectId');
105
+ const current = projects.find(p => p.id === currentId);
106
+ if (!current) return '';
107
+ return `
108
+ <span class="topbar-project-icon" aria-hidden="true">${icon('folder', 14)}</span>
109
+ <span class="topbar-project-label">${esc(current.name)}</span>
110
+ `;
111
+ }
112
+
113
+ function _currentLocale() {
114
+ return state.get('locale') || 'es';
115
+ }
116
+
85
117
  function _renderRepoBadge(runtime) {
86
118
  const isClean = runtime.clean;
87
119
  const label = isClean
88
- ? t('ui.topbar.repoClean', {}, 'Clean repo')
120
+ ? t('ui.topbar.repoClean', {}, 'Clean')
89
121
  : t('ui.topbar.repoDirty', {
90
122
  staged: runtime.staged,
91
123
  unstaged: runtime.unstaged,
92
124
  untracked: runtime.untracked,
93
125
  }, `${runtime.staged}s ${runtime.unstaged}u ${runtime.untracked}?`);
126
+
94
127
  return `
95
128
  <div class="repo-badge ${isClean ? 'clean' : 'dirty'}" title="${esc(runtime.branch || '')}">
96
129
  <span class="repo-badge-dot" aria-hidden="true"></span>
97
- <span>${icon('gitBranch', 12)} ${esc(runtime.branch || t('ui.topbar.noBranch', {}, 'no branch'))} · ${label}</span>
98
- </div>
99
- `;
100
- }
101
-
102
- function _renderProjectSelector(projects, currentId) {
103
- const options = projects.map(p =>
104
- `<option value="${esc(p.id)}" ${p.id === currentId ? 'selected' : ''} ${p.available ? '' : 'disabled'}>
105
- ${esc(p.name)}${p.available ? '' : ` (${t('ui.topbar.unavailable', {}, 'unavailable')})`}
106
- </option>`
107
- ).join('');
108
-
109
- return `
110
- <div class="project-select-wrapper" title="${t('ui.topbar.activeProject', {}, 'Active project')}">
111
- <select id="project-select" aria-label="${t('ui.topbar.activeProject', {}, 'Active project')}">
112
- ${options}
113
- </select>
114
- </div>
115
- `;
116
- }
117
-
118
- function _renderLocaleSelector(currentLocale) {
119
- return `
120
- <div class="project-select-wrapper locale-select-wrapper" title="${t('ui.topbar.language', {}, 'Language')}">
121
- <select id="locale-select" aria-label="${t('ui.topbar.languageAria', {}, 'Select dashboard language')}">
122
- <option value="es" ${currentLocale === 'es' ? 'selected' : ''}>${t('ui.topbar.languageEs', {}, 'ES')}</option>
123
- <option value="en" ${currentLocale === 'en' ? 'selected' : ''}>${t('ui.topbar.languageEn', {}, 'EN')}</option>
124
- </select>
130
+ <span>${icon('gitBranch', 12)} ${esc(runtime.branch || t('ui.topbar.noBranch', {}, 'no branch'))} \u00b7 ${label}</span>
125
131
  </div>
126
132
  `;
127
133
  }
128
134
 
129
- function _applyLocaleState(payload) {
130
- if (!payload) return;
131
- state.update('payload', payload);
132
- if (payload.i18n) {
133
- state.update({
134
- phases: payload.i18n.phases || [],
135
- statusLabels: payload.i18n.statusLabels || {},
136
- locale: payload.i18n.locale || 'es',
137
- messages: payload.i18n.messages || {},
138
- });
139
- }
140
- }
135
+ /* ── Event bindings ──────────────────────────────────────────────────── */
141
136
 
142
137
  function _bindEvents() {
143
- // Hamburger sidebar open
138
+ // Hamburger -> sidebar toggle
144
139
  document.getElementById('sidebar-toggle')?.addEventListener('click', () => {
145
140
  const sidebar = document.getElementById('sidebar');
146
- const isOpen = sidebar?.classList.toggle('is-open');
141
+ const isOpen = sidebar?.classList.toggle('is-open');
147
142
  document.getElementById('sidebar-toggle')?.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
148
143
  });
149
144
 
150
- // Theme toggle
151
- theme.bindButton();
152
-
153
- // Cerrar sidebar al hacer clic fuera (mobile)
145
+ // Close sidebar on outside click (mobile)
154
146
  document.addEventListener('click', e => {
155
147
  const sidebar = document.getElementById('sidebar');
156
- const toggle = document.getElementById('sidebar-toggle');
148
+ const toggle = document.getElementById('sidebar-toggle');
157
149
  if (sidebar?.classList.contains('is-open') &&
158
150
  !sidebar.contains(e.target) && !toggle?.contains(e.target)) {
159
151
  sidebar.classList.remove('is-open');
@@ -161,41 +153,13 @@ function _bindEvents() {
161
153
  }
162
154
  });
163
155
 
164
- // Project selector
165
- document.getElementById('project-select')?.addEventListener('change', async e => {
166
- const id = e.target.value;
167
- state.update('currentProjectId', id);
168
- localStorage.setItem('ops-dashboard-project', id);
169
- state.update('selectedTaskId', null);
170
- // Trigger refresh global
171
- window.dispatchEvent(new CustomEvent('ops:refresh'));
172
- });
173
-
174
- document.getElementById('locale-select')?.addEventListener('change', async e => {
175
- const select = e.target;
176
- const previousLocale = state.get('locale') || 'es';
177
- const nextLocale = select.value;
178
-
179
- if (nextLocale === previousLocale) return;
180
-
181
- select.disabled = true;
182
- try {
183
- const result = await api.updateProjectLocale(nextLocale);
184
- _applyLocaleState(result.state);
185
- flash(t('ui.topbar.localeUpdated', {}, 'Language updated.'), 'success');
186
- window.dispatchEvent(new CustomEvent('ops:refresh'));
187
- } catch (err) {
188
- select.value = previousLocale;
189
- flash(err.message, 'error');
190
- } finally {
191
- select.disabled = false;
192
- }
193
- });
156
+ // Theme toggle
157
+ theme.bindButton();
194
158
 
195
159
  // Sync button
196
160
  document.getElementById('sync-btn')?.addEventListener('click', async () => {
197
161
  const btn = document.getElementById('sync-btn');
198
- if (btn) { btn.disabled = true; btn.innerHTML = `${icon('sync', 16)} ${t('ui.topbar.syncing', {}, 'Syncing')}`; }
162
+ if (btn) { btn.disabled = true; btn.innerHTML = `${icon('sync', 16)} ${t('ui.topbar.syncing', {}, 'Syncing\u2026')}`; }
199
163
  try {
200
164
  await api.syncDocs();
201
165
  flash(t('ui.topbar.synced', {}, 'Documentation synced.'), 'success');
@@ -212,7 +176,30 @@ function _bindEvents() {
212
176
  window.dispatchEvent(new CustomEvent('ops:refresh'));
213
177
  });
214
178
 
215
- // Búsqueda global (debounced)
179
+ // Locale toggle
180
+ document.getElementById('locale-toggle-btn')?.addEventListener('click', () => {
181
+ document.getElementById('locale-dropdown')?.classList.toggle('is-hidden');
182
+ });
183
+ document.getElementById('locale-dropdown')?.addEventListener('click', async (e) => {
184
+ const btn = e.target.closest('[data-locale]');
185
+ if (!btn) return;
186
+ const locale = btn.dataset.locale;
187
+ try {
188
+ await api.updateProjectLocale(locale);
189
+ document.getElementById('locale-dropdown')?.classList.add('is-hidden');
190
+ window.dispatchEvent(new CustomEvent('ops:refresh'));
191
+ } catch (err) {
192
+ flash(`Error: ${err.message}`, 'error');
193
+ }
194
+ });
195
+ // Close locale dropdown on outside click
196
+ document.addEventListener('click', (e) => {
197
+ if (!e.target.closest('#topbar-locale')) {
198
+ document.getElementById('locale-dropdown')?.classList.add('is-hidden');
199
+ }
200
+ });
201
+
202
+ // Global search (debounced)
216
203
  const searchInput = document.getElementById('global-search');
217
204
  if (searchInput) {
218
205
  const handleSearch = debounce(e => {
@@ -221,7 +208,7 @@ function _bindEvents() {
221
208
  }, 250);
222
209
  searchInput.addEventListener('input', handleSearch);
223
210
 
224
- // Atajo de teclado ⌘/Ctrl+F
211
+ // Keyboard shortcut Ctrl/Cmd+F
225
212
  document.addEventListener('keydown', e => {
226
213
  if ((e.metaKey || e.ctrlKey) && e.key === 'f') {
227
214
  e.preventDefault();
@@ -232,7 +219,7 @@ function _bindEvents() {
232
219
  }
233
220
  }
234
221
 
235
- /** Actualiza solo el timer del topbar sin re-renderizar */
222
+ /** Update only the timer display without a full re-render */
236
223
  export function updateTimer(display) {
237
224
  const el = document.getElementById('topbar-timer-display');
238
225
  if (el) el.textContent = display;