@basementuniverse/kanbn 1.0.0 → 1.1.0
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 +2 -0
- package/docs/commands/gantt.txt +3 -7
- package/docs/commands/history.txt +29 -0
- package/docs/task-structure.md +8 -4
- package/example/.kanbn/index.md +13 -10
- package/example/.kanbn/tasks/build-invoice-download-endpoint.md +3 -1
- package/example/.kanbn/tasks/create-self-serve-cancellation-flow.md +1 -1
- package/example/.kanbn/tasks/optimize-dashboard-first-load.md +1 -1
- package/example/.kanbn/tasks/prototype-report-export-scheduler.md +1 -1
- package/package.json +1 -1
- package/routes/add.json +2 -0
- package/routes/edit.json +2 -0
- package/routes/find.json +2 -0
- package/routes/history.json +27 -0
- package/routes/sort.json +2 -0
- package/skills/kanbn-plan/SKILL.md +211 -0
- package/skills/kanbn-plan/references/index-structure.md +89 -0
- package/skills/kanbn-plan/references/planning-rules.md +58 -0
- package/skills/kanbn-plan/references/task-structure.md +143 -0
- package/skills/kanbn-plan/scripts/check-dependency-cycles.mjs +273 -0
- package/skills/kanbn-plan/scripts/validate-kanbn.mjs +135 -0
- package/skills/kanbn-replan/SKILL.md +121 -0
- package/src/controller/add.js +49 -17
- package/src/controller/edit.js +91 -24
- package/src/controller/find.js +20 -3
- package/src/controller/gantt.js +2 -3
- package/src/controller/history.js +105 -0
- package/src/controller/sort.js +11 -3
- package/src/main.js +176 -14
- package/src/parse-task.js +20 -6
package/src/controller/add.js
CHANGED
|
@@ -23,10 +23,15 @@ async function interactiveCreateTask(taskData, taskIds, columnName, columnNames)
|
|
|
23
23
|
'due' in taskData.metadata &&
|
|
24
24
|
taskData.metadata.due != null
|
|
25
25
|
);
|
|
26
|
-
const
|
|
26
|
+
const plannedStartDateExists = (
|
|
27
27
|
'metadata' in taskData &&
|
|
28
|
-
'
|
|
29
|
-
taskData.metadata.
|
|
28
|
+
'plannedStart' in taskData.metadata &&
|
|
29
|
+
taskData.metadata.plannedStart != null
|
|
30
|
+
);
|
|
31
|
+
const plannedFinishDateExists = (
|
|
32
|
+
'metadata' in taskData &&
|
|
33
|
+
'plannedFinish' in taskData.metadata &&
|
|
34
|
+
taskData.metadata.plannedFinish != null
|
|
30
35
|
);
|
|
31
36
|
const assignedExists = (
|
|
32
37
|
'metadata' in taskData &&
|
|
@@ -83,18 +88,33 @@ async function interactiveCreateTask(taskData, taskIds, columnName, columnNames)
|
|
|
83
88
|
},
|
|
84
89
|
{
|
|
85
90
|
type: 'confirm',
|
|
86
|
-
name: '
|
|
87
|
-
message: 'Set a
|
|
91
|
+
name: 'setPlannedStart',
|
|
92
|
+
message: 'Set a planned start date?',
|
|
88
93
|
default: false,
|
|
89
|
-
when: answers => !
|
|
94
|
+
when: answers => !plannedStartDateExists
|
|
90
95
|
},
|
|
91
96
|
{
|
|
92
97
|
type: 'datepicker',
|
|
93
|
-
name: '
|
|
94
|
-
message: '
|
|
95
|
-
default:
|
|
98
|
+
name: 'plannedStart',
|
|
99
|
+
message: 'Planned start date:',
|
|
100
|
+
default: plannedStartDateExists ? taskData.metadata.plannedStart : new Date(),
|
|
96
101
|
format: ['Y', '/', 'MM', '/', 'DD'],
|
|
97
|
-
when: answers => answers.
|
|
102
|
+
when: answers => answers.setPlannedStart,
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
type: 'confirm',
|
|
106
|
+
name: 'setPlannedFinish',
|
|
107
|
+
message: 'Set a planned finish date?',
|
|
108
|
+
default: false,
|
|
109
|
+
when: answers => !plannedFinishDateExists
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
type: 'datepicker',
|
|
113
|
+
name: 'plannedFinish',
|
|
114
|
+
message: 'Planned finish date:',
|
|
115
|
+
default: plannedFinishDateExists ? taskData.metadata.plannedFinish : new Date(),
|
|
116
|
+
format: ['Y', '/', 'MM', '/', 'DD'],
|
|
117
|
+
when: answers => answers.setPlannedFinish,
|
|
98
118
|
},
|
|
99
119
|
{
|
|
100
120
|
type: 'confirm',
|
|
@@ -343,11 +363,20 @@ module.exports = async args => {
|
|
|
343
363
|
}
|
|
344
364
|
}
|
|
345
365
|
|
|
346
|
-
//
|
|
347
|
-
if (args.
|
|
348
|
-
taskData.metadata.
|
|
349
|
-
if (taskData.metadata.
|
|
350
|
-
utility.error('Unable to parse
|
|
366
|
+
// Planned start date
|
|
367
|
+
if (args.plannedStart) {
|
|
368
|
+
taskData.metadata.plannedStart = chrono.parseDate(utility.strArg(args.plannedStart));
|
|
369
|
+
if (taskData.metadata.plannedStart === null) {
|
|
370
|
+
utility.error('Unable to parse plannedStart date');
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Planned finish date
|
|
376
|
+
if (args.plannedFinish) {
|
|
377
|
+
taskData.metadata.plannedFinish = chrono.parseDate(utility.strArg(args.plannedFinish));
|
|
378
|
+
if (taskData.metadata.plannedFinish === null) {
|
|
379
|
+
utility.error('Unable to parse plannedFinish date');
|
|
351
380
|
return;
|
|
352
381
|
}
|
|
353
382
|
}
|
|
@@ -481,8 +510,11 @@ module.exports = async args => {
|
|
|
481
510
|
if ('due' in answers) {
|
|
482
511
|
taskData.metadata.due = answers.due;
|
|
483
512
|
}
|
|
484
|
-
if ('
|
|
485
|
-
taskData.metadata.
|
|
513
|
+
if ('plannedStart' in answers) {
|
|
514
|
+
taskData.metadata.plannedStart = answers.plannedStart;
|
|
515
|
+
}
|
|
516
|
+
if ('plannedFinish' in answers) {
|
|
517
|
+
taskData.metadata.plannedFinish = answers.plannedFinish;
|
|
486
518
|
}
|
|
487
519
|
if ('assigned' in answers) {
|
|
488
520
|
taskData.metadata.assigned = answers.assigned;
|
package/src/controller/edit.js
CHANGED
|
@@ -23,10 +23,15 @@ async function interactive(taskData, taskIds, columnName, columnNames) {
|
|
|
23
23
|
'due' in taskData.metadata &&
|
|
24
24
|
taskData.metadata.due != null
|
|
25
25
|
);
|
|
26
|
-
const
|
|
26
|
+
const plannedStartDateExists = (
|
|
27
27
|
'metadata' in taskData &&
|
|
28
|
-
'
|
|
29
|
-
taskData.metadata.
|
|
28
|
+
'plannedStart' in taskData.metadata &&
|
|
29
|
+
taskData.metadata.plannedStart != null
|
|
30
|
+
);
|
|
31
|
+
const plannedFinishDateExists = (
|
|
32
|
+
'metadata' in taskData &&
|
|
33
|
+
'plannedFinish' in taskData.metadata &&
|
|
34
|
+
taskData.metadata.plannedFinish != null
|
|
30
35
|
);
|
|
31
36
|
const assignedExists = (
|
|
32
37
|
'metadata' in taskData &&
|
|
@@ -108,10 +113,10 @@ async function interactive(taskData, taskIds, columnName, columnNames) {
|
|
|
108
113
|
},
|
|
109
114
|
{
|
|
110
115
|
type: 'expand',
|
|
111
|
-
name: '
|
|
112
|
-
message: 'Edit or remove
|
|
116
|
+
name: 'editPlannedStart',
|
|
117
|
+
message: 'Edit or remove planned start date?',
|
|
113
118
|
default: 'none',
|
|
114
|
-
when: answers =>
|
|
119
|
+
when: answers => plannedStartDateExists,
|
|
115
120
|
choices: [
|
|
116
121
|
{
|
|
117
122
|
key: 'e',
|
|
@@ -133,18 +138,58 @@ async function interactive(taskData, taskIds, columnName, columnNames) {
|
|
|
133
138
|
},
|
|
134
139
|
{
|
|
135
140
|
type: 'confirm',
|
|
136
|
-
name: '
|
|
137
|
-
message: 'Set a
|
|
141
|
+
name: 'setPlannedStart',
|
|
142
|
+
message: 'Set a planned start date?',
|
|
138
143
|
default: false,
|
|
139
|
-
when: answers => !
|
|
144
|
+
when: answers => !plannedStartDateExists
|
|
140
145
|
},
|
|
141
146
|
{
|
|
142
147
|
type: 'datepicker',
|
|
143
|
-
name: '
|
|
144
|
-
message: '
|
|
145
|
-
default:
|
|
148
|
+
name: 'plannedStart',
|
|
149
|
+
message: 'Planned start date:',
|
|
150
|
+
default: plannedStartDateExists ? taskData.metadata.plannedStart : new Date(),
|
|
146
151
|
format: ['Y', '/', 'MM', '/', 'DD'],
|
|
147
|
-
when: answers => answers.
|
|
152
|
+
when: answers => answers.setPlannedStart || answers.editPlannedStart === 'edit'
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
type: 'expand',
|
|
156
|
+
name: 'editPlannedFinish',
|
|
157
|
+
message: 'Edit or remove planned finish date?',
|
|
158
|
+
default: 'none',
|
|
159
|
+
when: answers => plannedFinishDateExists,
|
|
160
|
+
choices: [
|
|
161
|
+
{
|
|
162
|
+
key: 'e',
|
|
163
|
+
name: 'Edit',
|
|
164
|
+
value: 'edit'
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
key: 'r',
|
|
168
|
+
name: 'Remove',
|
|
169
|
+
value: 'remove'
|
|
170
|
+
},
|
|
171
|
+
new inquirer.Separator(),
|
|
172
|
+
{
|
|
173
|
+
key: 'n',
|
|
174
|
+
name: 'Do nothing',
|
|
175
|
+
value: 'none'
|
|
176
|
+
}
|
|
177
|
+
]
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
type: 'confirm',
|
|
181
|
+
name: 'setPlannedFinish',
|
|
182
|
+
message: 'Set a planned finish date?',
|
|
183
|
+
default: false,
|
|
184
|
+
when: answers => !plannedFinishDateExists
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
type: 'datepicker',
|
|
188
|
+
name: 'plannedFinish',
|
|
189
|
+
message: 'Planned finish date:',
|
|
190
|
+
default: plannedFinishDateExists ? taskData.metadata.plannedFinish : new Date(),
|
|
191
|
+
format: ['Y', '/', 'MM', '/', 'DD'],
|
|
192
|
+
when: answers => answers.setPlannedFinish || answers.editPlannedFinish === 'edit'
|
|
148
193
|
},
|
|
149
194
|
{
|
|
150
195
|
type: 'expand',
|
|
@@ -475,14 +520,26 @@ module.exports = async args => {
|
|
|
475
520
|
}
|
|
476
521
|
}
|
|
477
522
|
|
|
478
|
-
//
|
|
479
|
-
if (args.
|
|
523
|
+
// Planned start date
|
|
524
|
+
if (args.plannedStart) {
|
|
480
525
|
if (!('metadata' in taskData)) {
|
|
481
526
|
taskData.metadata = {};
|
|
482
527
|
}
|
|
483
|
-
taskData.metadata.
|
|
484
|
-
if (taskData.metadata.
|
|
485
|
-
utility.error('Unable to parse
|
|
528
|
+
taskData.metadata.plannedStart = chrono.parseDate(utility.strArg(args.plannedStart));
|
|
529
|
+
if (taskData.metadata.plannedStart === null) {
|
|
530
|
+
utility.error('Unable to parse plannedStart date');
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Planned finish date
|
|
536
|
+
if (args.plannedFinish) {
|
|
537
|
+
if (!('metadata' in taskData)) {
|
|
538
|
+
taskData.metadata = {};
|
|
539
|
+
}
|
|
540
|
+
taskData.metadata.plannedFinish = chrono.parseDate(utility.strArg(args.plannedFinish));
|
|
541
|
+
if (taskData.metadata.plannedFinish === null) {
|
|
542
|
+
utility.error('Unable to parse plannedFinish date');
|
|
486
543
|
return;
|
|
487
544
|
}
|
|
488
545
|
}
|
|
@@ -720,14 +777,24 @@ module.exports = async args => {
|
|
|
720
777
|
taskData.metadata.due = answers.due;
|
|
721
778
|
}
|
|
722
779
|
|
|
723
|
-
// Remove
|
|
724
|
-
if ('
|
|
725
|
-
delete taskData.metadata.
|
|
780
|
+
// Remove planned start date
|
|
781
|
+
if ('editPlannedStart' in answers && answers.editPlannedStart === 'remove') {
|
|
782
|
+
delete taskData.metadata.plannedStart;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// Planned start date
|
|
786
|
+
if ('plannedStart' in answers) {
|
|
787
|
+
taskData.metadata.plannedStart = answers.plannedStart;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// Remove planned finish date
|
|
791
|
+
if ('editPlannedFinish' in answers && answers.editPlannedFinish === 'remove') {
|
|
792
|
+
delete taskData.metadata.plannedFinish;
|
|
726
793
|
}
|
|
727
794
|
|
|
728
|
-
//
|
|
729
|
-
if ('
|
|
730
|
-
taskData.metadata.
|
|
795
|
+
// Planned finish date
|
|
796
|
+
if ('plannedFinish' in answers) {
|
|
797
|
+
taskData.metadata.plannedFinish = answers.plannedFinish;
|
|
731
798
|
}
|
|
732
799
|
|
|
733
800
|
// Remove assigned
|
package/src/controller/find.js
CHANGED
|
@@ -53,8 +53,13 @@ const searchFields = [
|
|
|
53
53
|
type: 'date'
|
|
54
54
|
},
|
|
55
55
|
{
|
|
56
|
-
name: '
|
|
57
|
-
field: '
|
|
56
|
+
name: 'Planned start',
|
|
57
|
+
field: 'plannedStart',
|
|
58
|
+
type: 'date'
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: 'Planned finish',
|
|
62
|
+
field: 'plannedFinish',
|
|
58
63
|
type: 'date'
|
|
59
64
|
},
|
|
60
65
|
{
|
|
@@ -369,7 +374,7 @@ module.exports = async args => {
|
|
|
369
374
|
}
|
|
370
375
|
}
|
|
371
376
|
if ('workload' in filters){
|
|
372
|
-
if (!convertNumericFilters(filters, '
|
|
377
|
+
if (!convertNumericFilters(filters, 'workload')) {
|
|
373
378
|
utility.error('Workload filter value must be numeric');
|
|
374
379
|
return;
|
|
375
380
|
}
|
|
@@ -412,6 +417,18 @@ module.exports = async args => {
|
|
|
412
417
|
return;
|
|
413
418
|
}
|
|
414
419
|
}
|
|
420
|
+
if ('plannedStart' in filters) {
|
|
421
|
+
if (!convertDateFilters(filters, 'plannedStart')) {
|
|
422
|
+
utility.error('Unable to parse plannedStart date');
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
if ('plannedFinish' in filters) {
|
|
427
|
+
if (!convertDateFilters(filters, 'plannedFinish')) {
|
|
428
|
+
utility.error('Unable to parse plannedFinish date');
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
415
432
|
|
|
416
433
|
// Check and add custom field filters
|
|
417
434
|
if ('customFields' in index.options) {
|
package/src/controller/gantt.js
CHANGED
|
@@ -363,10 +363,9 @@ module.exports = async args => {
|
|
|
363
363
|
});
|
|
364
364
|
|
|
365
365
|
// Render x-axis
|
|
366
|
-
console.log('');
|
|
367
366
|
const [ticks, labels] = renderXAxisLabels(data.from, data.to, barWidth, dateFormat);
|
|
368
|
-
console.log(` ${''.padEnd(maxNameWidth)}
|
|
369
|
-
console.log(` ${''.padEnd(maxNameWidth)}
|
|
367
|
+
console.log(` ${''.padEnd(maxNameWidth)} ${renderTickRow(ticks, nowPosition)}`);
|
|
368
|
+
console.log(` ${''.padEnd(maxNameWidth)} ${labels}`);
|
|
370
369
|
}
|
|
371
370
|
})
|
|
372
371
|
.catch(error => {
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
const kanbn = require('../main');
|
|
2
|
+
const utility = require('../utility');
|
|
3
|
+
const chrono = require('chrono-node');
|
|
4
|
+
const formatDate = require('dateformat');
|
|
5
|
+
|
|
6
|
+
function formatHistoryEvent(event) {
|
|
7
|
+
const details = [];
|
|
8
|
+
if ('column' in event.event) {
|
|
9
|
+
details.push(`column: ${event.event.column}`);
|
|
10
|
+
}
|
|
11
|
+
if ('fromColumn' in event.event || 'toColumn' in event.event) {
|
|
12
|
+
details.push(`column: ${event.event.fromColumn || '?'} -> ${event.event.toColumn || '?'}`);
|
|
13
|
+
}
|
|
14
|
+
if ('fromProgress' in event.event || 'toProgress' in event.event) {
|
|
15
|
+
details.push(`progress: ${event.event.fromProgress || 0} -> ${event.event.toProgress || 0}`);
|
|
16
|
+
}
|
|
17
|
+
if ('author' in event.event && event.event.author) {
|
|
18
|
+
details.push(`author: ${event.event.author}`);
|
|
19
|
+
}
|
|
20
|
+
return details.join(', ');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function formatMetadataEvent(event, dateFormat) {
|
|
24
|
+
if (!('field' in event.event) || !('value' in event.event) || !(event.event.value instanceof Date)) {
|
|
25
|
+
return '';
|
|
26
|
+
}
|
|
27
|
+
return `${event.event.field}: ${formatDate(event.event.value, dateFormat)}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = async args => {
|
|
31
|
+
|
|
32
|
+
// Make sure kanbn has been initialised
|
|
33
|
+
if (!await kanbn.initialised()) {
|
|
34
|
+
utility.error('Kanbn has not been initialised in this folder\nTry running: {b}kanbn init{b}');
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const index = await kanbn.getIndex();
|
|
38
|
+
|
|
39
|
+
// Get sprint numbers or names
|
|
40
|
+
let sprints = null;
|
|
41
|
+
if (args.sprint) {
|
|
42
|
+
sprints = utility.arrayArg(args.sprint).map(s => {
|
|
43
|
+
const sprintNumber = parseInt(s);
|
|
44
|
+
return isNaN(sprintNumber) ? s : sprintNumber;
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Get dates
|
|
49
|
+
let dates = null;
|
|
50
|
+
if (args.date) {
|
|
51
|
+
dates = utility.arrayArg(args.date);
|
|
52
|
+
if (dates.length) {
|
|
53
|
+
for (let i = 0; i < dates.length; i++) {
|
|
54
|
+
const dateValue = chrono.parseDate(dates[i]);
|
|
55
|
+
if (dateValue === null) {
|
|
56
|
+
utility.error('Unable to parse date');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
dates[i] = dateValue;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Get assigned
|
|
65
|
+
let assigned = null;
|
|
66
|
+
if (args.assigned) {
|
|
67
|
+
assigned = utility.strArg(args.assigned);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Get task ids
|
|
71
|
+
let taskIds = null;
|
|
72
|
+
if (args.task) {
|
|
73
|
+
taskIds = utility.arrayArg(args.task).map((taskId) => utility.trimLeftEscapeCharacters(taskId));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
kanbn
|
|
77
|
+
.history(sprints, dates, assigned, taskIds)
|
|
78
|
+
.then(data => {
|
|
79
|
+
if (args.json) {
|
|
80
|
+
console.log(JSON.stringify(data, null, 2));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (data.events.length === 0) {
|
|
85
|
+
console.log('No history events found');
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const dateFormat = kanbn.getDateFormat(index);
|
|
90
|
+
const lines = data.events.map((event) => {
|
|
91
|
+
const eventDetails = event.source === 'history'
|
|
92
|
+
? formatHistoryEvent(event)
|
|
93
|
+
: formatMetadataEvent(event, dateFormat);
|
|
94
|
+
const source = event.source === 'history' ? event.eventType : `metadata.${event.eventType}`;
|
|
95
|
+
const line = `${formatDate(event.date, dateFormat)} | ${event.task.id} | ${source}`;
|
|
96
|
+
return eventDetails
|
|
97
|
+
? `${line} | ${eventDetails}`
|
|
98
|
+
: line;
|
|
99
|
+
});
|
|
100
|
+
console.log(lines.join('\n'));
|
|
101
|
+
})
|
|
102
|
+
.catch(error => {
|
|
103
|
+
utility.error(error);
|
|
104
|
+
});
|
|
105
|
+
};
|
package/src/controller/sort.js
CHANGED
|
@@ -123,10 +123,18 @@ const sorterFields = [
|
|
|
123
123
|
filterable: false
|
|
124
124
|
},
|
|
125
125
|
{
|
|
126
|
-
name: '
|
|
127
|
-
field: '
|
|
126
|
+
name: 'Planned start date',
|
|
127
|
+
field: 'plannedStart',
|
|
128
128
|
options: [
|
|
129
|
-
'--
|
|
129
|
+
'--plannedStart'
|
|
130
|
+
],
|
|
131
|
+
filterable: false
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
name: 'Planned finish date',
|
|
135
|
+
field: 'plannedFinish',
|
|
136
|
+
options: [
|
|
137
|
+
'--plannedFinish'
|
|
130
138
|
],
|
|
131
139
|
filterable: false
|
|
132
140
|
},
|
package/src/main.js
CHANGED
|
@@ -266,7 +266,8 @@ function sortColumnInIndex(index, tasks, columnName, sorters) {
|
|
|
266
266
|
started: "started" in task.metadata ? task.metadata.started : "",
|
|
267
267
|
completed: "completed" in task.metadata ? task.metadata.completed : "",
|
|
268
268
|
due: "due" in task.metadata ? task.metadata.due : "",
|
|
269
|
-
|
|
269
|
+
plannedStart: "plannedStart" in task.metadata ? task.metadata.plannedStart : "",
|
|
270
|
+
plannedFinish: "plannedFinish" in task.metadata ? task.metadata.plannedFinish : "",
|
|
270
271
|
assigned: "assigned" in task.metadata ? task.metadata.assigned : "",
|
|
271
272
|
countSubTasks: task.subTasks.length,
|
|
272
273
|
subTasks: task.subTasks.map((subTask) => `[${subTask.completed ? "x" : ""}] ${subTask.text}`).join("\n"),
|
|
@@ -840,9 +841,10 @@ function endOfDay(date) {
|
|
|
840
841
|
*/
|
|
841
842
|
function getTaskGanttAnchor(task) {
|
|
842
843
|
return minDate([
|
|
843
|
-
task.metadata.
|
|
844
|
+
task.metadata.plannedStart,
|
|
844
845
|
task.created,
|
|
845
846
|
task.started,
|
|
847
|
+
task.metadata.plannedFinish,
|
|
846
848
|
task.completed
|
|
847
849
|
]) || new Date(0);
|
|
848
850
|
}
|
|
@@ -986,20 +988,47 @@ function buildGanttSchedule(index, tasks, now) {
|
|
|
986
988
|
.filter((scheduledDependency) => scheduledDependency)
|
|
987
989
|
.map((scheduledDependency) => scheduledDependency.end)
|
|
988
990
|
);
|
|
989
|
-
const
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
991
|
+
const hasPlannedStart = task.metadata.plannedStart instanceof Date;
|
|
992
|
+
const hasPlannedFinish = task.metadata.plannedFinish instanceof Date;
|
|
993
|
+
const hasStarted = task.started instanceof Date;
|
|
994
|
+
const hasCompleted = task.completed instanceof Date;
|
|
995
|
+
|
|
996
|
+
const duration = hasPlannedStart && hasPlannedFinish
|
|
997
|
+
? Math.max(DAY, task.metadata.plannedFinish.getTime() - task.metadata.plannedStart.getTime())
|
|
998
|
+
: hasStarted && hasCompleted
|
|
999
|
+
? Math.max(DAY, task.completed.getTime() - task.started.getTime())
|
|
1000
|
+
: Math.max(DAY, Math.ceil(Math.max(1, task.workload)) * DAY);
|
|
1001
|
+
|
|
1002
|
+
const preferredStart = hasPlannedStart
|
|
1003
|
+
? task.metadata.plannedStart
|
|
1004
|
+
: hasStarted
|
|
1005
|
+
? task.started
|
|
1006
|
+
: task.created;
|
|
1007
|
+
const finishHint = hasPlannedFinish
|
|
1008
|
+
? task.metadata.plannedFinish
|
|
1009
|
+
: hasCompleted
|
|
1010
|
+
? task.completed
|
|
1011
|
+
: null;
|
|
1012
|
+
|
|
1013
|
+
const derivedStartFromFinish = finishHint instanceof Date
|
|
1014
|
+
? new Date(finishHint.getTime() - duration)
|
|
1015
|
+
: null;
|
|
1016
|
+
const taskBaseDate = hasPlannedStart || hasStarted
|
|
1017
|
+
? preferredStart
|
|
1018
|
+
: (derivedStartFromFinish || preferredStart);
|
|
1019
|
+
|
|
994
1020
|
const scheduleAnchor = maxDate([
|
|
995
1021
|
taskBaseDate,
|
|
996
1022
|
dependencyEnd
|
|
997
1023
|
]) || new Date(0);
|
|
998
|
-
|
|
999
|
-
? Math.max(DAY, task.completed.getTime() - task.started.getTime())
|
|
1000
|
-
: Math.max(DAY, Math.ceil(Math.max(1, task.workload)) * DAY);
|
|
1024
|
+
|
|
1001
1025
|
let start = new Date(scheduleAnchor.getTime());
|
|
1002
|
-
let end =
|
|
1026
|
+
let end = hasPlannedFinish
|
|
1027
|
+
? new Date(task.metadata.plannedFinish.getTime())
|
|
1028
|
+
: hasCompleted
|
|
1029
|
+
? new Date(task.completed.getTime())
|
|
1030
|
+
: new Date(start.getTime() + duration);
|
|
1031
|
+
|
|
1003
1032
|
if (end.getTime() < start.getTime()) {
|
|
1004
1033
|
end = new Date(start.getTime() + duration);
|
|
1005
1034
|
}
|
|
@@ -1009,7 +1038,7 @@ function buildGanttSchedule(index, tasks, now) {
|
|
|
1009
1038
|
dependencies,
|
|
1010
1039
|
start,
|
|
1011
1040
|
end,
|
|
1012
|
-
blocked: dependencyEnd !== null &&
|
|
1041
|
+
blocked: dependencyEnd !== null && preferredStart instanceof Date && dependencyEnd.getTime() > preferredStart.getTime()
|
|
1013
1042
|
};
|
|
1014
1043
|
scheduleById.set(task.id, scheduledTask);
|
|
1015
1044
|
scheduledTasks.push(scheduledTask);
|
|
@@ -2520,7 +2549,7 @@ class Kanbn {
|
|
|
2520
2549
|
if (indexSprints === null) {
|
|
2521
2550
|
throw new Error(`No sprints defined`);
|
|
2522
2551
|
} else {
|
|
2523
|
-
for (sprint of sprints) {
|
|
2552
|
+
for (let sprint of sprints) {
|
|
2524
2553
|
let sprintIndex = null;
|
|
2525
2554
|
|
|
2526
2555
|
// Select sprint by number (1-based index)
|
|
@@ -2632,6 +2661,138 @@ class Kanbn {
|
|
|
2632
2661
|
return { series };
|
|
2633
2662
|
}
|
|
2634
2663
|
|
|
2664
|
+
/**
|
|
2665
|
+
* Get a chronological list of task events from metadata dates and structured task history
|
|
2666
|
+
* @param {?string[]} [sprints=null] The sprint names or numbers to include, or null for no sprint filter
|
|
2667
|
+
* @param {?Date[]} [dates=null] One or more dates to define a range filter, or null for no date filter
|
|
2668
|
+
* @param {?string} [assigned=null] The assigned user to filter for, or null for no assigned filter
|
|
2669
|
+
* @param {?string[]} [taskIds=null] One or more task ids to filter for, or null for no task id filter
|
|
2670
|
+
* @return {Promise<object>} A list of chronological task history events
|
|
2671
|
+
*/
|
|
2672
|
+
async history(sprints = null, dates = null, assigned = null, taskIds = null) {
|
|
2673
|
+
// Check if this folder has been initialised
|
|
2674
|
+
if (!(await this.initialised())) {
|
|
2675
|
+
throw new Error("Not initialised in this folder");
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2678
|
+
const index = await this.loadIndex();
|
|
2679
|
+
const selectedTaskIds = taskIds === null
|
|
2680
|
+
? null
|
|
2681
|
+
: new Set(taskIds.map((taskId) => removeFileExtension(taskId)));
|
|
2682
|
+
|
|
2683
|
+
// Build date filter periods from sprints and/or dates
|
|
2684
|
+
const periods = [];
|
|
2685
|
+
const indexSprints = "sprints" in index.options && index.options.sprints.length ? index.options.sprints : null;
|
|
2686
|
+
if (sprints !== null) {
|
|
2687
|
+
if (indexSprints === null) {
|
|
2688
|
+
throw new Error("No sprints defined");
|
|
2689
|
+
}
|
|
2690
|
+
for (let sprint of sprints) {
|
|
2691
|
+
let sprintIndex = null;
|
|
2692
|
+
|
|
2693
|
+
// Select sprint by number (1-based index)
|
|
2694
|
+
if (typeof sprint === "number") {
|
|
2695
|
+
if (sprint < 1 || sprint > indexSprints.length) {
|
|
2696
|
+
throw new Error(`Sprint ${sprint} does not exist`);
|
|
2697
|
+
}
|
|
2698
|
+
sprintIndex = sprint - 1;
|
|
2699
|
+
|
|
2700
|
+
// Or select sprint by name
|
|
2701
|
+
} else if (typeof sprint === "string") {
|
|
2702
|
+
sprintIndex = indexSprints.findIndex((s) => s.name === sprint);
|
|
2703
|
+
if (sprintIndex === -1) {
|
|
2704
|
+
throw new Error(`No sprint found with name "${sprint}"`);
|
|
2705
|
+
}
|
|
2706
|
+
}
|
|
2707
|
+
|
|
2708
|
+
if (sprintIndex === null) {
|
|
2709
|
+
throw new Error(`Invalid sprint "${sprint}"`);
|
|
2710
|
+
}
|
|
2711
|
+
|
|
2712
|
+
periods.push({
|
|
2713
|
+
type: "sprint",
|
|
2714
|
+
label: indexSprints[sprintIndex].name,
|
|
2715
|
+
from: new Date(indexSprints[sprintIndex].start),
|
|
2716
|
+
to: sprintIndex < indexSprints.length - 1
|
|
2717
|
+
? new Date(indexSprints[sprintIndex + 1].start)
|
|
2718
|
+
: new Date(),
|
|
2719
|
+
});
|
|
2720
|
+
}
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2723
|
+
if (dates !== null) {
|
|
2724
|
+
periods.push({
|
|
2725
|
+
type: "date",
|
|
2726
|
+
from: new Date(Math.min(...dates)),
|
|
2727
|
+
to: dates.length === 1 ? new Date() : new Date(Math.max(...dates)),
|
|
2728
|
+
});
|
|
2729
|
+
}
|
|
2730
|
+
|
|
2731
|
+
const includeEventDate = (date) => {
|
|
2732
|
+
if (!(date instanceof Date) || isNaN(date.getTime())) {
|
|
2733
|
+
return false;
|
|
2734
|
+
}
|
|
2735
|
+
if (periods.length === 0) {
|
|
2736
|
+
return true;
|
|
2737
|
+
}
|
|
2738
|
+
return periods.some((period) => date >= period.from && date <= period.to);
|
|
2739
|
+
};
|
|
2740
|
+
|
|
2741
|
+
const buildBaseTask = (task) => ({
|
|
2742
|
+
id: task.id,
|
|
2743
|
+
name: task.name,
|
|
2744
|
+
assigned: getTaskMetadata(task, "assigned") || null,
|
|
2745
|
+
column: findTaskColumn(index, task.id),
|
|
2746
|
+
});
|
|
2747
|
+
|
|
2748
|
+
const tasks = await this.loadAllTrackedTasks(index);
|
|
2749
|
+
let events = tasks
|
|
2750
|
+
.filter((task) => {
|
|
2751
|
+
const assignedUser = getTaskMetadata(task, "assigned") || null;
|
|
2752
|
+
return (
|
|
2753
|
+
(assigned === null || assignedUser === assigned) &&
|
|
2754
|
+
(selectedTaskIds === null || selectedTaskIds.has(task.id))
|
|
2755
|
+
);
|
|
2756
|
+
})
|
|
2757
|
+
.map((task) => {
|
|
2758
|
+
const taskBase = buildBaseTask(task);
|
|
2759
|
+
|
|
2760
|
+
const metadataEvents = Object.entries(task.metadata)
|
|
2761
|
+
.filter(([, value]) => value instanceof Date)
|
|
2762
|
+
.map(([field, value]) => ({
|
|
2763
|
+
date: value,
|
|
2764
|
+
source: "metadata",
|
|
2765
|
+
eventType: field,
|
|
2766
|
+
task: taskBase,
|
|
2767
|
+
event: {
|
|
2768
|
+
field,
|
|
2769
|
+
value,
|
|
2770
|
+
},
|
|
2771
|
+
}));
|
|
2772
|
+
|
|
2773
|
+
const historyEvents = ("history" in task && Array.isArray(task.history) ? task.history : [])
|
|
2774
|
+
.filter((historyEvent) => historyEvent.date instanceof Date)
|
|
2775
|
+
.map((historyEvent) => {
|
|
2776
|
+
const event = { ...historyEvent };
|
|
2777
|
+
delete event.date;
|
|
2778
|
+
return {
|
|
2779
|
+
date: historyEvent.date,
|
|
2780
|
+
source: "history",
|
|
2781
|
+
eventType: historyEvent.type,
|
|
2782
|
+
task: taskBase,
|
|
2783
|
+
event,
|
|
2784
|
+
};
|
|
2785
|
+
});
|
|
2786
|
+
|
|
2787
|
+
return [...metadataEvents, ...historyEvents];
|
|
2788
|
+
})
|
|
2789
|
+
.flat()
|
|
2790
|
+
.filter((event) => includeEventDate(event.date));
|
|
2791
|
+
|
|
2792
|
+
events.sort((a, b) => compareValues(a.date, b.date) || compareValues(a.task.id, b.task.id));
|
|
2793
|
+
return { periods, events };
|
|
2794
|
+
}
|
|
2795
|
+
|
|
2635
2796
|
/**
|
|
2636
2797
|
* Output gantt chart data
|
|
2637
2798
|
* @param {?string} [assigned=null] The assigned user to filter for, or null for no assigned filter
|
|
@@ -2659,7 +2820,8 @@ class Kanbn {
|
|
|
2659
2820
|
created,
|
|
2660
2821
|
started: "started" in task.metadata ? task.metadata.started : false,
|
|
2661
2822
|
completed: "completed" in task.metadata ? task.metadata.completed : false,
|
|
2662
|
-
|
|
2823
|
+
plannedStart: "plannedStart" in task.metadata ? task.metadata.plannedStart : false,
|
|
2824
|
+
plannedFinish: "plannedFinish" in task.metadata ? task.metadata.plannedFinish : false,
|
|
2663
2825
|
workload: taskWorkload(index, task),
|
|
2664
2826
|
progress: taskProgress(index, task),
|
|
2665
2827
|
column: taskColumn,
|