@basementuniverse/kanbn 1.1.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/README.md +2 -0
  2. package/coverage/base.css +224 -0
  3. package/coverage/block-navigation.js +87 -0
  4. package/coverage/coverage-summary.json +31 -0
  5. package/coverage/favicon.png +0 -0
  6. package/coverage/index.html +146 -0
  7. package/coverage/kanbn/index.html +116 -0
  8. package/coverage/kanbn/index.js.html +217 -0
  9. package/coverage/kanbn/src/board.js.html +646 -0
  10. package/coverage/kanbn/src/controller/add.js.html +1729 -0
  11. package/coverage/kanbn/src/controller/archive.js.html +277 -0
  12. package/coverage/kanbn/src/controller/board.js.html +217 -0
  13. package/coverage/kanbn/src/controller/burndown.js.html +607 -0
  14. package/coverage/kanbn/src/controller/comment.js.html +397 -0
  15. package/coverage/kanbn/src/controller/edit.js.html +2920 -0
  16. package/coverage/kanbn/src/controller/find.js.html +1597 -0
  17. package/coverage/kanbn/src/controller/gantt.js.html +1342 -0
  18. package/coverage/kanbn/src/controller/help.js.html +115 -0
  19. package/coverage/kanbn/src/controller/history.js.html +400 -0
  20. package/coverage/kanbn/src/controller/index.html +431 -0
  21. package/coverage/kanbn/src/controller/init.js.html +511 -0
  22. package/coverage/kanbn/src/controller/move.js.html +514 -0
  23. package/coverage/kanbn/src/controller/remove-all.js.html +226 -0
  24. package/coverage/kanbn/src/controller/remove.js.html +307 -0
  25. package/coverage/kanbn/src/controller/rename.js.html +397 -0
  26. package/coverage/kanbn/src/controller/restore.js.html +268 -0
  27. package/coverage/kanbn/src/controller/sort.js.html +1318 -0
  28. package/coverage/kanbn/src/controller/sprint.js.html +361 -0
  29. package/coverage/kanbn/src/controller/status.js.html +310 -0
  30. package/coverage/kanbn/src/controller/task.js.html +244 -0
  31. package/coverage/kanbn/src/controller/validate.js.html +367 -0
  32. package/coverage/kanbn/src/controller/version.js.html +100 -0
  33. package/coverage/kanbn/src/index.html +191 -0
  34. package/coverage/kanbn/src/main.js.html +9787 -0
  35. package/coverage/kanbn/src/parse-index.js.html +1063 -0
  36. package/coverage/kanbn/src/parse-markdown.js.html +202 -0
  37. package/coverage/kanbn/src/parse-task.js.html +2191 -0
  38. package/coverage/kanbn/src/utility.js.html +616 -0
  39. package/coverage/lcov.info +11837 -0
  40. package/coverage/prettify.css +1 -0
  41. package/coverage/prettify.js +2 -0
  42. package/coverage/sort-arrow-sprite.png +0 -0
  43. package/coverage/sorter.js +210 -0
  44. package/coverage/tmp/coverage-214292-1787777191526-0.json +1 -0
  45. package/coverage/tmp/coverage-214293-1787777184569-0.json +1 -0
  46. package/docs/advanced-configuration.md +2 -0
  47. package/docs/commands/add.txt +10 -0
  48. package/docs/commands/board.txt +3 -1
  49. package/docs/commands/burndown.txt +2 -4
  50. package/docs/commands/edit.txt +22 -0
  51. package/docs/commands/find.txt +45 -1
  52. package/docs/commands/help.txt +1 -0
  53. package/docs/commands/move.txt +1 -1
  54. package/docs/commands/sort.txt +13 -2
  55. package/docs/commands/validate.txt +12 -0
  56. package/docs/filtering-and-sorting.md +218 -0
  57. package/docs/index-structure.md +68 -19
  58. package/docs/index.md +26 -2
  59. package/docs/migration-2.0.md +92 -0
  60. package/docs/quick-start.md +37 -1
  61. package/docs/task-structure.md +12 -3
  62. package/docs/views.md +229 -0
  63. package/example/README.md +70 -0
  64. package/example/advanced/.kanbn/index.md +26 -0
  65. package/example/advanced/.kanbn/tasks/add-rate-limiting.md +18 -0
  66. package/example/advanced/.kanbn/tasks/add-sso-support.md +15 -0
  67. package/example/advanced/.kanbn/tasks/migrate-to-new-cdn.md +14 -0
  68. package/example/advanced/.kanbn/tasks/refresh-marketing-site.md +14 -0
  69. package/example/advanced/.kanbn/tasks/rotate-api-keys.md +15 -0
  70. package/example/advanced/.kanbn/tasks/split-billing-service.md +17 -0
  71. package/example/advanced/.kanbn/tasks/upgrade-node-runtime.md +19 -0
  72. package/example/advanced/kanbn.yml +59 -0
  73. package/example/views/.kanbn/index.md +140 -0
  74. package/example/views/.kanbn/tasks/add-audit-log-export.md +18 -0
  75. package/example/views/.kanbn/tasks/add-dark-mode-toggle.md +16 -0
  76. package/example/views/.kanbn/tasks/build-webhook-retry-queue.md +15 -0
  77. package/example/views/.kanbn/tasks/fix-avatar-upload-crash.md +13 -0
  78. package/example/views/.kanbn/tasks/fix-expired-session-redirect.md +15 -0
  79. package/example/views/.kanbn/tasks/investigate-offline-mode.md +12 -0
  80. package/example/views/.kanbn/tasks/rewrite-search-indexer.md +12 -0
  81. package/example/views/.kanbn/tasks/tidy-up-settings-copy.md +12 -0
  82. package/package.json +4 -3
  83. package/routes/edit.json +2 -1
  84. package/routes/validate.json +4 -2
  85. package/skills/kanbn-plan/references/index-structure.md +1 -0
  86. package/skills/kanbn-plan/references/task-structure.md +2 -0
  87. package/src/board.js +1 -0
  88. package/src/controller/board.js +6 -0
  89. package/src/controller/edit.js +65 -12
  90. package/src/controller/gantt.js +46 -1
  91. package/src/controller/status.js +12 -0
  92. package/src/controller/task.js +1 -1
  93. package/src/controller/validate.js +78 -15
  94. package/src/main.js +324 -152
  95. package/src/parse-index.js +3 -0
  96. package/src/parse-markdown.js +3 -1
  97. package/src/parse-task.js +28 -1
  98. package/src/utility.js +26 -1
  99. /package/example/{.kanbn → basic/.kanbn}/index.md +0 -0
  100. /package/example/{.kanbn → basic/.kanbn}/tasks/add-basic-activity-feed.md +0 -0
  101. /package/example/{.kanbn → basic/.kanbn}/tasks/add-passwordless-login-option.md +0 -0
  102. /package/example/{.kanbn → basic/.kanbn}/tasks/add-usage-alert-email-thresholds.md +0 -0
  103. /package/example/{.kanbn → basic/.kanbn}/tasks/build-email-template-system.md +0 -0
  104. /package/example/{.kanbn → basic/.kanbn}/tasks/build-invoice-download-endpoint.md +0 -0
  105. /package/example/{.kanbn → basic/.kanbn}/tasks/build-tenant-settings-page.md +0 -0
  106. /package/example/{.kanbn → basic/.kanbn}/tasks/create-organization-switcher.md +0 -0
  107. /package/example/{.kanbn → basic/.kanbn}/tasks/create-sandbox-environment-provisioner.md +0 -0
  108. /package/example/{.kanbn → basic/.kanbn}/tasks/create-self-serve-cancellation-flow.md +0 -0
  109. /package/example/{.kanbn → basic/.kanbn}/tasks/define-product-pricing-strategy.md +0 -0
  110. /package/example/{.kanbn → basic/.kanbn}/tasks/design-onboarding-checklist.md +0 -0
  111. /package/example/{.kanbn → basic/.kanbn}/tasks/design-team-invite-expiry-flow.md +0 -0
  112. /package/example/{.kanbn → basic/.kanbn}/tasks/implement-data-retention-policy-jobs.md +0 -0
  113. /package/example/{.kanbn → basic/.kanbn}/tasks/implement-feature-flags-foundation.md +0 -0
  114. /package/example/{.kanbn → basic/.kanbn}/tasks/implement-project-creation-wizard.md +0 -0
  115. /package/example/{.kanbn → basic/.kanbn}/tasks/implement-stripe-webhook-signature-check.md +0 -0
  116. /package/example/{.kanbn → basic/.kanbn}/tasks/implement-team-permissions-ui.md +0 -0
  117. /package/example/{.kanbn → basic/.kanbn}/tasks/implement-user-signup-and-login.md +0 -0
  118. /package/example/{.kanbn → basic/.kanbn}/tasks/integrate-crm-lead-sync.md +0 -0
  119. /package/example/{.kanbn → basic/.kanbn}/tasks/legal-review-terms-and-privacy.md +0 -0
  120. /package/example/{.kanbn → basic/.kanbn}/tasks/migrate-legacy-events-to-new-schema.md +0 -0
  121. /package/example/{.kanbn → basic/.kanbn}/tasks/optimize-dashboard-first-load.md +0 -0
  122. /package/example/{.kanbn → basic/.kanbn}/tasks/prototype-report-export-scheduler.md +0 -0
  123. /package/example/{.kanbn → basic/.kanbn}/tasks/publish-internal-qa-checklist.md +0 -0
  124. /package/example/{.kanbn → basic/.kanbn}/tasks/setup-ci-pipeline.md +0 -0
