trackops 2.0.4 → 2.0.5

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 (90) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +695 -640
  3. package/bin/trackops.js +116 -116
  4. package/lib/config.js +326 -326
  5. package/lib/control.js +208 -208
  6. package/lib/env.js +244 -244
  7. package/lib/init.js +325 -325
  8. package/lib/locale.js +41 -41
  9. package/lib/opera-bootstrap.js +942 -936
  10. package/lib/opera.js +495 -486
  11. package/lib/preferences.js +74 -74
  12. package/lib/registry.js +214 -214
  13. package/lib/release.js +56 -56
  14. package/lib/runtime-state.js +144 -144
  15. package/lib/skills.js +74 -57
  16. package/lib/workspace.js +260 -260
  17. package/locales/en.json +192 -170
  18. package/locales/es.json +192 -170
  19. package/package.json +61 -58
  20. package/scripts/postinstall-locale.js +21 -21
  21. package/scripts/skills-marketplace-smoke.js +124 -124
  22. package/scripts/smoke-tests.js +558 -554
  23. package/scripts/sync-skill-version.js +21 -21
  24. package/scripts/validate-skill.js +103 -103
  25. package/skills/trackops/SKILL.md +126 -122
  26. package/skills/trackops/agents/openai.yaml +7 -7
  27. package/skills/trackops/locales/en/SKILL.md +126 -122
  28. package/skills/trackops/locales/en/references/activation.md +94 -90
  29. package/skills/trackops/locales/en/references/troubleshooting.md +73 -67
  30. package/skills/trackops/locales/en/references/workflow.md +55 -32
  31. package/skills/trackops/references/activation.md +94 -90
  32. package/skills/trackops/references/troubleshooting.md +73 -67
  33. package/skills/trackops/references/workflow.md +55 -32
  34. package/skills/trackops/skill.json +29 -29
  35. package/templates/hooks/post-checkout +2 -2
  36. package/templates/hooks/post-commit +2 -2
  37. package/templates/hooks/post-merge +2 -2
  38. package/templates/opera/agent.md +28 -27
  39. package/templates/opera/architecture/dependency-graph.md +24 -24
  40. package/templates/opera/architecture/runtime-automation.md +24 -24
  41. package/templates/opera/architecture/runtime-operations.md +34 -34
  42. package/templates/opera/en/agent.md +22 -21
  43. package/templates/opera/en/architecture/dependency-graph.md +24 -24
  44. package/templates/opera/en/architecture/runtime-automation.md +24 -24
  45. package/templates/opera/en/architecture/runtime-operations.md +34 -34
  46. package/templates/opera/en/reviews/delivery-audit.md +18 -18
  47. package/templates/opera/en/reviews/integration-audit.md +18 -18
  48. package/templates/opera/en/router.md +24 -19
  49. package/templates/opera/references/autonomy-and-recovery.md +117 -117
  50. package/templates/opera/references/opera-cycle.md +193 -193
  51. package/templates/opera/registry.md +28 -28
  52. package/templates/opera/reviews/delivery-audit.md +18 -18
  53. package/templates/opera/reviews/integration-audit.md +18 -18
  54. package/templates/opera/router.md +54 -49
  55. package/templates/skills/changelog-updater/SKILL.md +69 -69
  56. package/templates/skills/commiter/SKILL.md +99 -99
  57. package/templates/skills/opera-contract-auditor/SKILL.md +38 -38
  58. package/templates/skills/opera-contract-auditor/locales/en/SKILL.md +38 -38
  59. package/templates/skills/opera-policy-guard/SKILL.md +26 -26
  60. package/templates/skills/opera-policy-guard/locales/en/SKILL.md +26 -26
  61. package/templates/skills/opera-skill/SKILL.md +279 -0
  62. package/templates/skills/opera-skill/locales/en/SKILL.md +279 -0
  63. package/templates/skills/opera-skill/locales/en/references/phase-dod.md +138 -0
  64. package/templates/skills/opera-skill/references/phase-dod.md +138 -0
  65. package/templates/skills/project-starter-skill/SKILL.md +150 -131
  66. package/templates/skills/project-starter-skill/locales/en/SKILL.md +143 -105
  67. package/templates/skills/project-starter-skill/references/opera-cycle.md +195 -193
  68. package/ui/css/base.css +284 -284
  69. package/ui/css/charts.css +425 -425
  70. package/ui/css/components.css +1107 -1107
  71. package/ui/css/onboarding.css +133 -133
  72. package/ui/css/terminal.css +125 -125
  73. package/ui/css/timeline.css +58 -58
  74. package/ui/css/tokens.css +284 -284
  75. package/ui/favicon.svg +5 -5
  76. package/ui/index.html +99 -99
  77. package/ui/js/charts.js +526 -526
  78. package/ui/js/console-logger.js +172 -172
  79. package/ui/js/filters.js +247 -247
  80. package/ui/js/icons.js +129 -129
  81. package/ui/js/keyboard.js +229 -229
  82. package/ui/js/router.js +142 -142
  83. package/ui/js/theme.js +100 -100
  84. package/ui/js/time-tracker.js +248 -248
  85. package/ui/js/views/dashboard.js +870 -870
  86. package/ui/js/views/flash.js +47 -47
  87. package/ui/js/views/projects.js +745 -745
  88. package/ui/js/views/scrum.js +476 -476
  89. package/ui/js/views/settings.js +331 -331
  90. package/ui/js/views/timeline.js +265 -265
@@ -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
+ }