@basementuniverse/kanbn 1.1.1 → 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/status.js +12 -0
  91. package/src/controller/task.js +1 -1
  92. package/src/controller/validate.js +78 -15
  93. package/src/main.js +300 -149
  94. package/src/parse-index.js +3 -0
  95. package/src/parse-markdown.js +3 -1
  96. package/src/parse-task.js +28 -1
  97. package/src/utility.js +26 -1
  98. package/PLANS.md +0 -9
  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
+ }
283
303
 
284
- // Sort the list of tasks
285
- tasks = sortTasks(tasks, sorters);
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
+ }
321
+
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
  /**
@@ -1077,31 +1101,13 @@ function buildGanttSchedule(index, tasks, now, trackedTaskIds = new Set()) {
1077
1101
  };
1078
1102
  }
1079
1103
 
1080
- /**
1081
- * Render a gantt bar for a scheduled task
1082
- * @param {object} task
1083
- * @param {Date} from
1084
- * @param {Date} to
1085
- * @return {string}
1086
- */
1087
- function renderGanttBar(task, from, to) {
1088
- const result = [];
1089
- for (let date = new Date(from.getTime()); date <= to; date = new Date(date.getTime() + DAY)) {
1090
- if (date < task.start || date > task.end) {
1091
- result.push(' ');
1092
- } else if (task.completed instanceof Date) {
1093
- result.push('█');
1094
- } else if (task.started instanceof Date) {
1095
- result.push('▓');
1096
- } else {
1097
- result.push('░');
1098
- }
1099
- }
1100
- return result.join('');
1101
- }
1102
-
1103
1104
  /**
1104
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
1105
1111
  * @param {object} index
1106
1112
  * @param {object[]} tasks
1107
1113
  * @param {Date} date
@@ -1109,14 +1115,8 @@ function renderGanttBar(task, from, to) {
1109
1115
  */
1110
1116
  function getActiveTasksAtDate(index, tasks, date) {
1111
1117
  return tasks.filter((task) => (
1112
- (() => {
1113
- const historyState = getTaskHistoryStateAtDate(task, date);
1114
- if (historyState !== null) {
1115
- return historyState.created && !historyState.archived && startedColumn(index, historyState.column) && !completedColumn(index, historyState.column);
1116
- }
1117
- return (task.started !== false && task.started <= date) &&
1118
- (task.completed === false || task.completed > date);
1119
- })()
1118
+ (task.started !== false && task.started <= date) &&
1119
+ (task.completed === false || task.completed > date)
1120
1120
  ));
1121
1121
  }
1122
1122
 
@@ -1210,12 +1210,19 @@ function normaliseDate(date, resolution = 'minutes') {
1210
1210
  * @param {object} index
1211
1211
  * @param {object} taskData
1212
1212
  * @param {string} columnName
1213
+ * @param {Date} [date=new Date()] The date to stamp
1213
1214
  * @return {object} The updated task data
1214
1215
  */
1215
- function updateColumnLinkedCustomFields(index, taskData, columnName) {
1216
- // Update built-in column-linked metadata properties first (started and completed dates)
1217
- taskData = updateColumnLinkedCustomField(index, taskData, columnName, "completed", "once");
1218
- 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
+ );
1219
1226
 
1220
1227
  // Update column-linked custom fields
1221
1228
  if ("customFields" in index.options) {
@@ -1226,7 +1233,9 @@ function updateColumnLinkedCustomFields(index, taskData, columnName) {
1226
1233
  taskData,
1227
1234
  columnName,
1228
1235
  customField.name,
1229
- customField.updateDate || "none"
1236
+ customField.updateDate || "none",
1237
+ null,
1238
+ date
1230
1239
  );
1231
1240
  }
1232
1241
  }
