@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
package/src/main.js CHANGED
@@ -30,6 +30,12 @@ const DEFAULT_TASK_WORKLOAD_TAGS = {
30
30
  Huge: 8,
31
31
  };
32
32
  const DEFAULT_DATE_FORMAT = "d mmm yy, H:MM";
33
+
34
+ // Metadata fields that hold a task's started and completed dates. Boards can override these with the
35
+ // startedField and completedField options, so that a task shared between several boards can carry
36
+ // separate started/completed state for each of them
37
+ const DEFAULT_STARTED_FIELD = "started";
38
+ const DEFAULT_COMPLETED_FIELD = "completed";
33
39
  const DEFAULT_TASK_TEMPLATE = "^+^_${overdue ? '^R' : ''}${name}^: ${created ? ('\\n^-^/' + created) : ''}";
34
40
 
35
41
  /**
@@ -240,25 +246,38 @@ function appendTaskHistory(taskData, historyEvent) {
240
246
  * @param {object} task
241
247
  * @return {boolean} True if the task is in a completed column or has a completed date
242
248
  */
249
+ /**
250
+ * Get the name of the metadata field that holds a task's started date for this board. Boards can
251
+ * point this at a custom date field so that several boards can track their own started/completed
252
+ * state against the same shared task file
253
+ * @param {object} index The index object
254
+ * @return {string} The metadata field name
255
+ */
256
+ function getStartedField(index) {
257
+ return ("startedField" in index.options && index.options.startedField) || DEFAULT_STARTED_FIELD;
258
+ }
259
+
260
+ /**
261
+ * Get the name of the metadata field that holds a task's completed date for this board
262
+ * @param {object} index The index object
263
+ * @return {string} The metadata field name
264
+ */
265
+ function getCompletedField(index) {
266
+ return ("completedField" in index.options && index.options.completedField) || DEFAULT_COMPLETED_FIELD;
267
+ }
268
+
243
269
  function taskCompleted(index, task) {
244
- return (
245
- "completed" in task.metadata ||
246
- ("completedColumns" in index.options &&
247
- index.options.completedColumns.indexOf(findTaskColumn(index, task.id)) !== -1)
248
- );
270
+ return getCompletedField(index) in task.metadata;
249
271
  }
250
272
 
251
273
  /**
252
- * Sort a column in the index
274
+ * Flatten a task's metadata and computed values into a single object that can be used for sorting
253
275
  * @param {object} index The index object
254
- * @param {object[]} tasks The tasks in the index
255
- * @param {string} columnName The column to sort
256
- * @param {object[]} sorters A list of sorter objects
257
- * @return {object} The modified index object
276
+ * @param {object} task The task object
277
+ * @return {object} An object containing all sortable task fields
258
278
  */
