trackops 2.0.4 → 2.0.6
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/LICENSE +21 -21
- package/README.md +660 -575
- package/bin/trackops.js +127 -106
- package/lib/cli-format.js +118 -0
- package/lib/config.js +352 -326
- package/lib/control.js +408 -246
- package/lib/env.js +234 -222
- package/lib/i18n.js +5 -4
- package/lib/init.js +390 -282
- package/lib/locale.js +41 -41
- package/lib/opera-bootstrap.js +1066 -880
- package/lib/opera.js +615 -444
- package/lib/preferences.js +74 -74
- package/lib/registry.js +214 -214
- package/lib/release.js +56 -56
- package/lib/runtime-state.js +144 -144
- package/lib/skills.js +114 -89
- package/lib/workspace.js +259 -248
- package/locales/en.json +311 -167
- package/locales/es.json +314 -170
- package/package.json +61 -58
- package/scripts/postinstall-locale.js +21 -21
- package/scripts/skills-marketplace-smoke.js +124 -124
- package/scripts/smoke-tests.js +563 -517
- package/scripts/sync-skill-version.js +21 -21
- package/scripts/validate-skill.js +103 -103
- package/skills/trackops/SKILL.md +126 -122
- package/skills/trackops/agents/openai.yaml +7 -7
- package/skills/trackops/locales/en/SKILL.md +126 -122
- package/skills/trackops/locales/en/references/activation.md +94 -90
- package/skills/trackops/locales/en/references/troubleshooting.md +73 -67
- package/skills/trackops/locales/en/references/workflow.md +55 -32
- package/skills/trackops/references/activation.md +94 -90
- package/skills/trackops/references/troubleshooting.md +73 -67
- package/skills/trackops/references/workflow.md +55 -32
- package/skills/trackops/skill.json +29 -29
- package/templates/hooks/post-checkout +2 -2
- package/templates/hooks/post-commit +2 -2
- package/templates/hooks/post-merge +2 -2
- package/templates/opera/agent.md +28 -27
- package/templates/opera/architecture/dependency-graph.md +24 -24
- package/templates/opera/architecture/runtime-automation.md +24 -24
- package/templates/opera/architecture/runtime-operations.md +34 -34
- package/templates/opera/en/agent.md +22 -21
- package/templates/opera/en/architecture/dependency-graph.md +24 -24
- package/templates/opera/en/architecture/runtime-automation.md +24 -24
- package/templates/opera/en/architecture/runtime-operations.md +34 -34
- package/templates/opera/en/reviews/delivery-audit.md +18 -18
- package/templates/opera/en/reviews/integration-audit.md +18 -18
- package/templates/opera/en/router.md +24 -19
- package/templates/opera/references/autonomy-and-recovery.md +117 -117
- package/templates/opera/references/opera-cycle.md +193 -193
- package/templates/opera/registry.md +28 -28
- package/templates/opera/reviews/delivery-audit.md +18 -18
- package/templates/opera/reviews/integration-audit.md +18 -18
- package/templates/opera/router.md +54 -49
- package/templates/skills/changelog-updater/SKILL.md +69 -69
- package/templates/skills/commiter/SKILL.md +99 -99
- package/templates/skills/opera-contract-auditor/SKILL.md +38 -38
- package/templates/skills/opera-contract-auditor/locales/en/SKILL.md +38 -38
- package/templates/skills/opera-policy-guard/SKILL.md +26 -26
- package/templates/skills/opera-policy-guard/locales/en/SKILL.md +26 -26
- package/templates/skills/opera-skill/SKILL.md +279 -0
- package/templates/skills/opera-skill/locales/en/SKILL.md +279 -0
- package/templates/skills/opera-skill/locales/en/references/phase-dod.md +138 -0
- package/templates/skills/opera-skill/references/phase-dod.md +138 -0
- package/templates/skills/project-starter-skill/SKILL.md +150 -131
- package/templates/skills/project-starter-skill/locales/en/SKILL.md +143 -105
- package/templates/skills/project-starter-skill/references/opera-cycle.md +195 -193
- package/ui/css/base.css +284 -284
- package/ui/css/charts.css +425 -425
- package/ui/css/components.css +1107 -1107
- package/ui/css/onboarding.css +133 -133
- package/ui/css/terminal.css +125 -125
- package/ui/css/timeline.css +58 -58
- package/ui/css/tokens.css +284 -284
- package/ui/favicon.svg +5 -5
- package/ui/index.html +99 -99
- package/ui/js/charts.js +526 -526
- package/ui/js/console-logger.js +172 -172
- package/ui/js/filters.js +247 -247
- package/ui/js/icons.js +129 -129
- package/ui/js/keyboard.js +229 -229
- package/ui/js/router.js +142 -142
- package/ui/js/theme.js +100 -100
- package/ui/js/time-tracker.js +248 -248
- package/ui/js/views/dashboard.js +870 -870
- package/ui/js/views/flash.js +47 -47
- package/ui/js/views/projects.js +745 -745
- package/ui/js/views/scrum.js +476 -476
- package/ui/js/views/settings.js +331 -331
- package/ui/js/views/timeline.js +265 -265
package/ui/js/views/timeline.js
CHANGED
|
@@ -1,265 +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
|
+
/**
|
|
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
|
+
}
|