@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 ADDED
@@ -0,0 +1,9 @@
1
+ # Future plans
2
+
3
+ * multiple boards
4
+ * epics / stories
5
+ * swimlanes
6
+ * list of contributors in index config
7
+ * dropdown in vsce taskeditor
8
+ * optional autopopulate from git user
9
+ * web UI
package/README.md CHANGED
@@ -39,6 +39,7 @@ Where <command> is one of:
39
39
  archive ....... Archive a task
40
40
  restore ....... Restore a task from the archive
41
41
  remove-all .... Remove the kanbn board and all tasks
42
+ history ....... Show task history
42
43
 
43
44
  For more help with commands, try:
44
45
 
@@ -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 uses task dates, workload, and the optional
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
- A unified date axis at the bottom for easy time reference
10
- A vertical "now" line marking the current date (if within the chart range)
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.
@@ -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
- postponed: 2021-03-20T04:58:38.653Z
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
- ### `postponed`
121
+ ### `plannedStart`
121
122
 
122
- The date and time after which the task should be scheduled. Gantt chart rendering uses this value as an explicit
123
- start-floor when present.
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
 
@@ -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
- - start: 2026-05-06T09:00:00.000Z
8
- name: Foundation Sprint
9
- description: Baseline product and infrastructure work.
10
- - start: 2026-06-10T09:00:00.000Z
11
- name: Workflow Sprint
12
- description: Core team workflow and onboarding features.
13
- - start: 2026-07-08T09:00:00.000Z
14
- name: Stabilisation Sprint
15
- description: Reliability and release readiness improvements.
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-06T10:00:00.000Z
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
@@ -6,7 +6,7 @@ tags:
6
6
  - Large
7
7
  progress: 0
8
8
  due: 2026-07-11T10:00:00.000Z
9
- postponed: 2026-07-20T10:00:00.000Z
9
+ plannedStart: 2026-07-20T10:00:00.000Z
10
10
  ---
11
11
 
12
12
  # Create Self-serve Cancellation Flow
@@ -8,7 +8,7 @@ assigned: Ava
8
8
  progress: 0.4
9
9
  started: 2026-07-06T10:00:00.000Z
10
10
  due: 2026-07-07T10:00:00.000Z
11
- postponed: 2026-07-22T10:00:00.000Z
11
+ plannedStart: 2026-07-22T10:00:00.000Z
12
12
  ---
13
13
 
14
14
  # Optimize Dashboard First Load
@@ -6,7 +6,7 @@ tags:
6
6
  - Large
7
7
  progress: 0
8
8
  due: 2026-07-07T10:00:00.000Z
9
- postponed: 2026-07-18T10:00:00.000Z
9
+ plannedStart: 2026-07-18T10:00:00.000Z
10
10
  ---
11
11
 
12
12
  # Prototype Report Export Scheduler
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basementuniverse/kanbn",
3
- "version": "1.0.1",
3
+ "version": "1.1.1",
4
4
  "description": "A CLI Kanban application",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/routes/add.json CHANGED
@@ -16,6 +16,8 @@
16
16
  "tag",
17
17
  "relation",
18
18
  "due",
19
+ "plannedStart",
20
+ "plannedFinish",
19
21
  "progress"
20
22
  ],
21
23
  "alias": {
package/routes/edit.json CHANGED
@@ -19,6 +19,8 @@
19
19
  "relation",
20
20
  "remove-relation",
21
21
  "due",
22
+ "plannedStart",
23
+ "plannedFinish",
22
24
  "progress"
23
25
  ],