259
- function sortColumnInIndex(index, tasks, columnName, sorters) {
260
- // Get a list of tasks in the target column and add computed fields
261
- tasks = tasks.map((task) => ({
279
+ function taskSortFields(index, task) {
280
+ return {
262
281
  ...task,
263
282
  ...task.metadata,
264
283
  created: "created" in task.metadata ? task.metadata.created : "",
@@ -279,10 +298,38 @@ function sortColumnInIndex(index, tasks, columnName, sorters) {
279
298
  comments: task.comments.map((comment) => `${comment.author} ${comment.text}`).join("\n"),
280
299
  workload: taskWorkload(index, task),
281
300
  progress: taskProgress(index, task),
282
- }));
301
+ };
302
+ }
303
+
304
+ /**
305
+ * Sort a list of tasks without modifying them, using each task's flattened sort fields
306
+ * @param {object} index The index object
307
+ * @param {object[]} tasks
308
+ * @param {object[]} sorters
309
+ * @return {object[]} The sorted tasks
310
+ */
311
+ function sortTasksWithFields(index, tasks, sorters) {
312
+ if (!sorters.length) {
313
+ return tasks;
314
+ }
315
+ return sortTasks(
316
+ tasks.map((task) => ({ task, fields: taskSortFields(index, task) })),
317
+ sorters,
318
+ (entry, field) => entry.fields[field]
319
+ ).map((entry) => entry.task);
320
+ }
283
321
 
284
- // Sort the list of tasks
285
- tasks = sortTasks(tasks, sorters);
322
+ /**
323
+ * Sort a column in the index
324
+ * @param {object} index The index object
325
+ * @param {object[]} tasks The tasks in the index
326
+ * @param {string} columnName The column to sort
327
+ * @param {object[]} sorters A list of sorter objects
328
+ * @return {object} The modified index object
329
+ */
330
+ function sortColumnInIndex(index, tasks, columnName, sorters) {
331
+ // Sort the tasks in the target column using their flattened sort fields
332
+ tasks = sortTasksWithFields(index, tasks, sorters);
286
333
 
287
334
  // Save the list of tasks back to the index
288
335
  index.columns[columnName] = tasks.map((task) => task.id);
@@ -293,14 +340,16 @@ function sortColumnInIndex(index, tasks, columnName, sorters) {
293
340
  * Sort a list of tasks
294
341
  * @param {object[]} tasks
295
342
  * @param {object[]} sorters
343
+ * @param {?function} [getField=null] An optional accessor used to read a sort field from a task
296
344
  * @return {object[]} The sorted tasks
297
345
  */
298
- function sortTasks(tasks, sorters) {
346
+ function sortTasks(tasks, sorters, getField = null) {
347
+ const readField = getField || ((task, field) => task[field]);
299
348
  tasks.sort((a, b) => {
300
349
  let compareA, compareB;
301
350
  for (let sorter of sorters) {
302
- compareA = a[sorter.field];
303
- compareB = b[sorter.field];
351
+ compareA = readField(a, sorter.field);
352
+ compareB = readField(b, sorter.field);
304
353
  if (sorter.filter) {
305
354
  compareA = sortFilter(compareA, sorter.filter);
306
355
  compareB = sortFilter(compareB, sorter.filter);
@@ -436,6 +485,22 @@ function filterTasks(index, tasks, filters) {
436
485
  result = false;
437
486
  }
438
487
 
488
+ // Planned start date
489
+ if (
490
+ "plannedStart" in filters &&
491
+ (!("plannedStart" in task.metadata) || !dateFilter(filters.plannedStart, task.metadata.plannedStart))
492
+ ) {
493
+ result = false;
494
+ }
495
+
496
+ // Planned finish date
497
+ if (
498
+ "plannedFinish" in filters &&
499
+ (!("plannedFinish" in task.metadata) || !dateFilter(filters.plannedFinish, task.metadata.plannedFinish))
500
+ ) {
501
+ result = false;
502
+ }
503
+
439
504
  // Workload
440
505
  if ("workload" in filters && !numberFilter(filters.workload, taskWorkload(index, task))) {
441
506
  result = false;
@@ -471,12 +536,15 @@ function filterTasks(index, tasks, filters) {
471
536
  }
472
537
 
473
538
  // Tag
474
- if ("tag" in filters && !stringFilter(filters.tag, task.metadata.tags.join("\n"))) {
539
+ if ("tag" in filters && !stringFilter(filters.tag, ("tags" in task.metadata ? task.metadata.tags : []).join("\n"))) {
475
540
  result = false;
476
541
  }
477
542
 
478
543
  // Count tags
479
- if ("count-tags" in filters && !numberFilter(filters["count-tags"], task.tags.length)) {
544
+ if (
545
+ "count-tags" in filters &&
546
+ !numberFilter(filters["count-tags"], ("tags" in task.metadata ? task.metadata.tags : []).length)
547
+ ) {
480
548
  result = false;
481
549
  }
482
550
 
@@ -656,75 +724,31 @@ function taskWorkloadInPeriod(tasks, metadataProperty, start, end) {
656
724
  }
657
725
 
658
726
  /**
659
- * Check if a column is configured as a started column
660
- * @param {object} index
661
- * @param {string|null} columnName
662
- * @return {boolean}
663
- */
664
- function startedColumn(index, columnName) {
665
- return (
666
- !!columnName &&
667
- "startedColumns" in index.options &&
668
- index.options.startedColumns.indexOf(columnName) !== -1
669
- );
670
- }
671
-
672
- /**
673
- * Check if a column is configured as a completed column
674
- * @param {object} index
675
- * @param {string|null} columnName
676
- * @return {boolean}
677
- */
678
- function completedColumn(index, columnName) {
679
- return (
680
- !!columnName &&
681
- "completedColumns" in index.options &&
682
- index.options.completedColumns.indexOf(columnName) !== -1
683
- );
684
- }
685
-
686
- /**
687
- * Build a task's state at a specific date from history events
727
+ * Find the date at which a task most recently entered one of the specified columns, using its
728
+ * history. Returns null if the task has no history, or none of its events mention those columns
688
729
  * @param {object} task
689
- * @param {Date} date
690
- * @return {?object}
730
+ * @param {string[]} columnNames
731
+ * @return {?Date}
691
732
  */
692
- function getTaskHistoryStateAtDate(task, date) {
733
+ function findColumnEntryDate(task, columnNames) {
693
734
  if (!("history" in task) || !Array.isArray(task.history) || task.history.length === 0) {
694
735
  return null;
695
736
  }
696
-
697
- const state = {
698
- created: false,
699
- archived: false,
700
- column: null,
701
- };
702
- const history = [...task.history].sort((a, b) => a.date.getTime() - b.date.getTime());
703
- history.forEach((historyEvent) => {
704
- if (!historyEvent.date || historyEvent.date > date) {
705
- return;
737
+ let result = null;
738
+ for (const historyEvent of [...task.history].sort((a, b) => a.date.getTime() - b.date.getTime())) {
739
+ if (!historyEvent.date) {
740
+ continue;
706
741
  }
707
- switch (historyEvent.type) {
708
- case 'created':
709
- state.created = true;
710
- state.archived = false;
711
- state.column = historyEvent.column || state.column;
712
- break;
713
- case 'moved':
714
- state.column = historyEvent.toColumn || state.column;
715
- break;
716
- case 'archived':
717
- state.archived = true;
718
- break;
719
- case 'restored':
720
- state.archived = false;
721
- state.column = historyEvent.toColumn || state.column;
722
- break;
723
- default:
724
- break;
742
+ const enteredColumn = historyEvent.type === "moved"
743
+ ? historyEvent.toColumn
744
+ : (historyEvent.type === "created" || historyEvent.type === "restored")
745
+ ? (historyEvent.column || historyEvent.toColumn)
746
+ : null;
747
+ if (enteredColumn && columnNames.indexOf(enteredColumn) !== -1) {
748
+ result = historyEvent.date;
725
749
  }
726
- });
727
- return state;
750
+ }
751
+ return result;
728
752
  }
729
753
 
730
754
  /**
@@ -905,14 +929,26 @@ function findDependencyCycle(dependencyMap) {
905
929
  * @param {Date} now
906
930
  * @return {object}
907
931
  */
908
- function buildGanttSchedule(index, tasks, now) {
932
+ function buildGanttSchedule(index, tasks, now, trackedTaskIds = new Set()) {
909
933
  const taskById = new Map(tasks.map((task) => [task.id, task]));
910
934
  const dependencyMap = new Map(tasks.map((task) => [task.id, new Set()]));
911
935
  const dependentsMap = new Map();
912
936
  const indegree = new Map(tasks.map((task) => [task.id, 0]));
937
+ const externalIncomingDependenciesMap = new Map(tasks.map((task) => [task.id, new Set()]));
938
+ const externalOutgoingDependentsMap = new Map(tasks.map((task) => [task.id, new Set()]));
939
+
940
+ const taskExistsInBoard = (taskId) => trackedTaskIds.has(taskId);
913
941
 
914
942
  const addDependencyEdge = (dependencyId, dependentId) => {
943
+ if (!taskById.has(dependentId)) {
944
+ throw new Error(`Task "${dependentId}" depends on missing task "${dependencyId}"`);
945
+ }
946
+
915
947
  if (!taskById.has(dependencyId)) {
948
+ if (taskExistsInBoard(dependencyId)) {
949
+ externalIncomingDependenciesMap.get(dependentId).add(dependencyId);
950
+ return;
951
+ }
916
952
  throw new Error(`Task "${dependentId}" depends on missing task "${dependencyId}"`);
917
953
  }
918
954
 
@@ -939,7 +975,13 @@ function buildGanttSchedule(index, tasks, now) {
939
975
  if (relationType === 'depends-on') {
940
976
  addDependencyEdge(relation.task, task.id);
941
977
  } else if (relationType === 'blocks') {
942
- addDependencyEdge(task.id, relation.task);
978
+ if (taskById.has(relation.task)) {
979
+ addDependencyEdge(task.id, relation.task);
980
+ } else if (taskExistsInBoard(relation.task)) {
981
+ externalOutgoingDependentsMap.get(task.id).add(relation.task);
982
+ } else {
983
+ throw new Error(`Task "${task.id}" blocks missing task "${relation.task}"`);
984
+ }
943
985
  }
944
986
  });
945
987
  });
@@ -1036,6 +1078,8 @@ function buildGanttSchedule(index, tasks, now) {
1036
1078
  const scheduledTask = {
1037
1079
  ...task,
1038
1080
  dependencies,
1081
+ externalIncomingDependencies: [...(externalIncomingDependenciesMap.get(task.id) || [])],
1082
+ externalOutgoingDependents: [...(externalOutgoingDependentsMap.get(task.id) || [])],
1039
1083
  start,
1040
1084
  end,
1041
1085
  blocked: dependencyEnd !== null && preferredStart instanceof Date && dependencyEnd.getTime() > preferredStart.getTime()
@@ -1057,31 +1101,13 @@ function buildGanttSchedule(index, tasks, now) {
1057
1101
  };
1058
1102
  }
1059
1103
 
1060
- /**
1061
- * Render a gantt bar for a scheduled task
1062
- * @param {object} task
1063
- * @param {Date} from
1064
- * @param {Date} to
1065
- * @return {string}
1066
- */
1067
- function renderGanttBar(task, from, to) {
1068
- const result = [];
1069
- for (let date = new Date(from.getTime()); date <= to; date = new Date(date.getTime() + DAY)) {
1070
- if (date < task.start || date > task.end) {
1071
- result.push(' ');
1072
- } else if (task.completed instanceof Date) {
1073
- result.push('█');
1074
- } else if (task.started instanceof Date) {
1075
- result.push('▓');
1076
- } else {
1077
- result.push('░');
1078
- }
1079
- }
1080
- return result.join('');
1081
- }
1082
-
1083
1104
  /**
1084
1105
  * Get a list of tasks that were started before and/or completed after a date
1106
+ *
1107
+ * A task is active from its started date until its completed date. This used to be derived by
1108
+ * replaying the task's history to work out which column it was in at the time and comparing that
1109
+ * column against the index options, which meant a task's activity depended on column names rather
1110
+ * than on the dates recorded against it
1085
1111
  * @param {object} index
1086
1112
  * @param {object[]} tasks
1087
1113
  * @param {Date} date
@@ -1089,14 +1115,8 @@ function renderGanttBar(task, from, to) {
1089
1115
  */
1090
1116
  function getActiveTasksAtDate(index, tasks, date) {
1091
1117
  return tasks.filter((task) => (
1092
- (() => {
1093
- const historyState = getTaskHistoryStateAtDate(task, date);
1094
- if (historyState !== null) {
1095
- return historyState.created && !historyState.archived && startedColumn(index, historyState.column) && !completedColumn(index, historyState.column);
1096
- }
1097
- return (task.started !== false && task.started <= date) &&
1098
- (task.completed === false || task.completed > date);
1099
- })()
1118
+ (task.started !== false && task.started <= date) &&
1119
+ (task.completed === false || task.completed > date)
1100
1120
  ));
1101
1121
  }
1102
1122
 
@@ -1190,12 +1210,19 @@ function normaliseDate(date, resolution = 'minutes') {
1190
1210
  * @param {object} index
1191
1211
  * @param {object} taskData
1192
1212
  * @param {string} columnName
1213
+ * @param {Date} [date=new Date()] The date to stamp
1193
1214
  * @return {object} The updated task data
1194
1215
  */
1195
- function updateColumnLinkedCustomFields(index, taskData, columnName) {
1196
- // Update built-in column-linked metadata properties first (started and completed dates)
1197
- taskData = updateColumnLinkedCustomField(index, taskData, columnName, "completed", "once");
1198
- taskData = updateColumnLinkedCustomField(index, taskData, columnName, "started", "once");
1216
+ function updateColumnLinkedCustomFields(index, taskData, columnName, date = new Date()) {
1217
+ // Update built-in column-linked metadata properties first (started and completed dates). The
1218
+ // columns are always listed in startedColumns/completedColumns, but the field that gets written
1219
+ // can be pointed elsewhere with startedField/completedField
1220
+ taskData = updateColumnLinkedCustomField(
1221
+ index, taskData, columnName, getCompletedField(index), "once", "completedColumns", date
1222
+ );
1223
+ taskData = updateColumnLinkedCustomField(
1224
+ index, taskData, columnName, getStartedField(index), "once", "startedColumns", date
1225
+ );
1199
1226
 
1200
1227
  // Update column-linked custom fields
1201
1228
  if ("customFields" in index.options) {
@@ -1206,7 +1233,9 @@ function updateColumnLinkedCustomFields(index, taskData, columnName) {
1206
1233
  taskData,
1207
1234
  columnName,
1208
1235
  customField.name,
1209
- customField.updateDate || "none"
1236
+ customField.updateDate || "none",
1237
+ null,
1238
+ date
1210
1239
  );
1211
1240
  }
1212
1241
  }
@@ -1225,17 +1254,23 @@ function updateColumnLinkedCustomFields(index, taskData, columnName) {
1225
1254
  * @param {string} columnName
1226
1255
  * @param {string} fieldName
1227
1256
  * @param {string} [updateCriteria='none']
1257
+ * @param {?string} [columnListName=null] The option holding the linked column names, or null to use
1258
+ * the `${fieldName}Columns` convention
1259
+ * @param {Date} [date=new Date()] The date to stamp
1228
1260
  */
1229
- function updateColumnLinkedCustomField(index, taskData, columnName, fieldName, updateCriteria = "none") {
1230
- const columnList = `${fieldName}Columns`;
1261
+ function updateColumnLinkedCustomField(
1262
+ index, taskData, columnName, fieldName, updateCriteria = "none", columnListName = null,
1263
+ date = new Date()
1264
+ ) {
1265
+ const columnList = columnListName || `${fieldName}Columns`;
1231
1266
  if (columnList in index.options && index.options[columnList].indexOf(columnName) !== -1) {
1232
1267
  switch (updateCriteria) {
1233
1268
  case "always":
1234
- taskData = setTaskMetadata(taskData, fieldName, new Date());
1269
+ taskData = setTaskMetadata(taskData, fieldName, date);
1235
1270
  break;
1236
1271
  case "once":
1237
1272
  if (!(fieldName in taskData.metadata && taskData.metadata[fieldName])) {
1238
- taskData = setTaskMetadata(taskData, fieldName, new Date());
1273
+ taskData = setTaskMetadata(taskData, fieldName, date);
1239
1274
  }
1240
1275
  break;
1241
1276
  default:
@@ -1411,7 +1446,7 @@ class Kanbn {
1411
1446
  * @return {Promise<task>} The task
1412
1447
  */
1413
1448
  async getTask(taskId) {
1414
- this.taskExists(taskId);
1449
+ await this.taskExists(taskId);
1415
1450
  return this.loadTask(taskId);
1416
1451
  }
1417
1452
 
@@ -1436,7 +1471,8 @@ class Kanbn {
1436
1471
 
1437
1472
  // A task is overdue if it's due date is in the past and the task is not in a completed column
1438
1473
  // or doesn't have a completed dates
1439
- const completedDate = "completed" in task.metadata ? task.metadata.completed : null;
1474
+ const completedField = getCompletedField(index);
1475
+ const completedDate = completedField in task.metadata ? task.metadata[completedField] : null;
1440
1476
 
1441
1477
  // Get task due delta - this is the difference between now and the due date, or if the task is completed
1442
1478
  // this is the difference between the completed and due dates
@@ -1478,7 +1514,7 @@ class Kanbn {
1478
1514
  * @return {object[]} A filtered and sorted list of tasks
1479
1515
  */
1480
1516
  filterAndSortTasks(index, tasks, filters, sorters) {
1481
- return sortTasks(filterTasks(index, tasks, filters), sorters);
1517
+ return sortTasksWithFields(index, filterTasks(index, tasks, filters), sorters);
1482
1518
  }
1483
1519
 
1484
1520
  /**
@@ -1857,9 +1893,10 @@ class Kanbn {
1857
1893
  * @param {string} taskId The id of the task to update
1858
1894
  * @param {object} taskData The new task data
1859
1895
  * @param {?string} [columnName=null] The column name to move this task to, or null if not moving this task
1896
+ * @param {string[]} [unsetFields=[]] Metadata field names to remove from the task
1860
1897
  * @return {Promise<string>} The id of the task that was updated
1861
1898
  */
1862
- async updateTask(taskId, taskData, columnName = null) {
1899
+ async updateTask(taskId, taskData, columnName = null, unsetFields = []) {
1863
1900
  // Check if this folder has been initialised
1864
1901
  if (!(await this.initialised())) {
1865
1902
  throw new Error("Not initialised in this folder");
@@ -1921,6 +1958,19 @@ class Kanbn {
1921
1958
  } else {
1922
1959
  await this.saveIndex(index);
1923
1960
  }
1961
+
1962
+ // Remove any explicitly unset metadata fields last. This has to happen after the move, because
1963
+ // moving into a started or completed column stamps the linked date fields - an explicit unset
1964
+ // from the user should win over that automatic stamp
1965
+ if (unsetFields.length) {
1966
+ const finalTaskData = await this.loadTask(taskId);
1967
+ if ("metadata" in finalTaskData) {
1968
+ for (const field of unsetFields) {
1969
+ delete finalTaskData.metadata[field];
1970
+ }
1971
+ }
1972
+ await this.saveTask(getTaskPath(await this.getTaskFolderPath(), taskId), finalTaskData);
1973
+ }
1924
1974
  return taskId;
1925
1975
  }
1926
1976
 
@@ -2009,13 +2059,18 @@ class Kanbn {
2009
2059
  // Find current column before mutating data
2010
2060
  const currentColumnName = findTaskColumn(index, taskId);
2011
2061
 
2062
+ // Stamp every date this move writes with the same timestamp, so that the history event and the
2063
+ // dates it backfills from can't disagree by a millisecond
2064
+ const moveDate = new Date();
2065
+
2012
2066
  // Update the task's updated date
2013
2067
  let taskData = await this.loadTask(taskId);
2014
- taskData = setTaskMetadata(taskData, "updated", new Date());
2068
+ taskData = setTaskMetadata(taskData, "updated", moveDate);
2015
2069
 
2016
2070
  // Add history only when a task changes columns
2017
2071
  if (currentColumnName !== columnName) {
2018
2072
  taskData = appendTaskHistory(taskData, {
2073
+ date: moveDate,
2019
2074
  type: 'moved',
2020
2075
  fromColumn: currentColumnName,
2021
2076
  toColumn: columnName
@@ -2023,7 +2078,7 @@ class Kanbn {
2023
2078
  }
2024
2079
 
2025
2080
  // Update task metadata dates
2026
- taskData = updateColumnLinkedCustomFields(index, taskData, columnName);
2081
+ taskData = updateColumnLinkedCustomFields(index, taskData, columnName, moveDate);
2027
2082
  await this.saveTask(getTaskPath(await this.getTaskFolderPath(), taskId), taskData);
2028
2083
 
2029
2084
  // If we're moving the task to a new position, calculate the absolute position
@@ -2112,6 +2167,8 @@ class Kanbn {
2112
2167
  // Get index and column names
2113
2168
  const index = await this.loadIndex();
2114
2169
  const columnNames = Object.keys(index.columns);
2170
+ const startedField = getStartedField(index);
2171
+ const completedField = getCompletedField(index);
2115
2172
 
2116
2173
  // Prepare output
2117
2174
  const result = {
@@ -2276,8 +2333,8 @@ class Kanbn {
2276
2333
 
2277
2334
  // Add task workload information for the sprint
2278
2335
  result.sprint.created = taskWorkloadInPeriod(tasks, "created", sprintStartDate, sprintEndDate);
2279
- result.sprint.started = taskWorkloadInPeriod(tasks, "started", sprintStartDate, sprintEndDate);
2280
- result.sprint.completed = taskWorkloadInPeriod(tasks, "completed", sprintStartDate, sprintEndDate);
2336
+ result.sprint.started = taskWorkloadInPeriod(tasks, startedField, sprintStartDate, sprintEndDate);
2337
+ result.sprint.completed = taskWorkloadInPeriod(tasks, completedField, sprintStartDate, sprintEndDate);
2281
2338
  result.sprint.due = taskWorkloadInPeriod(tasks, "due", sprintStartDate, sprintEndDate);
2282
2339
 
2283
2340
  // Add custom date property workload information for the sprint
@@ -2311,8 +2368,8 @@ class Kanbn {
2311
2368
  result.period.end = periodEnd = new Date(Math.max(...dates));
2312
2369
  }
2313
2370
  result.period.created = taskWorkloadInPeriod(tasks, "created", periodStart, periodEnd);
2314
- result.period.started = taskWorkloadInPeriod(tasks, "started", periodStart, periodEnd);
2315
- result.period.completed = taskWorkloadInPeriod(tasks, "completed", periodStart, periodEnd);
2371
+ result.period.started = taskWorkloadInPeriod(tasks, startedField, periodStart, periodEnd);
2372
+ result.period.completed = taskWorkloadInPeriod(tasks, completedField, periodStart, periodEnd);
2316
2373
  result.period.due = taskWorkloadInPeriod(tasks, "due", periodStart, periodEnd);
2317
2374
 
2318
2375
  // Add custom date property workload information for the selected date range
@@ -2386,6 +2443,122 @@ class Kanbn {
2386
2443
  return true;
2387
2444
  }
2388
2445
 
2446
+ /**
2447
+ * Find tasks whose started/completed dates disagree with the column they're in
2448
+ *
2449
+ * Task state comes from metadata dates only, so a task moved between columns by hand (by editing
2450
+ * the index file, or through a merge) won't have had its dates updated. This reports both
2451
+ * directions: a task sitting in a started or completed column with no date, and a task carrying a
2452
+ * completed date while sitting somewhere else
2453
+ * @return {Promise<object[]>} A list of drift entries
2454
+ */
2455
+ async findDateDrift() {
2456
+ // Check if this folder has been initialised
2457
+ if (!(await this.initialised())) {
2458
+ throw new Error("Not initialised in this folder");
2459
+ }
2460
+ const index = await this.loadIndex();
2461
+ const tasks = [];
2462
+ for (const taskId of getTrackedTaskIds(index)) {
2463
+ try {
2464
+ tasks.push(await this.loadTask(taskId));
2465
+ } catch (error) {
2466
+ // Unreadable tasks are reported by validate(), not here
2467
+ }
2468
+ }
2469
+ return this.getDateDrift(index, tasks);
2470
+ }
2471
+
2472
+ /**
2473
+ * Find date drift in a list of tasks that have already been loaded
2474
+ * @param {object} index The index object
2475
+ * @param {object[]} tasks Loaded task objects
2476
+ * @return {object[]} A list of drift entries
2477
+ */
2478
+ getDateDrift(index, tasks) {
2479
+ const startedField = getStartedField(index);
2480
+ const completedField = getCompletedField(index);
2481
+ const startedColumns = "startedColumns" in index.options ? index.options.startedColumns : [];
2482
+ const completedColumns = "completedColumns" in index.options ? index.options.completedColumns : [];
2483
+ const drift = [];
2484
+
2485
+ for (const task of tasks) {
2486
+ const column = findTaskColumn(index, task.id);
2487
+
2488
+ // A task in a started or completed column with no date to say when that happened. The date is
2489
+ // usually recoverable from history, so these can be backfilled
2490
+ for (const [field, columns] of [[completedField, completedColumns], [startedField, startedColumns]]) {
2491
+ if (columns.indexOf(column) === -1 || field in task.metadata) {
2492
+ continue;
2493
+ }
2494
+ const historyDate = findColumnEntryDate(task, columns);
2495
+ const date = historyDate
2496
+ || ("updated" in task.metadata ? task.metadata.updated : null)
2497
+ || ("created" in task.metadata ? task.metadata.created : null);
2498
+ drift.push({
2499
+ task: task.id,
2500
+ column,
2501
+ field,
2502
+ issue: "missing",
2503
+ message: `task is in column "${column}" but has no ${field} date`,
2504
+ fixable: date !== null,
2505
+ date,
2506
+ source: date === null
2507
+ ? null
2508
+ : historyDate
2509
+ ? "history"
2510
+ : "updated" in task.metadata && task.metadata.updated === date
2511
+ ? "updated"
2512
+ : "created"
2513
+ });
2514
+ }
2515
+
2516
+ // A task carrying a completed date while sitting outside the completed columns. This is left
2517
+ // alone: the date records something that really happened, and removing it would lose it
2518
+ if (
2519
+ completedColumns.length > 0 &&
2520
+ completedField in task.metadata &&
2521
+ completedColumns.indexOf(column) === -1
2522
+ ) {
2523
+ drift.push({
2524
+ task: task.id,
2525
+ column,
2526
+ field: completedField,
2527
+ issue: "unexpected",
2528
+ message: `task has a ${completedField} date but is in column "${column}"`,
2529
+ fixable: false,
2530
+ date: null,
2531
+ source: null
2532
+ });
2533
+ }
2534
+ }
2535
+ return drift;
2536
+ }
2537
+
2538
+ /**
2539
+ * Check whether hints and notices should be shown for this board
2540
+ * @param {object} index The index object
2541
+ * @return {boolean} True if hints should be shown
2542
+ */
2543
+ isVerbose(index) {
2544
+ return !("verbose" in index.options) || index.options.verbose !== false;
2545
+ }
2546
+
2547
+ /**
2548
+ * Backfill missing started/completed dates for tasks that are in a started or completed column
2549
+ * @return {Promise<object[]>} The drift entries that were fixed
2550
+ */
2551
+ async fixDateDrift() {
2552
+ const fixed = [];
2553
+ for (const item of (await this.findDateDrift()).filter((d) => d.fixable && d.issue === "missing")) {
2554
+ const task = await this.loadTask(item.task);
2555
+ setTaskMetadata(task, item.field, item.date);
2556
+ await this.saveTask(getTaskPath(await this.getTaskFolderPath(), item.task), task);
2557
+ fixed.push(item);
2558
+ }
2559
+ return fixed;
2560
+ }
2561
+
2389
2562
  /**
2390
2563
  * Sort a column in the index
2391
2564
  * @param {string} columnName The column name to sort
@@ -2481,6 +2654,8 @@ class Kanbn {
2481
2654
 
2482
2655
  // Get index and tasks
2483
2656
  const index = await this.loadIndex();
2657
+ const startedField = getStartedField(index);
2658
+ const completedField = getCompletedField(index);
2484
2659
  const tasks = [...(await this.loadAllTrackedTasks(index))]
2485
2660
  .map((task) => {
2486
2661
  const taskColumn = findTaskColumn(index, task.id);
@@ -2488,18 +2663,12 @@ class Kanbn {
2488
2663
  return {
2489
2664
  ...task,
2490
2665
  created,
2491
- started:
2492
- "started" in task.metadata
2493
- ? task.metadata.started
2494
- : "startedColumns" in index.options && index.options.startedColumns.indexOf(taskColumn) !== -1
2495
- ? created
2496
- : false,
2497
- completed:
2498
- "completed" in task.metadata
2499
- ? task.metadata.completed
2500
- : "completedColumns" in index.options && index.options.completedColumns.indexOf(taskColumn) !== -1
2501
- ? created
2502
- : false,
2666
+
2667
+ // A task's started and completed dates come from its metadata only. They used to fall back
2668
+ // to the task's created date whenever it sat in a started or completed column, which
2669
+ // invented timings that had nothing to do with when the work actually happened
2670
+ started: startedField in task.metadata ? task.metadata[startedField] : false,
2671
+ completed: completedField in task.metadata ? task.metadata[completedField] : false,
2503
2672
  progress: taskProgress(index, task),
2504
2673
  assigned: "assigned" in task.metadata ? task.metadata.assigned : null,
2505
2674
  workload: taskWorkload(index, task),
@@ -2533,8 +2702,8 @@ class Kanbn {
2533
2702
  .map((t) =>
2534
2703
  [
2535
2704
  "created" in t.metadata && t.metadata.created,
2536
- "started" in t.metadata && t.metadata.started,
2537
- "completed" in t.metadata && (t.metadata.completed || new Date(8640000000000000))
2705
+ startedField in t.metadata && t.metadata[startedField],
2706
+ completedField in t.metadata && (t.metadata[completedField] || new Date(8640000000000000))
2538
2707
  ].filter((d) => d)
2539
2708
  )
2540
2709
  .flat()
@@ -2811,6 +2980,8 @@ class Kanbn {
2811
2980
 
2812
2981
  // Get index and tasks
2813
2982
  const index = await this.loadIndex();
2983
+ const startedField = getStartedField(index);
2984
+ const completedField = getCompletedField(index);
2814
2985
  const tasks = [...(await this.loadAllTrackedTasks(index))]
2815
2986
  .map((task) => {
2816
2987
  const taskColumn = findTaskColumn(index, task.id);
@@ -2818,8 +2989,8 @@ class Kanbn {
2818
2989
  return {
2819
2990
  ...task,
2820
2991
  created,
2821
- started: "started" in task.metadata ? task.metadata.started : false,
2822
- completed: "completed" in task.metadata ? task.metadata.completed : false,
2992
+ started: startedField in task.metadata ? task.metadata[startedField] : false,
2993
+ completed: completedField in task.metadata ? task.metadata[completedField] : false,
2823
2994
  plannedStart: "plannedStart" in task.metadata ? task.metadata.plannedStart : false,
2824
2995
  plannedFinish: "plannedFinish" in task.metadata ? task.metadata.plannedFinish : false,
2825
2996
  workload: taskWorkload(index, task),
@@ -2850,7 +3021,8 @@ class Kanbn {
2850
3021
  }
2851
3022
  );
2852
3023
 
2853
- return buildGanttSchedule(index, tasks, effectiveNow);
3024
+ const trackedTaskIds = getTrackedTaskIds(index);
3025
+ return buildGanttSchedule(index, tasks, effectiveNow, trackedTaskIds);
2854
3026
  }
2855
3027
 
2856
3028
  /**