@basementuniverse/kanbn 1.0.1 → 1.1.1
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/PLANS.md +9 -0
- package/README.md +1 -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-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 +48 -4
- package/src/controller/history.js +105 -0
- package/src/controller/sort.js +11 -3
- package/src/main.js +200 -17
- package/src/parse-task.js +20 -6
package/PLANS.md
ADDED
package/README.md
CHANGED
package/docs/commands/gantt.txt
CHANGED
|
@@ -3,14 +3,10 @@
|
|
|
3
3
|
|
|
4
4
|
Show a gantt chart for tracked tasks.
|
|
5
5
|
|
|
6
|
-
Tasks are ordered using `depends-on` relations when present. The chart
|
|
7
|
-
`postponed` metadata field to estimate when work can start.
|
|
6
|
+
Tasks are ordered using `depends-on` relations when present. The chart prioritizes optional `plannedStart`/`plannedFinish` metadata when arranging bars, then falls back through `started`/`completed` dates, and finally uses workload-based best-effort scheduling when no timeline dates are available.
|
|
8
7
|
The rendered chart includes:
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
• Dependency arrows (└─→) showing which tasks depend on others
|
|
12
|
-
• Task bars with progress indicators: █ (completed), ▓ (in progress), ░ (not started)
|
|
13
|
-
• A blocked indicator (⧗) showing tasks delayed by dependencies
|
|
8
|
+
Dependency arrows (└─→) showing which tasks depend on others
|
|
9
|
+
A blocked indicator (⧗) showing tasks delayed by dependencies
|
|
14
10
|
|
|
15
11
|
Options:
|
|
16
12
|
{b}kanbn gantt --json{b}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{b}kanbn history{b}
|
|
2
|
+
{b}kanbn hi{b}
|
|
3
|
+
|
|
4
|
+
Show a chronological listing of task events.
|
|
5
|
+
|
|
6
|
+
Options:
|
|
7
|
+
{b}kanbn history --json{b}
|
|
8
|
+
{b}kanbn history -j{b}
|
|
9
|
+
Output raw history data in JSON format.
|
|
10
|
+
|
|
11
|
+
{b}kanbn history --sprint N|"name"{b}
|
|
12
|
+
{b}kanbn history -p N|"name"{b}
|
|
13
|
+
Filter history to one or more sprints.
|
|
14
|
+
This option can be repeated to include multiple sprints.
|
|
15
|
+
|
|
16
|
+
{b}kanbn history --date "date"{b}
|
|
17
|
+
{b}kanbn history -d "date"{b}
|
|
18
|
+
Filter history by date.
|
|
19
|
+
If one date is provided, show history from this date up to the present date.
|
|
20
|
+
If two or more dates are provided, show history from the earliest to latest date.
|
|
21
|
+
|
|
22
|
+
{b}kanbn history --assigned "user"{b}
|
|
23
|
+
{b}kanbn history -a "user"{b}
|
|
24
|
+
Filter for tasks assigned to a specific user.
|
|
25
|
+
|
|
26
|
+
{b}kanbn history --task "task-id"{b}
|
|
27
|
+
{b}kanbn history -t "task-id"{b}
|
|
28
|
+
Filter for one or more task ids.
|
|
29
|
+
This option can be repeated.
|
package/docs/task-structure.md
CHANGED
|
@@ -13,7 +13,8 @@ progress: 0.5
|
|
|
13
13
|
started: 2021-03-21T04:58:38.653Z
|
|
14
14
|
completed: 2021-03-21T04:58:38.653Z
|
|
15
15
|
due: 2021-03-21T04:58:38.653Z
|
|
16
|
-
|
|
16
|
+
plannedStart: 2021-03-20T04:58:38.653Z
|
|
17
|
+
plannedFinish: 2021-03-24T04:58:38.653Z
|
|
17
18
|
---
|
|
18
19
|
|
|
19
20
|
# Task Name
|
|
@@ -117,10 +118,13 @@ The date and time that the task was completed.
|
|
|
117
118
|
|
|
118
119
|
The date and time that the task is due to be completed.
|
|
119
120
|
|
|
120
|
-
### `
|
|
121
|
+
### `plannedStart`
|
|
121
122
|
|
|
122
|
-
|
|
123
|
-
|
|
123
|
+
An optional date and time used as a preferred start point for gantt scheduling.
|
|
124
|
+
|
|
125
|
+
### `plannedFinish`
|
|
126
|
+
|
|
127
|
+
An optional date and time used as a preferred finish point for gantt scheduling.
|
|
124
128
|
|
|
125
129
|
## Sub-tasks
|
|
126
130
|
|
package/example/.kanbn/index.md
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
---
|
|
2
2
|
startedColumns:
|
|
3
|
-
- In Progress
|
|
3
|
+
- 'In Progress'
|
|
4
4
|
completedColumns:
|
|
5
5
|
- Done
|
|
6
6
|
sprints:
|
|
7
|
-
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
7
|
+
-
|
|
8
|
+
start: 2026-07-01T09:00:00.000Z
|
|
9
|
+
name: 'Foundation Sprint'
|
|
10
|
+
description: 'Baseline product and infrastructure work.'
|
|
11
|
+
-
|
|
12
|
+
start: 2026-07-08T09:00:00.000Z
|
|
13
|
+
name: 'Workflow Sprint'
|
|
14
|
+
description: 'Core team workflow and onboarding features.'
|
|
15
|
+
-
|
|
16
|
+
start: 2026-07-15T09:00:00.000Z
|
|
17
|
+
name: 'Stabilisation Sprint'
|
|
18
|
+
description: 'Reliability and release readiness improvements.'
|
|
16
19
|
taskWorkloadTags:
|
|
17
20
|
Tiny: 1
|
|
18
21
|
Small: 2
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
---
|
|
2
2
|
created: 2026-07-05T10:00:00.000Z
|
|
3
|
-
updated: 2026-07-
|
|
3
|
+
updated: 2026-07-24T22:28:06.841Z
|
|
4
4
|
tags:
|
|
5
5
|
- Platform
|
|
6
6
|
- Medium
|
|
7
7
|
progress: 0
|
|
8
8
|
due: 2026-07-07T10:00:00.000Z
|
|
9
|
+
plannedStart: 2026-07-06T14:05:13.527Z
|
|
10
|
+
plannedFinish: 2026-07-11T11:20:37.323Z
|
|
9
11
|
---
|
|
10
12
|
|
|
11
13
|
# Build Invoice Download Endpoint
|
package/package.json
CHANGED
package/routes/add.json
CHANGED
package/routes/edit.json
CHANGED
package/routes/find.json
CHANGED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Task History",
|
|
3
|
+
"commands": [
|
|
4
|
+
"history",
|
|
5
|
+
"hi"
|
|
6
|
+
],
|
|
7
|
+
"args": {
|
|
8
|
+
"boolean": [
|
|
9
|
+
"json"
|
|
10
|
+
],
|
|
11
|
+
"string": [
|
|
12
|
+
"date",
|
|
13
|
+
"sprint",
|
|
14
|
+
"assigned",
|
|
15
|
+
"task"
|
|
16
|
+
],
|
|
17
|
+
"alias": {
|
|
18
|
+
"json": ["j"],
|
|
19
|
+
"date": ["d"],
|
|
20
|
+
"sprint": ["p"],
|
|
21
|
+
"assigned": ["a"],
|
|
22
|
+
"task": ["t"]
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"controller": "./src/controller/history",
|
|
26
|
+
"help": "./docs/commands/history.txt"
|
|
27
|
+
}
|
package/routes/sort.json
CHANGED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: kanbn-replan
|
|
3
|
+
description: Recalculate unfinished Kanbn task dates from the current project state, preserving completed tasks and writing updated scheduled dates back to existing task markdown files.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Kanbn Replan
|
|
7
|
+
|
|
8
|
+
Use this skill when a Kanbn board needs to be rescheduled after time has passed, work was paused, or the current Gantt chart shows that unfinished work no longer fits the available timeline.
|
|
9
|
+
|
|
10
|
+
Do not use this skill to redesign scope, rewrite completed tasks, or invent new task structure. Keep the workflow focused on rescheduling existing incomplete work.
|
|
11
|
+
|
|
12
|
+
## Core Contract
|
|
13
|
+
|
|
14
|
+
Your output must stay inside the Kanbn task surface:
|
|
15
|
+
|
|
16
|
+
- `.kanbn/index.md`
|
|
17
|
+
- `.kanbn/tasks/*.md`
|
|
18
|
+
|
|
19
|
+
You may read the current board and gantt output, but the end result should be a revised schedule for existing tasks, not a new plan.
|
|
20
|
+
|
|
21
|
+
Do not modify completed tasks unless the user explicitly asks to reopen them. Leave completed task descriptions, relations, and completion history intact.
|
|
22
|
+
|
|
23
|
+
## When to Use
|
|
24
|
+
|
|
25
|
+
Use this skill when the user wants to:
|
|
26
|
+
|
|
27
|
+
- move overdue work forward to a realistic schedule
|
|
28
|
+
- reschedule unfinished tasks after a long interruption
|
|
29
|
+
- spread work out to reduce overload
|
|
30
|
+
- refresh `started`, `plannedStart`, `plannedFinish`, and `due` dates for incomplete tasks
|
|
31
|
+
- inspect Gantt output and derive a revised schedule from it
|
|
32
|
+
|
|
33
|
+
## Workflow
|
|
34
|
+
|
|
35
|
+
### 1. Inspect the current board state
|
|
36
|
+
|
|
37
|
+
Gather the current planning context before making edits:
|
|
38
|
+
|
|
39
|
+
- current date
|
|
40
|
+
- tracked tasks and their columns
|
|
41
|
+
- task metadata relevant to scheduling (`started`, `plannedStart`, `plannedFinish`, `due`, `progress`, workload tags)
|
|
42
|
+
- dependency relations
|
|
43
|
+
- the current Gantt layout
|
|
44
|
+
|
|
45
|
+
Prefer `kanbn gantt -j` for the machine-readable schedule. If the replanning should be anchored to a specific point in time, pass `--now "date"` so the schedule is evaluated against the intended date.
|
|
46
|
+
|
|
47
|
+
### 2. Decide what is in scope
|
|
48
|
+
|
|
49
|
+
Reschedule only incomplete work.
|
|
50
|
+
|
|
51
|
+
Treat these as locked unless the user says otherwise:
|
|
52
|
+
|
|
53
|
+
- tasks marked complete
|
|
54
|
+
- completed subtasks that should remain completed
|
|
55
|
+
- task names, descriptions, comments, and history
|
|
56
|
+
- dependency structure, unless the task graph itself is wrong and must be repaired to make the schedule valid
|
|
57
|
+
|
|
58
|
+
If a task is incomplete but already in progress, preserve evidence of work where that matters. Prefer updating scheduling metadata over rewriting task content.
|
|
59
|
+
|
|
60
|
+
### 3. Rebuild the schedule
|
|
61
|
+
|
|
62
|
+
Use the gantt data and dependency order to decide new dates.
|
|
63
|
+
|
|
64
|
+
General rules:
|
|
65
|
+
|
|
66
|
+
- dependency order wins over wishful dates
|
|
67
|
+
- `plannedStart` should be the earliest preferred start floor when present
|
|
68
|
+
- `plannedFinish` should be the preferred finish target when present
|
|
69
|
+
- `started` should reflect when work actually begins or resumes
|
|
70
|
+
- `due` should reflect the target finish date for the current schedule
|
|
71
|
+
- if a task would otherwise overlap too much with adjacent work, shift it later rather than ignoring the overload
|
|
72
|
+
- if the user has returned after a long gap, treat incomplete tasks as candidates for re-baselining from the current date forward
|
|
73
|
+
|
|
74
|
+
When task size information is available, use it to distribute work more realistically. If exact sizing is missing, use the gantt layout, task relations, and existing metadata as the primary signals.
|
|
75
|
+
|
|
76
|
+
If `kanbn gantt -j` reports dependency cycles, stop and resolve or report them before rescheduling dependent tasks.
|
|
77
|
+
|
|
78
|
+
### 4. Edit task files directly
|
|
79
|
+
|
|
80
|
+
Update the existing `.kanbn/tasks/*.md` files rather than creating a new board.
|
|
81
|
+
|
|
82
|
+
Prefer minimal metadata changes:
|
|
83
|
+
|
|
84
|
+
- keep task titles and descriptions stable
|
|
85
|
+
- update only the date fields needed to express the new plan
|
|
86
|
+
- preserve completed tasks exactly as they are
|
|
87
|
+
- avoid changing `created`, `completed`, comments, or history unless the user explicitly asks
|
|
88
|
+
|
|
89
|
+
If the task file format uses front matter or a `Metadata` section, keep it valid and consistent with the repo docs.
|
|
90
|
+
|
|
91
|
+
### 5. Verify the result
|
|
92
|
+
|
|
93
|
+
After editing, validate the board:
|
|
94
|
+
|
|
95
|
+
1. Run `kanbn validate`
|
|
96
|
+
2. Re-run `kanbn gantt -j` to confirm the new dates produce a coherent schedule
|
|
97
|
+
3. Check for obvious dependency or date regressions
|
|
98
|
+
4. Fix any malformed task files before stopping
|
|
99
|
+
|
|
100
|
+
## Scheduling Heuristics
|
|
101
|
+
|
|
102
|
+
Use these heuristics when the user has not specified a custom policy:
|
|
103
|
+
|
|
104
|
+
- keep completed tasks unchanged
|
|
105
|
+
- schedule tasks in dependency order
|
|
106
|
+
- move overdue unfinished tasks forward if they cannot realistically finish on their original due dates
|
|
107
|
+
- avoid piling every incomplete task onto the earliest possible date
|
|
108
|
+
- keep `due` dates aligned with the revised execution window
|
|
109
|
+
- if a task has no meaningful `started` date yet, set it when work is planned to resume rather than inventing a long-past start
|
|
110
|
+
- use workload tags and any existing custom scheduling fields as additional inputs when the board defines them
|
|
111
|
+
|
|
112
|
+
## References
|
|
113
|
+
|
|
114
|
+
Use the repo docs as the source of truth for the data model and commands:
|
|
115
|
+
|
|
116
|
+
- `docs/commands/gantt.txt`
|
|
117
|
+
- `docs/commands/edit.txt`
|
|
118
|
+
- `docs/commands/validate.txt`
|
|
119
|
+
- `docs/task-structure.md`
|
|
120
|
+
- `docs/index-structure.md`
|
|
121
|
+
|
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
|
@@ -64,7 +64,8 @@ function createChartCell() {
|
|
|
64
64
|
return {
|
|
65
65
|
bg: null,
|
|
66
66
|
lineMask: 0,
|
|
67
|
-
arrowChar: null
|
|
67
|
+
arrowChar: null,
|
|
68
|
+
markerChar: null
|
|
68
69
|
};
|
|
69
70
|
}
|
|
70
71
|
|
|
@@ -121,6 +122,38 @@ function routeDependency(grid, sourceRow, sourceCol, targetRow, targetCol) {
|
|
|
121
122
|
grid[targetRow][targetCol].arrowChar = targetCol >= routeCol ? '→' : '←';
|
|
122
123
|
}
|
|
123
124
|
|
|
125
|
+
function setMarker(cell, markerChar) {
|
|
126
|
+
if (!cell.arrowChar && !cell.markerChar) {
|
|
127
|
+
cell.markerChar = markerChar;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function routeIncomingExternalDependency(grid, row, targetCol) {
|
|
132
|
+
const arrowCol = Math.max(0, targetCol);
|
|
133
|
+
grid[row][arrowCol].arrowChar = '→';
|
|
134
|
+
|
|
135
|
+
if (arrowCol > 0) {
|
|
136
|
+
addMask(grid[row][arrowCol], DIR_LEFT);
|
|
137
|
+
addMask(grid[row][arrowCol - 1], DIR_RIGHT);
|
|
138
|
+
}
|
|
139
|
+
if (arrowCol > 1) {
|
|
140
|
+
setMarker(grid[row][arrowCol - 2], '…');
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function routeOutgoingExternalDependency(grid, row, sourceCol, width) {
|
|
145
|
+
const arrowCol = Math.min(width - 1, sourceCol);
|
|
146
|
+
grid[row][arrowCol].arrowChar = '→';
|
|
147
|
+
|
|
148
|
+
if (arrowCol < width - 1) {
|
|
149
|
+
addMask(grid[row][arrowCol], DIR_RIGHT);
|
|
150
|
+
addMask(grid[row][arrowCol + 1], DIR_LEFT);
|
|
151
|
+
}
|
|
152
|
+
if (arrowCol < width - 2) {
|
|
153
|
+
setMarker(grid[row][arrowCol + 2], '…');
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
124
157
|
function buildDependencyOverlay(tasks, barGeometries, width) {
|
|
125
158
|
const grid = createChartGrid(tasks.length, width);
|
|
126
159
|
const taskRowMap = new Map(tasks.map((task, index) => [task.id, index]));
|
|
@@ -140,6 +173,14 @@ function buildDependencyOverlay(tasks, barGeometries, width) {
|
|
|
140
173
|
barGeometries[targetRow].start
|
|
141
174
|
);
|
|
142
175
|
});
|
|
176
|
+
|
|
177
|
+
if (Array.isArray(task.externalIncomingDependencies) && task.externalIncomingDependencies.length > 0) {
|
|
178
|
+
routeIncomingExternalDependency(grid, targetRow, barGeometries[targetRow].start);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (Array.isArray(task.externalOutgoingDependents) && task.externalOutgoingDependents.length > 0) {
|
|
182
|
+
routeOutgoingExternalDependency(grid, targetRow, barGeometries[targetRow].end, width);
|
|
183
|
+
}
|
|
143
184
|
});
|
|
144
185
|
|
|
145
186
|
return grid;
|
|
@@ -165,6 +206,10 @@ function renderChartRow(gridRow, barGeometry, barBackground, nowPosition) {
|
|
|
165
206
|
return styleCell(lineChar, ANSI_FG_LINE, bg);
|
|
166
207
|
}
|
|
167
208
|
|
|
209
|
+
if (cell.markerChar) {
|
|
210
|
+
return styleCell(cell.markerChar, ANSI_FG_LINE, bg);
|
|
211
|
+
}
|
|
212
|
+
|
|
168
213
|
if (nowPosition === column) {
|
|
169
214
|
return styleCell(NOW_MARKER, ANSI_FG_NOW, bg);
|
|
170
215
|
}
|
|
@@ -363,10 +408,9 @@ module.exports = async args => {
|
|
|
363
408
|
});
|
|
364
409
|
|
|
365
410
|
// Render x-axis
|
|
366
|
-
console.log('');
|
|
367
411
|
const [ticks, labels] = renderXAxisLabels(data.from, data.to, barWidth, dateFormat);
|
|
368
|
-
console.log(` ${''.padEnd(maxNameWidth)}
|
|
369
|
-
console.log(` ${''.padEnd(maxNameWidth)}
|
|
412
|
+
console.log(` ${''.padEnd(maxNameWidth)} ${renderTickRow(ticks, nowPosition)}`);
|
|
413
|
+
console.log(` ${''.padEnd(maxNameWidth)} ${labels}`);
|
|
370
414
|
}
|
|
371
415
|
})
|
|
372
416
|
.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
|
}
|
|
@@ -903,14 +905,26 @@ function findDependencyCycle(dependencyMap) {
|
|
|
903
905
|
* @param {Date} now
|
|
904
906
|
* @return {object}
|
|
905
907
|
*/
|
|
906
|
-
function buildGanttSchedule(index, tasks, now) {
|
|
908
|
+
function buildGanttSchedule(index, tasks, now, trackedTaskIds = new Set()) {
|
|
907
909
|
const taskById = new Map(tasks.map((task) => [task.id, task]));
|
|
908
910
|
const dependencyMap = new Map(tasks.map((task) => [task.id, new Set()]));
|
|
909
911
|
const dependentsMap = new Map();
|
|
910
912
|
const indegree = new Map(tasks.map((task) => [task.id, 0]));
|
|
913
|
+
const externalIncomingDependenciesMap = new Map(tasks.map((task) => [task.id, new Set()]));
|
|
914
|
+
const externalOutgoingDependentsMap = new Map(tasks.map((task) => [task.id, new Set()]));
|
|
915
|
+
|
|
916
|
+
const taskExistsInBoard = (taskId) => trackedTaskIds.has(taskId);
|
|
911
917
|
|
|
912
918
|
const addDependencyEdge = (dependencyId, dependentId) => {
|
|
919
|
+
if (!taskById.has(dependentId)) {
|
|
920
|
+
throw new Error(`Task "${dependentId}" depends on missing task "${dependencyId}"`);
|
|
921
|
+
}
|
|
922
|
+
|
|
913
923
|
if (!taskById.has(dependencyId)) {
|
|
924
|
+
if (taskExistsInBoard(dependencyId)) {
|
|
925
|
+
externalIncomingDependenciesMap.get(dependentId).add(dependencyId);
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
914
928
|
throw new Error(`Task "${dependentId}" depends on missing task "${dependencyId}"`);
|
|
915
929
|
}
|
|
916
930
|
|
|
@@ -937,7 +951,13 @@ function buildGanttSchedule(index, tasks, now) {
|
|
|
937
951
|
if (relationType === 'depends-on') {
|
|
938
952
|
addDependencyEdge(relation.task, task.id);
|
|
939
953
|
} else if (relationType === 'blocks') {
|
|
940
|
-
|
|
954
|
+
if (taskById.has(relation.task)) {
|
|
955
|
+
addDependencyEdge(task.id, relation.task);
|
|
956
|
+
} else if (taskExistsInBoard(relation.task)) {
|
|
957
|
+
externalOutgoingDependentsMap.get(task.id).add(relation.task);
|
|
958
|
+
} else {
|
|
959
|
+
throw new Error(`Task "${task.id}" blocks missing task "${relation.task}"`);
|
|
960
|
+
}
|
|
941
961
|
}
|
|
942
962
|
});
|
|
943
963
|
});
|
|
@@ -986,20 +1006,47 @@ function buildGanttSchedule(index, tasks, now) {
|
|
|
986
1006
|
.filter((scheduledDependency) => scheduledDependency)
|
|
987
1007
|
.map((scheduledDependency) => scheduledDependency.end)
|
|
988
1008
|
);
|
|
989
|
-
const
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
1009
|
+
const hasPlannedStart = task.metadata.plannedStart instanceof Date;
|
|
1010
|
+
const hasPlannedFinish = task.metadata.plannedFinish instanceof Date;
|
|
1011
|
+
const hasStarted = task.started instanceof Date;
|
|
1012
|
+
const hasCompleted = task.completed instanceof Date;
|
|
1013
|
+
|
|
1014
|
+
const duration = hasPlannedStart && hasPlannedFinish
|
|
1015
|
+
? Math.max(DAY, task.metadata.plannedFinish.getTime() - task.metadata.plannedStart.getTime())
|
|
1016
|
+
: hasStarted && hasCompleted
|
|
1017
|
+
? Math.max(DAY, task.completed.getTime() - task.started.getTime())
|
|
1018
|
+
: Math.max(DAY, Math.ceil(Math.max(1, task.workload)) * DAY);
|
|
1019
|
+
|
|
1020
|
+
const preferredStart = hasPlannedStart
|
|
1021
|
+
? task.metadata.plannedStart
|
|
1022
|
+
: hasStarted
|
|
1023
|
+
? task.started
|
|
1024
|
+
: task.created;
|
|
1025
|
+
const finishHint = hasPlannedFinish
|
|
1026
|
+
? task.metadata.plannedFinish
|
|
1027
|
+
: hasCompleted
|
|
1028
|
+
? task.completed
|
|
1029
|
+
: null;
|
|
1030
|
+
|
|
1031
|
+
const derivedStartFromFinish = finishHint instanceof Date
|
|
1032
|
+
? new Date(finishHint.getTime() - duration)
|
|
1033
|
+
: null;
|
|
1034
|
+
const taskBaseDate = hasPlannedStart || hasStarted
|
|
1035
|
+
? preferredStart
|
|
1036
|
+
: (derivedStartFromFinish || preferredStart);
|
|
1037
|
+
|
|
994
1038
|
const scheduleAnchor = maxDate([
|
|
995
1039
|
taskBaseDate,
|
|
996
1040
|
dependencyEnd
|
|
997
1041
|
]) || 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);
|
|
1042
|
+
|
|
1001
1043
|
let start = new Date(scheduleAnchor.getTime());
|
|
1002
|
-
let end =
|
|
1044
|
+
let end = hasPlannedFinish
|
|
1045
|
+
? new Date(task.metadata.plannedFinish.getTime())
|
|
1046
|
+
: hasCompleted
|
|
1047
|
+
? new Date(task.completed.getTime())
|
|
1048
|
+
: new Date(start.getTime() + duration);
|
|
1049
|
+
|
|
1003
1050
|
if (end.getTime() < start.getTime()) {
|
|
1004
1051
|
end = new Date(start.getTime() + duration);
|
|
1005
1052
|
}
|
|
@@ -1007,9 +1054,11 @@ function buildGanttSchedule(index, tasks, now) {
|
|
|
1007
1054
|
const scheduledTask = {
|
|
1008
1055
|
...task,
|
|
1009
1056
|
dependencies,
|
|
1057
|
+
externalIncomingDependencies: [...(externalIncomingDependenciesMap.get(task.id) || [])],
|
|
1058
|
+
externalOutgoingDependents: [...(externalOutgoingDependentsMap.get(task.id) || [])],
|
|
1010
1059
|
start,
|
|
1011
1060
|
end,
|
|
1012
|
-
blocked: dependencyEnd !== null &&
|
|
1061
|
+
blocked: dependencyEnd !== null && preferredStart instanceof Date && dependencyEnd.getTime() > preferredStart.getTime()
|
|
1013
1062
|
};
|
|
1014
1063
|
scheduleById.set(task.id, scheduledTask);
|
|
1015
1064
|
scheduledTasks.push(scheduledTask);
|
|
@@ -2520,7 +2569,7 @@ class Kanbn {
|
|
|
2520
2569
|
if (indexSprints === null) {
|
|
2521
2570
|
throw new Error(`No sprints defined`);
|
|
2522
2571
|
} else {
|
|
2523
|
-
for (sprint of sprints) {
|
|
2572
|
+
for (let sprint of sprints) {
|
|
2524
2573
|
let sprintIndex = null;
|
|
2525
2574
|
|
|
2526
2575
|
// Select sprint by number (1-based index)
|
|
@@ -2632,6 +2681,138 @@ class Kanbn {
|
|
|
2632
2681
|
return { series };
|
|
2633
2682
|
}
|
|
2634
2683
|
|
|
2684
|
+
/**
|
|
2685
|
+
* Get a chronological list of task events from metadata dates and structured task history
|
|
2686
|
+
* @param {?string[]} [sprints=null] The sprint names or numbers to include, or null for no sprint filter
|
|
2687
|
+
* @param {?Date[]} [dates=null] One or more dates to define a range filter, or null for no date filter
|
|
2688
|
+
* @param {?string} [assigned=null] The assigned user to filter for, or null for no assigned filter
|
|
2689
|
+
* @param {?string[]} [taskIds=null] One or more task ids to filter for, or null for no task id filter
|
|
2690
|
+
* @return {Promise<object>} A list of chronological task history events
|
|
2691
|
+
*/
|
|
2692
|
+
async history(sprints = null, dates = null, assigned = null, taskIds = null) {
|
|
2693
|
+
// Check if this folder has been initialised
|
|
2694
|
+
if (!(await this.initialised())) {
|
|
2695
|
+
throw new Error("Not initialised in this folder");
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
const index = await this.loadIndex();
|
|
2699
|
+
const selectedTaskIds = taskIds === null
|
|
2700
|
+
? null
|
|
2701
|
+
: new Set(taskIds.map((taskId) => removeFileExtension(taskId)));
|
|
2702
|
+
|
|
2703
|
+
// Build date filter periods from sprints and/or dates
|
|
2704
|
+
const periods = [];
|
|
2705
|
+
const indexSprints = "sprints" in index.options && index.options.sprints.length ? index.options.sprints : null;
|
|
2706
|
+
if (sprints !== null) {
|
|
2707
|
+
if (indexSprints === null) {
|
|
2708
|
+
throw new Error("No sprints defined");
|
|
2709
|
+
}
|
|
2710
|
+
for (let sprint of sprints) {
|
|
2711
|
+
let sprintIndex = null;
|
|
2712
|
+
|
|
2713
|
+
// Select sprint by number (1-based index)
|
|
2714
|
+
if (typeof sprint === "number") {
|
|
2715
|
+
if (sprint < 1 || sprint > indexSprints.length) {
|
|
2716
|
+
throw new Error(`Sprint ${sprint} does not exist`);
|
|
2717
|
+
}
|
|
2718
|
+
sprintIndex = sprint - 1;
|
|
2719
|
+
|
|
2720
|
+
// Or select sprint by name
|
|
2721
|
+
} else if (typeof sprint === "string") {
|
|
2722
|
+
sprintIndex = indexSprints.findIndex((s) => s.name === sprint);
|
|
2723
|
+
if (sprintIndex === -1) {
|
|
2724
|
+
throw new Error(`No sprint found with name "${sprint}"`);
|
|
2725
|
+
}
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2728
|
+
if (sprintIndex === null) {
|
|
2729
|
+
throw new Error(`Invalid sprint "${sprint}"`);
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2732
|
+
periods.push({
|
|
2733
|
+
type: "sprint",
|
|
2734
|
+
label: indexSprints[sprintIndex].name,
|
|
2735
|
+
from: new Date(indexSprints[sprintIndex].start),
|
|
2736
|
+
to: sprintIndex < indexSprints.length - 1
|
|
2737
|
+
? new Date(indexSprints[sprintIndex + 1].start)
|
|
2738
|
+
: new Date(),
|
|
2739
|
+
});
|
|
2740
|
+
}
|
|
2741
|
+
}
|
|
2742
|
+
|
|
2743
|
+
if (dates !== null) {
|
|
2744
|
+
periods.push({
|
|
2745
|
+
type: "date",
|
|
2746
|
+
from: new Date(Math.min(...dates)),
|
|
2747
|
+
to: dates.length === 1 ? new Date() : new Date(Math.max(...dates)),
|
|
2748
|
+
});
|
|
2749
|
+
}
|
|
2750
|
+
|
|
2751
|
+
const includeEventDate = (date) => {
|
|
2752
|
+
if (!(date instanceof Date) || isNaN(date.getTime())) {
|
|
2753
|
+
return false;
|
|
2754
|
+
}
|
|
2755
|
+
if (periods.length === 0) {
|
|
2756
|
+
return true;
|
|
2757
|
+
}
|
|
2758
|
+
return periods.some((period) => date >= period.from && date <= period.to);
|
|
2759
|
+
};
|
|
2760
|
+
|
|
2761
|
+
const buildBaseTask = (task) => ({
|
|
2762
|
+
id: task.id,
|
|
2763
|
+
name: task.name,
|
|
2764
|
+
assigned: getTaskMetadata(task, "assigned") || null,
|
|
2765
|
+
column: findTaskColumn(index, task.id),
|
|
2766
|
+
});
|
|
2767
|
+
|
|
2768
|
+
const tasks = await this.loadAllTrackedTasks(index);
|
|
2769
|
+
let events = tasks
|
|
2770
|
+
.filter((task) => {
|
|
2771
|
+
const assignedUser = getTaskMetadata(task, "assigned") || null;
|
|
2772
|
+
return (
|
|
2773
|
+
(assigned === null || assignedUser === assigned) &&
|
|
2774
|
+
(selectedTaskIds === null || selectedTaskIds.has(task.id))
|
|
2775
|
+
);
|
|
2776
|
+
})
|
|
2777
|
+
.map((task) => {
|
|
2778
|
+
const taskBase = buildBaseTask(task);
|
|
2779
|
+
|
|
2780
|
+
const metadataEvents = Object.entries(task.metadata)
|
|
2781
|
+
.filter(([, value]) => value instanceof Date)
|
|
2782
|
+
.map(([field, value]) => ({
|
|
2783
|
+
date: value,
|
|
2784
|
+
source: "metadata",
|
|
2785
|
+
eventType: field,
|
|
2786
|
+
task: taskBase,
|
|
2787
|
+
event: {
|
|
2788
|
+
field,
|
|
2789
|
+
value,
|
|
2790
|
+
},
|
|
2791
|
+
}));
|
|
2792
|
+
|
|
2793
|
+
const historyEvents = ("history" in task && Array.isArray(task.history) ? task.history : [])
|
|
2794
|
+
.filter((historyEvent) => historyEvent.date instanceof Date)
|
|
2795
|
+
.map((historyEvent) => {
|
|
2796
|
+
const event = { ...historyEvent };
|
|
2797
|
+
delete event.date;
|
|
2798
|
+
return {
|
|
2799
|
+
date: historyEvent.date,
|
|
2800
|
+
source: "history",
|
|
2801
|
+
eventType: historyEvent.type,
|
|
2802
|
+
task: taskBase,
|
|
2803
|
+
event,
|
|
2804
|
+
};
|
|
2805
|
+
});
|
|
2806
|
+
|
|
2807
|
+
return [...metadataEvents, ...historyEvents];
|
|
2808
|
+
})
|
|
2809
|
+
.flat()
|
|
2810
|
+
.filter((event) => includeEventDate(event.date));
|
|
2811
|
+
|
|
2812
|
+
events.sort((a, b) => compareValues(a.date, b.date) || compareValues(a.task.id, b.task.id));
|
|
2813
|
+
return { periods, events };
|
|
2814
|
+
}
|
|
2815
|
+
|
|
2635
2816
|
/**
|
|
2636
2817
|
* Output gantt chart data
|
|
2637
2818
|
* @param {?string} [assigned=null] The assigned user to filter for, or null for no assigned filter
|
|
@@ -2659,7 +2840,8 @@ class Kanbn {
|
|
|
2659
2840
|
created,
|
|
2660
2841
|
started: "started" in task.metadata ? task.metadata.started : false,
|
|
2661
2842
|
completed: "completed" in task.metadata ? task.metadata.completed : false,
|
|
2662
|
-
|
|
2843
|
+
plannedStart: "plannedStart" in task.metadata ? task.metadata.plannedStart : false,
|
|
2844
|
+
plannedFinish: "plannedFinish" in task.metadata ? task.metadata.plannedFinish : false,
|
|
2663
2845
|
workload: taskWorkload(index, task),
|
|
2664
2846
|
progress: taskProgress(index, task),
|
|
2665
2847
|
column: taskColumn,
|
|
@@ -2688,7 +2870,8 @@ class Kanbn {
|
|
|
2688
2870
|
}
|
|
2689
2871
|
);
|
|
2690
2872
|
|
|
2691
|
-
|
|
2873
|
+
const trackedTaskIds = getTrackedTaskIds(index);
|
|
2874
|
+
return buildGanttSchedule(index, tasks, effectiveNow, trackedTaskIds);
|
|
2692
2875
|
}
|
|
2693
2876
|
|
|
2694
2877
|
/**
|
package/src/parse-task.js
CHANGED
|
@@ -65,7 +65,13 @@ function validateMetadataFromMarkdown(metadata) {
|
|
|
65
65
|
{ type: 'date'}
|
|
66
66
|
]
|
|
67
67
|
},
|
|
68
|
-
'
|
|
68
|
+
'plannedStart': {
|
|
69
|
+
oneOf: [
|
|
70
|
+
{ type: 'string' },
|
|
71
|
+
{ type: 'date'}
|
|
72
|
+
]
|
|
73
|
+
},
|
|
74
|
+
'plannedFinish': {
|
|
69
75
|
oneOf: [
|
|
70
76
|
{ type: 'string' },
|
|
71
77
|
{ type: 'date'}
|
|
@@ -98,7 +104,8 @@ function validateMetadataFromJSON(metadata) {
|
|
|
98
104
|
'started': { type: 'date'},
|
|
99
105
|
'completed': { type: 'date'},
|
|
100
106
|
'due': { type: 'date'},
|
|
101
|
-
'
|
|
107
|
+
'plannedStart': { type: 'date'},
|
|
108
|
+
'plannedFinish': { type: 'date'},
|
|
102
109
|
'progress': { type: 'number' },
|
|
103
110
|
'tags': {
|
|
104
111
|
type: 'array',
|
|
@@ -373,12 +380,19 @@ module.exports = {
|
|
|
373
380
|
}
|
|
374
381
|
metadata.due = dateValue;
|
|
375
382
|
}
|
|
376
|
-
if ('
|
|
377
|
-
const dateValue = chrono.parseDate(metadata.
|
|
383
|
+
if ('plannedStart' in metadata && !(metadata.plannedStart instanceof Date)) {
|
|
384
|
+
const dateValue = chrono.parseDate(metadata.plannedStart);
|
|
385
|
+
if (dateValue === null) {
|
|
386
|
+
throw new Error('unable to parse plannedStart date');
|
|
387
|
+
}
|
|
388
|
+
metadata.plannedStart = dateValue;
|
|
389
|
+
}
|
|
390
|
+
if ('plannedFinish' in metadata && !(metadata.plannedFinish instanceof Date)) {
|
|
391
|
+
const dateValue = chrono.parseDate(metadata.plannedFinish);
|
|
378
392
|
if (dateValue === null) {
|
|
379
|
-
throw new Error('unable to parse
|
|
393
|
+
throw new Error('unable to parse plannedFinish date');
|
|
380
394
|
}
|
|
381
|
-
metadata.
|
|
395
|
+
metadata.plannedFinish = dateValue;
|
|
382
396
|
}
|
|
383
397
|
|
|
384
398
|
// Check progress value
|