@@ -0,0 +1,15 @@
1
+ ---
2
+ created: 2026-07-07T09:00:00.000Z
3
+ updated: 2026-07-14T09:00:00.000Z
4
+ started: 2026-07-11T09:00:00.000Z
5
+ assigned: Ben
6
+ progress: 0.3
7
+ due: 2026-10-01T17:00:00.000Z
8
+ tags:
9
+ - Feature
10
+ - Large
11
+ ---
12
+
13
+ # Build Webhook Retry Queue
14
+
15
+ Retry failed webhook deliveries with exponential backoff.
@@ -0,0 +1,13 @@
1
+ ---
2
+ created: 2026-07-03T09:00:00.000Z
3
+ updated: 2026-07-03T09:00:00.000Z
4
+ assigned: Ben
5
+ due: 2026-07-20T17:00:00.000Z
6
+ tags:
7
+ - Bug
8
+ - Small
9
+ ---
10
+
11
+ # Fix Avatar Upload Crash
12
+
13
+ Uploading a PNG larger than 5MB crashes the avatar cropper.
@@ -0,0 +1,15 @@
1
+ ---
2
+ created: 2026-07-06T09:00:00.000Z
3
+ updated: 2026-07-12T09:00:00.000Z
4
+ started: 2026-07-10T09:00:00.000Z
5
+ assigned: Ana
6
+ progress: 0.5
7
+ due: 2026-07-25T17:00:00.000Z
8
+ tags:
9
+ - Bug
10
+ - Medium
11
+ ---
12
+
13
+ # Fix Expired Session Redirect
14
+
15
+ Expired sessions redirect to the dashboard instead of the login page.
@@ -0,0 +1,12 @@
1
+ ---
2
+ created: 2026-05-01T09:00:00.000Z
3
+ updated: 2026-05-01T09:00:00.000Z
4
+ due: 2027-01-01T17:00:00.000Z
5
+ tags:
6
+ - Feature
7
+ - Huge
8
+ ---
9
+
10
+ # Investigate Offline Mode
11
+
12
+ Parked until after the search rewrite.
@@ -0,0 +1,12 @@
1
+ ---
2
+ created: 2026-07-04T09:00:00.000Z
3
+ updated: 2026-07-04T09:00:00.000Z
4
+ due: 2026-11-30T17:00:00.000Z
5
+ tags:
6
+ - Feature
7
+ - Huge
8
+ ---
9
+
10
+ # Rewrite Search Indexer
11
+
12
+ Replace the nightly re-index with an incremental indexer.
@@ -0,0 +1,12 @@
1
+ ---
2
+ created: 2026-07-05T09:00:00.000Z
3
+ updated: 2026-07-05T09:00:00.000Z
4
+ due: 2026-10-15T17:00:00.000Z
5
+ tags:
6
+ - Chore
7
+ - Tiny
8
+ ---
9
+
10
+ # Tidy Up Settings Copy
11
+
12
+ Fix inconsistent capitalisation across the settings pages.
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@basementuniverse/kanbn",
3
- "version": "1.1.0",
3
+ "version": "2.0.0",
4
4
  "description": "A CLI Kanban application",