@@ -1245,17 +1254,23 @@ function updateColumnLinkedCustomFields(index, taskData, columnName) {
1245
1254
  * @param {string} columnName
1246
1255
  * @param {string} fieldName
1247
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
1248
1260
  */
1249
- function updateColumnLinkedCustomField(index, taskData, columnName, fieldName, updateCriteria = "none") {
1250
- 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`;
1251
1266
  if (columnList in index.options && index.options[columnList].indexOf(columnName) !== -1) {
1252
1267
  switch (updateCriteria) {
1253
1268
  case "always":
1254
- taskData = setTaskMetadata(taskData, fieldName, new Date());
1269
+ taskData = setTaskMetadata(taskData, fieldName, date);
1255
1270
  break;
1256
1271
  case "once":
1257
1272
  if (!(fieldName in taskData.metadata && taskData.metadata[fieldName])) {
1258
- taskData = setTaskMetadata(taskData, fieldName, new Date());
1273
+ taskData = setTaskMetadata(taskData, fieldName, date);
1259
1274
  }
1260
1275
  break;
1261
1276
  default:
@@ -1431,7 +1446,7 @@ class Kanbn {
1431
1446
  * @return {Promise<task>} The task
1432
1447
  */
1433
1448
  async getTask(taskId) {
1434
- this.taskExists(taskId);
1449
+ await this.taskExists(taskId);
1435
1450
  return this.loadTask(taskId);
1436
1451
  }
1437
1452
 
@@ -1456,7 +1471,8 @@ class Kanbn {
1456
1471
 
1457
1472
  // A task is overdue if it's due date is in the past and the task is not in a completed column
1458
1473
  // or doesn't have a completed dates
1459
- 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;
1460
1476
 
1461
1477
  // Get task due delta - this is the difference between now and the due date, or if the task is completed
1462
1478
  // this is the difference between the completed and due dates
@@ -1498,7 +1514,7 @@ class Kanbn {
1498
1514
  * @return {object[]} A filtered and sorted list of tasks
1499
1515
  */
1500
1516
  filterAndSortTasks(index, tasks, filters, sorters) {
1501
- return sortTasks(filterTasks(index, tasks, filters), sorters);
1517
+ return sortTasksWithFields(index, filterTasks(index, tasks, filters), sorters);
1502
1518
  }
1503
1519
 
1504
1520
  /**
@@ -1877,9 +1893,10 @@ class Kanbn {
1877
1893
  * @param {string} taskId The id of the task to update
1878
1894
  * @param {object} taskData The new task data
1879
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
1880
1897
  * @return {Promise<string>} The id of the task that was updated
1881
1898
  */
1882
- async updateTask(taskId, taskData, columnName = null) {
1899
+ async updateTask(taskId, taskData, columnName = null, unsetFields = []) {
1883
1900
  // Check if this folder has been initialised
1884
1901
  if (!(await this.initialised())) {
1885
1902
  throw new Error("Not initialised in this folder");
@@ -1941,6 +1958,19 @@ class Kanbn {
1941
1958
  } else {
1942
1959
  await this.saveIndex(index);
1943
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
+ }
1944
1974
  return taskId;
1945
1975
  }
1946
1976
 
@@ -2029,13 +2059,18 @@ class Kanbn {
2029
2059
  // Find current column before mutating data
2030
2060
  const currentColumnName = findTaskColumn(index, taskId);
2031
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
+
2032
2066
  // Update the task's updated date
2033
2067
  let taskData = await this.loadTask(taskId);
2034
- taskData = setTaskMetadata(taskData, "updated", new Date());
2068
+ taskData = setTaskMetadata(taskData, "updated", moveDate);
2035
2069
 
2036
2070
  // Add history only when a task changes columns
2037
2071
  if (currentColumnName !== columnName) {
2038
2072
  taskData = appendTaskHistory(taskData, {
2073
+ date: moveDate,
2039
2074
  type: 'moved',
2040
2075
  fromColumn: currentColumnName,
2041
2076
  toColumn: columnName
@@ -2043,7 +2078,7 @@ class Kanbn {
2043
2078
  }
2044
2079
 
2045
2080
  // Update task metadata dates
2046
- taskData = updateColumnLinkedCustomFields(index, taskData, columnName);
2081
+ taskData = updateColumnLinkedCustomFields(index, taskData, columnName, moveDate);
2047
2082
  await this.saveTask(getTaskPath(await this.getTaskFolderPath(), taskId), taskData);
2048
2083
 
2049
2084
  // If we're moving the task to a new position, calculate the absolute position
@@ -2132,6 +2167,8 @@ class Kanbn {
2132
2167
  // Get index and column names
2133
2168
  const index = await this.loadIndex();
2134
2169
  const columnNames = Object.keys(index.columns);
2170
+ const startedField = getStartedField(index);
2171
+ const completedField = getCompletedField(index);
2135
2172
 
2136
2173
  // Prepare output
2137
2174
  const result = {
@@ -2296,8 +2333,8 @@ class Kanbn {
2296
2333
 
2297
2334
  // Add task workload information for the sprint
2298
2335
  result.sprint.created = taskWorkloadInPeriod(tasks, "created", sprintStartDate, sprintEndDate);
2299
- result.sprint.started = taskWorkloadInPeriod(tasks, "started", sprintStartDate, sprintEndDate);
2300
- 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);
2301
2338
  result.sprint.due = taskWorkloadInPeriod(tasks, "due", sprintStartDate, sprintEndDate);
2302
2339
 
2303
2340
  // Add custom date property workload information for the sprint
@@ -2331,8 +2368,8 @@ class Kanbn {
2331
2368
  result.period.end = periodEnd = new Date(Math.max(...dates));
2332
2369
  }
2333
2370
  result.period.created = taskWorkloadInPeriod(tasks, "created", periodStart, periodEnd);
2334
- result.period.started = taskWorkloadInPeriod(tasks, "started", periodStart, periodEnd);
2335
- 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);
2336
2373
  result.period.due = taskWorkloadInPeriod(tasks, "due", periodStart, periodEnd);
2337
2374
 
2338
2375
  // Add custom date property workload information for the selected date range
@@ -2406,6 +2443,122 @@ class Kanbn {
2406
2443
  return true;
2407
2444
  }
2408
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
+
2409
2562
  /**
2410
2563
  * Sort a column in the index
2411
2564
  * @param {string} columnName The column name to sort
@@ -2501,6 +2654,8 @@ class Kanbn {
2501
2654
 
2502
2655
  // Get index and tasks
2503
2656
  const index = await this.loadIndex();
2657
+ const startedField = getStartedField(index);
2658
+ const completedField = getCompletedField(index);
2504
2659
  const tasks = [...(await this.loadAllTrackedTasks(index))]
2505
2660
  .map((task) => {
2506
2661
  const taskColumn = findTaskColumn(index, task.id);
@@ -2508,18 +2663,12 @@ class Kanbn {
2508
2663
  return {
2509
2664
  ...task,
2510
2665
  created,
2511
- started:
2512
- "started" in task.metadata
2513
- ? task.metadata.started
2514
- : "startedColumns" in index.options && index.options.startedColumns.indexOf(taskColumn) !== -1
2515
- ? created
2516
- : false,
2517
- completed:
2518
- "completed" in task.metadata
2519
- ? task.metadata.completed
2520
- : "completedColumns" in index.options && index.options.completedColumns.indexOf(taskColumn) !== -1
2521
- ? created
2522
- : 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,
2523
2672
  progress: taskProgress(index, task),
2524
2673
  assigned: "assigned" in task.metadata ? task.metadata.assigned : null,
2525
2674
  workload: taskWorkload(index, task),
@@ -2553,8 +2702,8 @@ class Kanbn {
2553
2702
  .map((t) =>
2554
2703
  [
2555
2704
  "created" in t.metadata && t.metadata.created,
2556
- "started" in t.metadata && t.metadata.started,
2557
- "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))
2558
2707
  ].filter((d) => d)
2559
2708
  )
2560
2709
  .flat()
@@ -2831,6 +2980,8 @@ class Kanbn {
2831
2980
 
2832
2981
  // Get index and tasks
2833
2982
  const index = await this.loadIndex();
2983
+ const startedField = getStartedField(index);
2984
+ const completedField = getCompletedField(index);
2834
2985
  const tasks = [...(await this.loadAllTrackedTasks(index))]
2835
2986
  .map((task) => {
2836
2987
  const taskColumn = findTaskColumn(index, task.id);
@@ -2838,8 +2989,8 @@ class Kanbn {
2838
2989
  return {
2839
2990
  ...task,
2840
2991
  created,
2841
- started: "started" in task.metadata ? task.metadata.started : false,
2842
- 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,
2843
2994
  plannedStart: "plannedStart" in task.metadata ? task.metadata.plannedStart : false,
2844
2995
  plannedFinish: "plannedFinish" in task.metadata ? task.metadata.plannedFinish : false,
2845
2996
  workload: taskWorkload(index, task),