@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.
- package/README.md +2 -0
- package/coverage/base.css +224 -0
- package/coverage/block-navigation.js +87 -0
- package/coverage/coverage-summary.json +31 -0
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +146 -0
- package/coverage/kanbn/index.html +116 -0
- package/coverage/kanbn/index.js.html +217 -0
- package/coverage/kanbn/src/board.js.html +646 -0
- package/coverage/kanbn/src/controller/add.js.html +1729 -0
- package/coverage/kanbn/src/controller/archive.js.html +277 -0
- package/coverage/kanbn/src/controller/board.js.html +217 -0
- package/coverage/kanbn/src/controller/burndown.js.html +607 -0
- package/coverage/kanbn/src/controller/comment.js.html +397 -0
- package/coverage/kanbn/src/controller/edit.js.html +2920 -0
- package/coverage/kanbn/src/controller/find.js.html +1597 -0
- package/coverage/kanbn/src/controller/gantt.js.html +1342 -0
- package/coverage/kanbn/src/controller/help.js.html +115 -0
- package/coverage/kanbn/src/controller/history.js.html +400 -0
- package/coverage/kanbn/src/controller/index.html +431 -0
- package/coverage/kanbn/src/controller/init.js.html +511 -0
- package/coverage/kanbn/src/controller/move.js.html +514 -0
- package/coverage/kanbn/src/controller/remove-all.js.html +226 -0
- package/coverage/kanbn/src/controller/remove.js.html +307 -0
- package/coverage/kanbn/src/controller/rename.js.html +397 -0
- package/coverage/kanbn/src/controller/restore.js.html +268 -0
- package/coverage/kanbn/src/controller/sort.js.html +1318 -0
- package/coverage/kanbn/src/controller/sprint.js.html +361 -0
- package/coverage/kanbn/src/controller/status.js.html +310 -0
- package/coverage/kanbn/src/controller/task.js.html +244 -0
- package/coverage/kanbn/src/controller/validate.js.html +367 -0
- package/coverage/kanbn/src/controller/version.js.html +100 -0
- package/coverage/kanbn/src/index.html +191 -0
- package/coverage/kanbn/src/main.js.html +9787 -0
- package/coverage/kanbn/src/parse-index.js.html +1063 -0
- package/coverage/kanbn/src/parse-markdown.js.html +202 -0
- package/coverage/kanbn/src/parse-task.js.html +2191 -0
- package/coverage/kanbn/src/utility.js.html +616 -0
- package/coverage/lcov.info +11837 -0
- package/coverage/prettify.css +1 -0
- package/coverage/prettify.js +2 -0
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +210 -0
- package/coverage/tmp/coverage-214292-1787777191526-0.json +1 -0
- package/coverage/tmp/coverage-214293-1787777184569-0.json +1 -0
- package/docs/advanced-configuration.md +2 -0
- package/docs/commands/add.txt +10 -0
- package/docs/commands/board.txt +3 -1
- package/docs/commands/burndown.txt +2 -4
- package/docs/commands/edit.txt +22 -0
- package/docs/commands/find.txt +45 -1
- package/docs/commands/help.txt +1 -0
- package/docs/commands/move.txt +1 -1
- package/docs/commands/sort.txt +13 -2
- package/docs/commands/validate.txt +12 -0
- package/docs/filtering-and-sorting.md +218 -0
- package/docs/index-structure.md +68 -19
- package/docs/index.md +26 -2
- package/docs/migration-2.0.md +92 -0
- package/docs/quick-start.md +37 -1
- package/docs/task-structure.md +12 -3
- package/docs/views.md +229 -0
- package/example/README.md +70 -0
- package/example/advanced/.kanbn/index.md +26 -0
- package/example/advanced/.kanbn/tasks/add-rate-limiting.md +18 -0
- package/example/advanced/.kanbn/tasks/add-sso-support.md +15 -0
- package/example/advanced/.kanbn/tasks/migrate-to-new-cdn.md +14 -0
- package/example/advanced/.kanbn/tasks/refresh-marketing-site.md +14 -0
- package/example/advanced/.kanbn/tasks/rotate-api-keys.md +15 -0
- package/example/advanced/.kanbn/tasks/split-billing-service.md +17 -0
- package/example/advanced/.kanbn/tasks/upgrade-node-runtime.md +19 -0
- package/example/advanced/kanbn.yml +59 -0
- package/example/views/.kanbn/index.md +140 -0
- package/example/views/.kanbn/tasks/add-audit-log-export.md +18 -0
- package/example/views/.kanbn/tasks/add-dark-mode-toggle.md +16 -0
- package/example/views/.kanbn/tasks/build-webhook-retry-queue.md +15 -0
- package/example/views/.kanbn/tasks/fix-avatar-upload-crash.md +13 -0
- package/example/views/.kanbn/tasks/fix-expired-session-redirect.md +15 -0
- package/example/views/.kanbn/tasks/investigate-offline-mode.md +12 -0
- package/example/views/.kanbn/tasks/rewrite-search-indexer.md +12 -0
- package/example/views/.kanbn/tasks/tidy-up-settings-copy.md +12 -0
- package/package.json +4 -3
- package/routes/edit.json +2 -1
- package/routes/validate.json +4 -2
- package/skills/kanbn-plan/references/index-structure.md +1 -0
- package/skills/kanbn-plan/references/task-structure.md +2 -0
- package/src/board.js +1 -0
- package/src/controller/board.js +6 -0
- package/src/controller/edit.js +65 -12
- package/src/controller/status.js +12 -0
- package/src/controller/task.js +1 -1
- package/src/controller/validate.js +78 -15
- package/src/main.js +300 -149
- package/src/parse-index.js +3 -0
- package/src/parse-markdown.js +3 -1
- package/src/parse-task.js +28 -1
- package/src/utility.js +26 -1
- package/PLANS.md +0 -9
- /package/example/{.kanbn → basic/.kanbn}/index.md +0 -0
- /package/example/{.kanbn → basic/.kanbn}/tasks/add-basic-activity-feed.md +0 -0
- /package/example/{.kanbn → basic/.kanbn}/tasks/add-passwordless-login-option.md +0 -0
- /package/example/{.kanbn → basic/.kanbn}/tasks/add-usage-alert-email-thresholds.md +0 -0
- /package/example/{.kanbn → basic/.kanbn}/tasks/build-email-template-system.md +0 -0
- /package/example/{.kanbn → basic/.kanbn}/tasks/build-invoice-download-endpoint.md +0 -0
- /package/example/{.kanbn → basic/.kanbn}/tasks/build-tenant-settings-page.md +0 -0
- /package/example/{.kanbn → basic/.kanbn}/tasks/create-organization-switcher.md +0 -0
- /package/example/{.kanbn → basic/.kanbn}/tasks/create-sandbox-environment-provisioner.md +0 -0
- /package/example/{.kanbn → basic/.kanbn}/tasks/create-self-serve-cancellation-flow.md +0 -0
- /package/example/{.kanbn → basic/.kanbn}/tasks/define-product-pricing-strategy.md +0 -0
- /package/example/{.kanbn → basic/.kanbn}/tasks/design-onboarding-checklist.md +0 -0
- /package/example/{.kanbn → basic/.kanbn}/tasks/design-team-invite-expiry-flow.md +0 -0
- /package/example/{.kanbn → basic/.kanbn}/tasks/implement-data-retention-policy-jobs.md +0 -0
- /package/example/{.kanbn → basic/.kanbn}/tasks/implement-feature-flags-foundation.md +0 -0
- /package/example/{.kanbn → basic/.kanbn}/tasks/implement-project-creation-wizard.md +0 -0
- /package/example/{.kanbn → basic/.kanbn}/tasks/implement-stripe-webhook-signature-check.md +0 -0
- /package/example/{.kanbn → basic/.kanbn}/tasks/implement-team-permissions-ui.md +0 -0
- /package/example/{.kanbn → basic/.kanbn}/tasks/implement-user-signup-and-login.md +0 -0
- /package/example/{.kanbn → basic/.kanbn}/tasks/integrate-crm-lead-sync.md +0 -0
- /package/example/{.kanbn → basic/.kanbn}/tasks/legal-review-terms-and-privacy.md +0 -0
- /package/example/{.kanbn → basic/.kanbn}/tasks/migrate-legacy-events-to-new-schema.md +0 -0
- /package/example/{.kanbn → basic/.kanbn}/tasks/optimize-dashboard-first-load.md +0 -0
- /package/example/{.kanbn → basic/.kanbn}/tasks/prototype-report-export-scheduler.md +0 -0
- /package/example/{.kanbn → basic/.kanbn}/tasks/publish-internal-qa-checklist.md +0 -0
- /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
|
-
*
|
|
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
|
|
255
|
-
* @
|
|
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
|
|
260
|
-
|
|
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
|
-
|
|
285
|
-
|
|
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
|
|
303
|
-
compareB = b
|
|
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 (
|
|
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
|
-
*
|
|
660
|
-
*
|
|
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 {
|
|
690
|
-
* @return {?
|
|
730
|
+
* @param {string[]} columnNames
|
|
731
|
+
* @return {?Date}
|
|
691
732
|
*/
|
|
692
|
-
function
|
|
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
|
|
698
|
-
|
|
699
|
-
|
|
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
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1218
|
-
|
|
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(
|
|
1250
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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",
|
|
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,
|
|
2300
|
-
result.sprint.completed = taskWorkloadInPeriod(tasks,
|
|
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,
|
|
2335
|
-
result.period.completed = taskWorkloadInPeriod(tasks,
|
|
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
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
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
|
-
|
|
2557
|
-
|
|
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:
|
|
2842
|
-
completed:
|
|
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),
|