5
5
  "main": "index.js",
6
6
  "scripts": {
7
- "test": "KANBN_ENV=test qunit ./test/**/*.test.js"
7
+ "test": "KANBN_ENV=test qunit ./test/**/*.test.js",
8
+ "coverage": "KANBN_ENV=test c8 --reporter=text --reporter=html --include=src/** --include=index.js qunit ./test/**/*.test.js"
8
9
  },
9
10
  "preferGlobal": true,
10
11
  "bin": {
@@ -55,10 +56,10 @@
55
56
  },
56
57
  "devDependencies": {
57
58
  "@types/typescript": "^2.0.0",
59
+ "c8": "^12.0.0",
58
60
  "capture-console": "^1.0.1",
59
61
  "faker": "^5.1.0",
60
62
  "mock-argv": "^1.1.10",
61
- "mock-fs": "^4.13.0",
62
63
  "mock-require": "^3.0.3",
63
64
  "mock-stdin": "^1.0.0",
64
65
  "mockdate": "^3.0.2",
package/routes/edit.json CHANGED
@@ -21,7 +21,8 @@
21
21
  "due",
22
22
  "plannedStart",
23
23
  "plannedFinish",
24
- "progress"
24
+ "progress",
25
+ "unset"
25
26
  ],
26
27
  "alias": {
27
28
  "interactive": ["i"],
@@ -6,11 +6,13 @@
6
6
  "args": {
7
7
  "boolean": [
8
8
  "json",
9
- "save"
9
+ "save",
10
+ "fix"
10
11
  ],
11
12
  "alias": {
12
13
  "json": ["j"],
13
- "save": ["s"]
14
+ "save": ["s"],
15
+ "fix": ["f"]
14
16
  }
15
17
  },
16
18
  "controller": "./src/controller/validate",
@@ -38,6 +38,7 @@ Short project summary and planning assumptions.
38
38
  - After the description, each level-2 heading defines a column.
39
39
  - Each column should contain a markdown list of task links.
40
40
  - Each task link should point to `tasks/<task-id>.md`.
41
+ - A task placed in a column listed in `startedColumns` or `completedColumns` should carry the matching `started` / `completed` date in its metadata. Kanbn reads task state from those dates, not from the column.
41
42
  - The link text should be the task id, not the human-readable title.
42
43
 
43
44
  ## Recommended Defaults
@@ -106,6 +106,8 @@ Usually omit:
106
106
 
107
107
  Add metadata only when the user supplied it or when the board already relies on it.
108
108
 
109
+ The one exception is a task you place in a started or completed column. Kanbn decides whether a task has started or been completed from its `started` and `completed` dates, not from the column it is in, so a task placed in `In Progress` or `Done` without those dates will be reported as drifted and will be missing from burndown charts. If you place a task in one of those columns because the user told you the work is already underway or finished, write the matching date. If you don't know when it happened, leave the task in a column that doesn't imply state.
110
+
109
111
  Good uses of metadata:
110
112
 
111
113
  - `tags` for epics, teams, domains, or sizing labels
package/src/board.js CHANGED
@@ -35,6 +35,7 @@ module.exports = (() => {
35
35
  started: 'started' in task.metadata ? formatDate(task.metadata.started, dateFormat) : '',
36
36
  completed: 'completed' in task.metadata ? formatDate(task.metadata.completed, dateFormat) : '',
37
37
  due: 'due' in task.metadata ? formatDate(task.metadata.due, dateFormat) : '',
38
+ assigned: 'assigned' in task.metadata ? task.metadata.assigned : '',
38
39
  tags: 'tags' in task.metadata ? task.metadata.tags : [],
39
40
  subTasks: task.subTasks,
40
41
  relations: task.relations,
@@ -32,6 +32,12 @@ module.exports = async args => {
32
32
  // Show the board
33
33
  board
34
34
  .show(index, tasks, args.view, args.json)
35
+ .then(() => {
36
+ utility.showDateDriftHint(
37
+ kanbn.getDateDrift(index, tasks),
38
+ !args.json && kanbn.isVerbose(index)
39
+ );
40
+ })
35
41
  .catch(error => {
36
42
  utility.error(error);
37
43
  });
@@ -412,7 +412,7 @@ async function interactive(taskData, taskIds, columnName, columnNames) {
412
412
  type: 'input',
413
413
  name: 'type',
414
414
  message: 'Relation type:',
415
- default: answers => taskData.relations.find(relation => relation.task === answers.selectRelation).task,
415
+ default: answers => taskData.relations.find(relation => relation.task === answers.selectRelation).type,
416
416
  when: answers => answers.editRelation === 'edit'
417
417
  }
418
418
  ]
@@ -420,15 +420,31 @@ async function interactive(taskData, taskIds, columnName, columnNames) {
420
420
  ]);
421
421
  }
422
422
 
423
+ /**
424
+ * Metadata fields that can be cleared with --unset, in addition to any custom fields defined in
425
+ * the index options
426
+ */
427
+ const UNSETTABLE_FIELDS = [
428
+ 'started',
429
+ 'completed',
430
+ 'due',
431
+ 'plannedStart',
432
+ 'plannedFinish',
433
+ 'assigned',
434
+ 'progress',
435
+ 'tags'
436
+ ];
437
+
423
438
  /**
424
439
  * Update a task
425
440
  * @param {string} taskId
426
441
  * @param {object} taskData
427
442
  * @param {?string} columnName
443
+ * @param {string[]} [unsetFields=[]]
428
444
  */
429
- function updateTask(taskId, taskData, columnName) {
445
+ function updateTask(taskId, taskData, columnName, unsetFields = []) {
430
446
  kanbn
431
- .updateTask(taskId, taskData, columnName)
447
+ .updateTask(taskId, taskData, columnName, unsetFields)
432
448
  .then(taskId => {
433
449
  console.log(`Updated task "${taskId}"`);
434
450
  })
@@ -578,8 +594,8 @@ module.exports = async args => {
578
594
 
579
595
  // Check that the sub-tasks being removed currently exist
580
596
  for (let removedSubTask of removedSubTasks) {
581
- if (taskData.subTasks.find(subTask => subTask.text === removedSubTask.text) === undefined) {
582
- utility.error(`Sub-task "${removedSubTask.text}" doesn't exist`);
597
+ if (taskData.subTasks.find(subTask => subTask.text === removedSubTask) === undefined) {
598
+ utility.error(`Sub-task "${removedSubTask}" doesn't exist`);
583
599
  return;
584
600
  }
585
601
  }
@@ -633,7 +649,7 @@ module.exports = async args => {
633
649
  // Check that the tags being removed currently exist
634
650
  for (let removedTag of removedTags) {
635
651
  if (taskData.metadata.tags.indexOf(removedTag) === -1) {
636
- utility.error(`Tag "${removedSubTask.text}" doesn't exist`);
652
+ utility.error(`Tag "${removedTag}" doesn't exist`);
637
653
  return;
638
654
  }
639
655
  }
@@ -652,8 +668,8 @@ module.exports = async args => {
652
668
 
653
669
  // Check that the relations being removed currently exist
654
670
  for (let removedRelation of removedRelations) {
655
- if (taskData.relations.find(relation => relation.task === removedRelation.task) === undefined) {
656
- utility.error(`Relation "${removedRelation.task}" doesn't exist`);
671
+ if (taskData.relations.find(relation => relation.task === removedRelation) === undefined) {
672
+ utility.error(`Relation "${removedRelation}" doesn't exist`);
657
673
  return;
658
674
  }
659
675
  }
@@ -754,6 +770,43 @@ module.exports = async args => {
754
770
  }
755
771
  }
756
772
 
773
+ // Unset metadata fields
774
+ const unsetFields = [];
775
+ if (args.unset) {
776
+ const customFieldNames = 'customFields' in index.options
777
+ ? index.options.customFields.map(customField => customField.name)
778
+ : [];
779
+ const unsettableFields = [...UNSETTABLE_FIELDS, ...customFieldNames];
780
+
781
+ // Arguments that set the value of a metadata field, so that setting and unsetting the same
782
+ // field in one command can be rejected instead of silently resolving one way or the other
783
+ const settingArgs = {
784
+ due: args.due,
785
+ plannedStart: args.plannedStart,
786
+ plannedFinish: args.plannedFinish,
787
+ progress: args.progress,
788
+ assigned: args.assigned,
789
+ tags: args.tag,
790
+ ...Object.fromEntries(customFieldNames.map(name => [name, args[name]]))
791
+ };
792
+
793
+ for (let field of utility.arrayArg(args.unset)) {
794
+ if (unsettableFields.indexOf(field) === -1) {
795
+ utility.error(
796
+ `Cannot unset "${field}"\nAvailable fields: ${unsettableFields.join(', ')}`
797
+ );
798
+ return;
799
+ }
800
+ if (settingArgs[field] !== undefined) {
801
+ utility.error(`Cannot set and unset "${field}" in the same command`);
802
+ return;
803
+ }
804
+ if (unsetFields.indexOf(field) === -1) {
805
+ unsetFields.push(field);
806
+ }
807
+ }
808
+ }
809
+
757
810
  // Update task interactively
758
811
  if (args.interactive) {
759
812
  interactive(taskData, taskIds, columnName, columnNames)
@@ -810,7 +863,7 @@ module.exports = async args => {
810
863
  // Edit or remove sub-tasks
811
864
  if ('editSubTasks' in answers) {
812
865
  for (editSubTask of answers.editSubTasks) {
813
- const i = taskData.subTasks.findIndex(subTask => subTask.task === editSubTask.selectSubTask);
866
+ const i = taskData.subTasks.findIndex(subTask => subTask.text === editSubTask.selectSubTask);
814
867
  if (i !== -1) {
815
868
  switch (editSubTask.editSubTask) {
816
869
  case 'remove':
@@ -837,7 +890,7 @@ module.exports = async args => {
837
890
  // Remove tags
838
891
  if ('removeTags' in answers && 'metadata' in taskData && 'tags' in taskData.metadata) {
839
892
  for (removeTag of answers.removeTags) {
840
- const i = taskData.metadata.tags.indexOf(removeTag.name);
893
+ const i = taskData.metadata.tags.indexOf(removeTag.selectTag);
841
894
  if (i !== -1) {
842
895
  taskData.metadata.tags.splice(i, 1);
843
896
  }
@@ -878,7 +931,7 @@ module.exports = async args => {
878
931
 
879
932
  // Update task
880
933
  columnName = answers.column !== currentColumnName ? answers.column : null;
881
- updateTask(taskId, taskData, columnName);
934
+ updateTask(taskId, taskData, columnName, unsetFields);
882
935
  })
883
936
  .catch(error => {
884
937
  utility.error(error);
@@ -887,6 +940,6 @@ module.exports = async args => {
887
940
  // Otherwise edit task non-interactively
888
941
  } else {
889
942
  columnName = columnName !== currentColumnName ? columnName : null;
890
- updateTask(taskId, taskData, columnName);
943
+ updateTask(taskId, taskData, columnName, unsetFields);
891
944
  }
892
945
  };
@@ -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
  }
@@ -57,6 +57,18 @@ module.exports = async args => {
57
57
  console.log(args.json ? JSON.stringify(output, null, 2) : yaml.stringify(output, 4, 2));
58
58
  }
59
59
  })
60
+ .then(async () => {
61
+
62
+ // Only load tasks for the hint when it would actually be shown
63
+ if (args.json || args.quiet) {
64
+ return;
65
+ }
66
+ const index = await kanbn.getIndex();
67
+ if (!kanbn.isVerbose(index)) {
68
+ return;
69
+ }
70
+ utility.showDateDriftHint(await kanbn.findDateDrift());
71
+ })
60
72
  .catch(error => {
61
73
  utility.error(error);
62
74
  });
@@ -13,7 +13,7 @@ function showTask(taskId, json = false) {
13
13
  .getTask(taskId)
14
14
  .then((task) => {
15
15
  if (json) {
16
- console.log(task);
16
+ console.log(JSON.stringify(task, null, 2));
17
17
  } else {
18
18
  marked.setOptions({
19
19
  renderer: new markedTerminalRenderer(),
@@ -2,6 +2,31 @@ const kanbn = require('../main');
2
2
  const utility = require('../utility');
3
3
  const yaml = require('yamljs');
4
4
 
5
+ /**
6
+ * Show a list of tasks whose dates disagree with the column they're in
7
+ * @param {object[]} drift
8
+ * @param {boolean} json
9
+ */
10
+ function showDrift(drift, json) {
11
+ if (json) {
12
+ console.log(JSON.stringify(drift, null, 2));
13
+ return;
14
+ }
15
+ const fixable = drift.filter(d => d.fixable);
16
+ console.log(
17
+ `${drift.length} ${drift.length === 1 ? 'task has' : 'tasks have'} dates that don't match ` +
18
+ `${drift.length === 1 ? 'its' : 'their'} column:`
19
+ );
20
+ for (const item of drift) {
21
+ console.log(` ${item.task}: ${item.message}`);
22
+ }
23
+ if (fixable.length) {
24
+ console.log(utility.replaceTags(
25
+ `\nRun {b}kanbn validate --fix{b} to fill in ${fixable.length === 1 ? 'the missing date' : 'the missing dates'}.`
26
+ ));
27
+ }
28
+ }
29
+
5
30
  module.exports = async args => {
6
31
 
7
32
  // Make sure kanbn has been initialised
@@ -11,21 +36,59 @@ module.exports = async args => {
11
36
  }
12
37
 
13
38
  // Validate kanbn files
14
- kanbn.validate(args.save)
15
- .then(result => {
16
- if (result === true) {
17
- console.log('Everything OK');
18
- } else {
19
- utility.error(
20
- `${result.length} errors found in task files:\n${(
21
- args.json
22
- ? JSON.stringify(result, null, 2)
23
- : yaml.stringify(result, 4, 2)
24
- )}`
25
- );
39
+ let result;
40
+ try {
41
+ result = await kanbn.validate(args.save);
42
+ } catch (error) {
43
+ utility.error(error);
44
+ return;
45
+ }
46
+ if (result !== true) {
47
+ utility.error(
48
+ `${result.length} errors found in task files:\n${(
49
+ args.json
50
+ ? JSON.stringify(result, null, 2)
51
+ : yaml.stringify(result, 4, 2)
52
+ )}`
53
+ );
54
+ return;
55
+ }
56
+
57
+ // Backfill missing started and completed dates
58
+ if (args.fix) {
59
+ let fixed;
60
+ try {
61
+ fixed = await kanbn.fixDateDrift();
62
+ } catch (error) {
63
+ utility.error(error);
64
+ return;
26
65
  }
27
- })
28
- .catch(error => {
66
+ if (!fixed.length) {
67
+ console.log('Everything OK, nothing to fix');
68
+ return;
69
+ }
70
+ if (args.json) {
71
+ console.log(JSON.stringify(fixed, null, 2));
72
+ return;
73
+ }
74
+ console.log(`Filled in ${fixed.length} missing ${fixed.length === 1 ? 'date' : 'dates'}:`);
75
+ for (const item of fixed) {
76
+ console.log(` ${item.task}: ${item.field} set to ${item.date.toISOString()} (from ${item.source})`);
77
+ }
78
+ return;
79
+ }
80
+
81
+ // Report tasks whose dates disagree with their column
82
+ let drift;
83
+ try {
84
+ drift = await kanbn.findDateDrift();
85
+ } catch (error) {
29
86
  utility.error(error);
30
- });
87
+ return;
88
+ }
89
+ if (drift.length) {
90
+ showDrift(drift, args.json);
91
+ return;
92
+ }
93
+ console.log('Everything OK');
31
94
  };