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