trackops 2.0.2 → 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.
- package/README.md +238 -0
- package/lib/init.js +2 -2
- package/lib/locale.js +41 -17
- package/lib/opera-bootstrap.js +68 -7
- package/lib/opera.js +10 -2
- package/lib/registry.js +18 -0
- package/lib/server.js +312 -207
- package/locales/en.json +4 -0
- package/locales/es.json +4 -0
- package/package.json +1 -1
- package/skills/trackops/SKILL.md +39 -4
- package/skills/trackops/agents/openai.yaml +2 -2
- package/skills/trackops/locales/en/SKILL.md +39 -4
- package/skills/trackops/locales/en/references/activation.md +15 -0
- package/skills/trackops/locales/en/references/troubleshooting.md +12 -0
- package/skills/trackops/references/activation.md +15 -0
- package/skills/trackops/references/troubleshooting.md +12 -0
- package/skills/trackops/skill.json +4 -4
- package/ui/css/base.css +19 -1
- package/ui/css/charts.css +106 -8
- package/ui/css/components.css +554 -17
- package/ui/css/onboarding.css +133 -0
- package/ui/css/panels.css +345 -406
- package/ui/css/terminal.css +125 -0
- package/ui/css/timeline.css +58 -0
- package/ui/css/tokens.css +170 -113
- package/ui/index.html +3 -0
- package/ui/js/api.js +49 -13
- package/ui/js/app.js +28 -32
- package/ui/js/charts.js +526 -0
- package/ui/js/filters.js +247 -0
- package/ui/js/icons.js +82 -57
- package/ui/js/keyboard.js +229 -0
- package/ui/js/onboarding.js +33 -42
- package/ui/js/router.js +20 -3
- package/ui/js/views/board.js +84 -114
- package/ui/js/views/dashboard.js +870 -0
- package/ui/js/views/projects.js +745 -0
- package/ui/js/views/scrum.js +476 -0
- package/ui/js/views/settings.js +197 -247
- package/ui/js/views/sidebar.js +37 -31
- package/ui/js/views/tasks.js +218 -101
- package/ui/js/views/timeline.js +265 -0
- package/ui/js/views/topbar.js +94 -107
- package/ui/app.js +0 -950
- package/ui/js/views/insights.js +0 -340
- package/ui/js/views/overview.js +0 -369
- 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
|
+
}
|
package/ui/js/views/topbar.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* topbar.js —
|
|
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
|
-
/**
|
|
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
|
|
19
|
-
const
|
|
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"
|
|
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"
|
|
50
|
+
<span class="search-kbd" aria-hidden="true">\u2318F</span>
|
|
44
51
|
</div>
|
|
45
52
|
</div>
|
|
46
53
|
|
|
47
|
-
<!--
|
|
54
|
+
<!-- Right -->
|
|
48
55
|
<div class="topbar-right">
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
63
|
+
<!-- Repo badge -->
|
|
57
64
|
${runtime ? _renderRepoBadge(runtime) : ''}
|
|
58
65
|
|
|
59
|
-
<!--
|
|
60
|
-
|
|
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"
|
|
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
|
|
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'))}
|
|
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
|
-
|
|
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
|
|
138
|
+
// Hamburger -> sidebar toggle
|
|
144
139
|
document.getElementById('sidebar-toggle')?.addEventListener('click', () => {
|
|
145
140
|
const sidebar = document.getElementById('sidebar');
|
|
146
|
-
const isOpen
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
165
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
/**
|
|
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;
|