24
26
  "alias": {
package/routes/find.json CHANGED
@@ -28,6 +28,8 @@
28
28
  "started",
29
29
  "completed",
30
30
  "due",
31
+ "plannedStart",
32
+ "plannedFinish",
31
33
  "workload",
32
34
  "progress",
33
35
  "assigned"
@@ -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
@@ -27,6 +27,8 @@
27
27
  "started",
28
28
  "completed",
29
29
  "due",
30
+ "plannedStart",
31
+ "plannedFinish",
30
32
  "workload",
31
33
  "progress",
32
34
  "assigned"
@@ -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
+
@@ -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 postponedDateExists = (
26
+ const plannedStartDateExists = (
27
27
  'metadata' in taskData &&
28
- 'postponed' in taskData.metadata &&
29
- taskData.metadata.postponed != null
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: 'setPostponed',
87
- message: 'Set a postponed date?',
91
+ name: 'setPlannedStart',
92
+ message: 'Set a planned start date?',
88
93
  default: false,
89
- when: answers => !postponedDateExists
94
+ when: answers => !plannedStartDateExists
90
95
  },
91
96
  {
92
97
  type: 'datepicker',
93
- name: 'postponed',
94
- message: 'Postponed date:',
95
- default: postponedDateExists ? taskData.metadata.postponed : new Date(),
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.setPostponed,
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
- // Postponed date
347
- if (args.postponed) {
348
- taskData.metadata.postponed = chrono.parseDate(utility.strArg(args.postponed));
349
- if (taskData.metadata.postponed === null) {
350
- utility.error('Unable to parse postponed date');
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 ('postponed' in answers) {
485
- taskData.metadata.postponed = answers.postponed;
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;
@@ -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 postponedDateExists = (
26
+ const plannedStartDateExists = (
27
27
  'metadata' in taskData &&
28
- 'postponed' in taskData.metadata &&
29
- taskData.metadata.postponed != null
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: 'editPostponed',
112
- message: 'Edit or remove postponed date?',
116
+ name: 'editPlannedStart',
117
+ message: 'Edit or remove planned start date?',
113
118
  default: 'none',
114
- when: answers => postponedDateExists,
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: 'setPostponed',
137
- message: 'Set a postponed date?',
141
+ name: 'setPlannedStart',
142
+ message: 'Set a planned start date?',
138
143
  default: false,
139
- when: answers => !postponedDateExists
144
+ when: answers => !plannedStartDateExists
140
145
  },
141
146
  {
142
147
  type: 'datepicker',
143
- name: 'postponed',
144
- message: 'Postponed date:',
145
- default: postponedDateExists ? taskData.metadata.postponed : new Date(),
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.setPostponed || answers.editPostponed === 'edit'
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
- // Postponed date
479
- if (args.postponed) {
523
+ // Planned start date
524
+ if (args.plannedStart) {
480
525
  if (!('metadata' in taskData)) {
481
526
  taskData.metadata = {};
482
527
  }
483
- taskData.metadata.postponed = chrono.parseDate(utility.strArg(args.postponed));
484
- if (taskData.metadata.postponed === null) {
485
- utility.error('Unable to parse postponed date');
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 postponed date
724
- if ('editPostponed' in answers && answers.editPostponed === 'remove') {
725
- delete taskData.metadata.postponed;
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
- // Postponed date
729
- if ('postponed' in answers) {
730
- taskData.metadata.postponed = answers.postponed;
795
+ // Planned finish date
796
+ if ('plannedFinish' in answers) {
797
+ taskData.metadata.plannedFinish = answers.plannedFinish;
731
798
  }
732
799
 
733
800
  // Remove assigned
@@ -53,8 +53,13 @@ const searchFields = [
53
53
  type: 'date'
54
54
  },
55
55
  {
56
- name: 'Postponed',
57
- field: 'postponed',
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, 'workoad')) {
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) {
@@ -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)} │${renderTickRow(ticks, nowPosition)}│`);
369
- console.log(` ${''.padEnd(maxNameWidth)} │${labels}│`);
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
+ };
@@ -123,10 +123,18 @@ const sorterFields = [
123
123
  filterable: false
124
124
  },
125
125
  {
126
- name: 'Postponed date',
127
- field: 'postponed',
126
+ name: 'Planned start date',
127
+ field: 'plannedStart',
128
128
  options: [
129
- '--postponed'
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
- postponed: "postponed" in task.metadata ? task.metadata.postponed : "",
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.postponed,
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
- addDependencyEdge(task.id, relation.task);
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 taskBaseDate = maxDate([
990
- task.created,
991
- task.started,
992
- task.metadata.postponed,
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
- const duration = task.completed instanceof Date && task.started instanceof Date
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 = task.completed instanceof Date ? new Date(task.completed.getTime()) : new Date(start.getTime() + duration);
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 && taskBaseDate !== null && dependencyEnd.getTime() > taskBaseDate.getTime()
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
- postponed: "postponed" in task.metadata ? task.metadata.postponed : false,
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
- return buildGanttSchedule(index, tasks, effectiveNow);
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
- 'postponed': {
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
- 'postponed': { type: 'date'},
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 ('postponed' in metadata && !(metadata.postponed instanceof Date)) {
377
- const dateValue = chrono.parseDate(metadata.postponed);
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 postponed date');
393
+ throw new Error('unable to parse plannedFinish date');
380
394
  }
381
- metadata.postponed = dateValue;
395
+ metadata.plannedFinish = dateValue;
382
396
  }
383
397
 
384
398
  // Check progress value