@harborppm/cli 0.37.0 → 0.38.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 +5 -1
- package/dist/app.mjs +539 -6
- package/lib/cli.mjs +113 -2
- package/lib/format.mjs +70 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -151,7 +151,11 @@ harbor issue "TITLE" --project P|--program P [--impact 1-5] [--owner NAME]
|
|
|
151
151
|
harbor decision "TITLE" --project P|--program P [--owner NAME] [--due DATE]
|
|
152
152
|
harbor risks [words] [--kind risk,issue,decision] [--project P] [--closed]
|
|
153
153
|
harbor brief [--portfolio P] [--say] the weekly brief, ready to send
|
|
154
|
-
harbor report PROGRAM a program's status report
|
|
154
|
+
harbor report PROGRAM a program's status report, as Markdown
|
|
155
|
+
harbor export tasks PROJECT|risks|review [--all]
|
|
156
|
+
a spreadsheet (CSV) to stdout
|
|
157
|
+
harbor import FILE [--project P] [--portfolio P] [--no-people]
|
|
158
|
+
add tasks from a CSV file
|
|
155
159
|
harbor premortem PROJECT ["WHAT WENT WRONG"] [--start]
|
|
156
160
|
harbor voyage PROJECT ["YOUR LINE"]
|
|
157
161
|
harbor note PROJECT ["NOTE"] [--opens DATE|--milestone NAME|--off-track]
|
package/dist/app.mjs
CHANGED
|
@@ -2772,10 +2772,36 @@ function columnWip(data, projectId, status, extra = 1) {
|
|
|
2772
2772
|
const current = tasksOf(data.tasks, projectId).filter((t) => t.status === status).length;
|
|
2773
2773
|
return wipOverflow(col, current, extra);
|
|
2774
2774
|
}
|
|
2775
|
-
var ALL_FIELDS, DEFAULT_DOING_WIP, DEFAULT_REVIEW_WIP;
|
|
2775
|
+
var BOARD_PRESETS, ALL_FIELDS, DEFAULT_DOING_WIP, DEFAULT_REVIEW_WIP, WIP_CAP_CHOICES;
|
|
2776
2776
|
var init_board = __esmMin((() => {
|
|
2777
2777
|
init_indexes();
|
|
2778
2778
|
init_swatches();
|
|
2779
|
+
BOARD_PRESETS = [
|
|
2780
|
+
{
|
|
2781
|
+
id: "trail",
|
|
2782
|
+
name: "Trail",
|
|
2783
|
+
kicker: "The full walk",
|
|
2784
|
+
blurb: "Backlog through done. Three in play, two in review."
|
|
2785
|
+
},
|
|
2786
|
+
{
|
|
2787
|
+
id: "lean",
|
|
2788
|
+
name: "Lean",
|
|
2789
|
+
kicker: "Just the work",
|
|
2790
|
+
blurb: "To do, in play, done. A cap of three in play."
|
|
2791
|
+
},
|
|
2792
|
+
{
|
|
2793
|
+
id: "delivery",
|
|
2794
|
+
name: "Delivery",
|
|
2795
|
+
kicker: "A review gate",
|
|
2796
|
+
blurb: "In-play and review are capped so work actually finishes."
|
|
2797
|
+
},
|
|
2798
|
+
{
|
|
2799
|
+
id: "intake",
|
|
2800
|
+
name: "Intake",
|
|
2801
|
+
kicker: "Collect, then ship",
|
|
2802
|
+
blurb: "Backlog, to do, done. For paused or sourcing work."
|
|
2803
|
+
}
|
|
2804
|
+
];
|
|
2779
2805
|
ALL_FIELDS = {
|
|
2780
2806
|
dueDate: true,
|
|
2781
2807
|
assignee: true,
|
|
@@ -2783,6 +2809,16 @@ var init_board = __esmMin((() => {
|
|
|
2783
2809
|
};
|
|
2784
2810
|
DEFAULT_DOING_WIP = 3;
|
|
2785
2811
|
DEFAULT_REVIEW_WIP = 2;
|
|
2812
|
+
WIP_CAP_CHOICES = [
|
|
2813
|
+
1,
|
|
2814
|
+
2,
|
|
2815
|
+
3,
|
|
2816
|
+
4,
|
|
2817
|
+
5,
|
|
2818
|
+
8,
|
|
2819
|
+
10,
|
|
2820
|
+
15
|
|
2821
|
+
];
|
|
2786
2822
|
}));
|
|
2787
2823
|
|
|
2788
2824
|
//#endregion
|
|
@@ -7169,7 +7205,7 @@ const HELP = [
|
|
|
7169
7205
|
["↑↓ or j k", "move; ←→ or h l across columns and lists"],
|
|
7170
7206
|
["Enter / Esc", "open / back"],
|
|
7171
7207
|
["n", "new: a task, or what the screen lists"],
|
|
7172
|
-
["e", "change what's open
|
|
7208
|
+
["e / b", "change what's open / a project's board setup"],
|
|
7173
7209
|
["[ ] / { }", "move a card a column / a project along its hill"],
|
|
7174
7210
|
["x / d", "mark done / delete (asks first)"],
|
|
7175
7211
|
["u / r", "undo your last change / retry saving"],
|
|
@@ -9279,6 +9315,7 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
9279
9315
|
if (key.escape || key.backspace) return onBack();
|
|
9280
9316
|
if (input === "v") return setMode((m) => m === "board" ? "list" : m === "list" ? "budget" : "board");
|
|
9281
9317
|
if (input === "e") return go.openPanel("project", projectId);
|
|
9318
|
+
if (input === "b") return go.openPanel("board", projectId);
|
|
9282
9319
|
if (input === "R") return go.create("raid", { projectId });
|
|
9283
9320
|
if (input === "f" && mode !== "budget") return go.pick("Show cards assigned to", [
|
|
9284
9321
|
{
|
|
@@ -9419,7 +9456,7 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
9419
9456
|
]
|
|
9420
9457
|
}) : null,
|
|
9421
9458
|
" ",
|
|
9422
|
-
"· v next · f p filter · e details"
|
|
9459
|
+
"· v next · f p filter · e details · b setup"
|
|
9423
9460
|
]
|
|
9424
9461
|
})
|
|
9425
9462
|
})]
|
|
@@ -9799,6 +9836,435 @@ function RoadmapView({ active, width, height, go }) {
|
|
|
9799
9836
|
});
|
|
9800
9837
|
}
|
|
9801
9838
|
|
|
9839
|
+
//#endregion
|
|
9840
|
+
//#region src/lib/automation-text.ts
|
|
9841
|
+
init_swatches();
|
|
9842
|
+
const TRIGGER_LABEL = {
|
|
9843
|
+
task_created: "A task is created",
|
|
9844
|
+
status_changed_to: "Status is set to…",
|
|
9845
|
+
priority_changed_to: "Priority is set to…",
|
|
9846
|
+
assigned: "A task is assigned to anyone"
|
|
9847
|
+
};
|
|
9848
|
+
const ACTION_LABEL = {
|
|
9849
|
+
set_status: "Set status to…",
|
|
9850
|
+
set_priority: "Set priority to…",
|
|
9851
|
+
set_assignee: "Assign to…",
|
|
9852
|
+
add_comment: "Post a comment"
|
|
9853
|
+
};
|
|
9854
|
+
function describeAction(a, peopleName) {
|
|
9855
|
+
switch (a.type) {
|
|
9856
|
+
case "set_status": return `set status to ${a.status ? TASK_STATUS_LABEL[a.status] : "…"}`;
|
|
9857
|
+
case "set_priority": return `set priority to ${a.priority ? PRIORITY_LABEL[a.priority] : "…"}`;
|
|
9858
|
+
case "set_assignee": return `assign to ${peopleName(a.assigneeId)}`;
|
|
9859
|
+
case "add_comment": return `post “${a.commentBody ?? ""}”`;
|
|
9860
|
+
}
|
|
9861
|
+
}
|
|
9862
|
+
function describeTrigger(rule) {
|
|
9863
|
+
const t = rule.trigger;
|
|
9864
|
+
if (t.type === "status_changed_to") return `When status is set to ${t.status ? TASK_STATUS_LABEL[t.status] : "…"}`;
|
|
9865
|
+
if (t.type === "priority_changed_to") return `When priority is set to ${t.priority ? PRIORITY_LABEL[t.priority] : "…"}`;
|
|
9866
|
+
return `When ${TRIGGER_LABEL[t.type].toLowerCase()}`;
|
|
9867
|
+
}
|
|
9868
|
+
/** "When status is set to Done: set priority to Low, post “Shipped”". */
|
|
9869
|
+
function describeRule(rule, peopleName) {
|
|
9870
|
+
const did = rule.actions.map((a) => describeAction(a, peopleName)).join(", ");
|
|
9871
|
+
return `${describeTrigger(rule)}: ${did || "no actions"}`;
|
|
9872
|
+
}
|
|
9873
|
+
function defaultAction(type) {
|
|
9874
|
+
if (type === "set_status") return {
|
|
9875
|
+
type,
|
|
9876
|
+
status: "done"
|
|
9877
|
+
};
|
|
9878
|
+
if (type === "set_priority") return {
|
|
9879
|
+
type,
|
|
9880
|
+
priority: "high"
|
|
9881
|
+
};
|
|
9882
|
+
if (type === "set_assignee") return {
|
|
9883
|
+
type,
|
|
9884
|
+
assigneeId: null
|
|
9885
|
+
};
|
|
9886
|
+
return {
|
|
9887
|
+
type,
|
|
9888
|
+
commentBody: ""
|
|
9889
|
+
};
|
|
9890
|
+
}
|
|
9891
|
+
|
|
9892
|
+
//#endregion
|
|
9893
|
+
//#region src/lib/custom-field-text.ts
|
|
9894
|
+
const FIELD_TYPE_LABEL = {
|
|
9895
|
+
text: "Text",
|
|
9896
|
+
number: "Number",
|
|
9897
|
+
select: "Dropdown",
|
|
9898
|
+
checkbox: "Checkbox"
|
|
9899
|
+
};
|
|
9900
|
+
const FIELD_TYPES = Object.keys(FIELD_TYPE_LABEL);
|
|
9901
|
+
/** A dropdown's options from "S, M, L". */
|
|
9902
|
+
function optionsOf(value) {
|
|
9903
|
+
return value.split(",").map((o) => o.trim()).filter(Boolean);
|
|
9904
|
+
}
|
|
9905
|
+
/** A task's value for a field as one line: "" when it has none. */
|
|
9906
|
+
function fieldValueText(def, value) {
|
|
9907
|
+
if (value == null || value === "") return "";
|
|
9908
|
+
if (def.type === "checkbox") return value ? "Yes" : "";
|
|
9909
|
+
return String(value);
|
|
9910
|
+
}
|
|
9911
|
+
/** What someone typed for a text or number field; undefined when a number can't be read. */
|
|
9912
|
+
function readFieldValue(def, text) {
|
|
9913
|
+
const t = text.trim();
|
|
9914
|
+
if (!t) return null;
|
|
9915
|
+
if (def.type !== "number") return t;
|
|
9916
|
+
const n = Number(t.replace(/,/g, ""));
|
|
9917
|
+
return Number.isFinite(n) ? n : void 0;
|
|
9918
|
+
}
|
|
9919
|
+
|
|
9920
|
+
//#endregion
|
|
9921
|
+
//#region src/tui/views/setup.tsx
|
|
9922
|
+
/**
|
|
9923
|
+
* A project's board setup, as the website's board settings have it: where it
|
|
9924
|
+
* starts from, each column (name, shown, cap, place), lanes and what cards
|
|
9925
|
+
* show, the project's own fields and its automations. b on a project opens it.
|
|
9926
|
+
*/
|
|
9927
|
+
init_board();
|
|
9928
|
+
init_swatches();
|
|
9929
|
+
const LANES = [
|
|
9930
|
+
{
|
|
9931
|
+
value: "none",
|
|
9932
|
+
label: "None"
|
|
9933
|
+
},
|
|
9934
|
+
{
|
|
9935
|
+
value: "assignee",
|
|
9936
|
+
label: "By person"
|
|
9937
|
+
},
|
|
9938
|
+
{
|
|
9939
|
+
value: "priority",
|
|
9940
|
+
label: "By priority"
|
|
9941
|
+
}
|
|
9942
|
+
];
|
|
9943
|
+
const CARD_FIELDS = [
|
|
9944
|
+
{
|
|
9945
|
+
key: "dueDate",
|
|
9946
|
+
label: "Due date"
|
|
9947
|
+
},
|
|
9948
|
+
{
|
|
9949
|
+
key: "assignee",
|
|
9950
|
+
label: "Owner"
|
|
9951
|
+
},
|
|
9952
|
+
{
|
|
9953
|
+
key: "priority",
|
|
9954
|
+
label: "Priority"
|
|
9955
|
+
}
|
|
9956
|
+
];
|
|
9957
|
+
const statusChoices = () => TASK_COLUMNS.map((s) => ({
|
|
9958
|
+
label: TASK_STATUS_LABEL[s],
|
|
9959
|
+
value: s
|
|
9960
|
+
}));
|
|
9961
|
+
const priorityChoices = () => PRIORITIES$1.map((p) => ({
|
|
9962
|
+
label: PRIORITY_LABEL[p],
|
|
9963
|
+
value: p
|
|
9964
|
+
}));
|
|
9965
|
+
function presetName(id) {
|
|
9966
|
+
return BOARD_PRESETS.find((p) => p.id === id)?.name ?? "Your own";
|
|
9967
|
+
}
|
|
9968
|
+
function columnValue(c) {
|
|
9969
|
+
const bits = [columnLabel(c)];
|
|
9970
|
+
if (!c.visible) bits.push("hidden");
|
|
9971
|
+
bits.push(c.wipLimit == null ? "no cap" : `cap ${c.wipLimit}`);
|
|
9972
|
+
return bits.join(" · ");
|
|
9973
|
+
}
|
|
9974
|
+
function fieldValue(f) {
|
|
9975
|
+
const kind = FIELD_TYPE_LABEL[f.type];
|
|
9976
|
+
return f.type === "select" && f.options?.length ? `${kind}: ${f.options.join(", ")}` : kind;
|
|
9977
|
+
}
|
|
9978
|
+
function BoardPanel({ id, width, height, go, active, onClose }) {
|
|
9979
|
+
const { store } = useDeps();
|
|
9980
|
+
const project = useHarbor((s) => s.projects.find((p) => p.id === id));
|
|
9981
|
+
const boardConfigs = useHarbor((s) => s.boardConfigs);
|
|
9982
|
+
const people = useHarbor((s) => s.people);
|
|
9983
|
+
if (!project) return null;
|
|
9984
|
+
const config = boardConfigOf({ boardConfigs }, id);
|
|
9985
|
+
const set = (patch) => store.getState().setBoardConfig(id, {
|
|
9986
|
+
...config,
|
|
9987
|
+
preset: "custom",
|
|
9988
|
+
...patch
|
|
9989
|
+
});
|
|
9990
|
+
const setColumns = (columns) => set({ columns });
|
|
9991
|
+
const peopleName = (pid) => pid ? people.find((p) => p.id === pid)?.name ?? "someone no longer here" : "no one";
|
|
9992
|
+
const columnField = (c, i) => ({
|
|
9993
|
+
label: TASK_STATUS_LABEL[c.status],
|
|
9994
|
+
value: columnValue(c),
|
|
9995
|
+
edit: {
|
|
9996
|
+
kind: "pick",
|
|
9997
|
+
title: `The ${columnLabel(c)} column`,
|
|
9998
|
+
choices: [
|
|
9999
|
+
{
|
|
10000
|
+
label: "Rename it",
|
|
10001
|
+
value: "rename"
|
|
10002
|
+
},
|
|
10003
|
+
{
|
|
10004
|
+
label: c.visible ? "Hide it" : "Show it",
|
|
10005
|
+
value: "visible"
|
|
10006
|
+
},
|
|
10007
|
+
{
|
|
10008
|
+
label: "Change its cap",
|
|
10009
|
+
value: "cap"
|
|
10010
|
+
},
|
|
10011
|
+
...i > 0 ? [{
|
|
10012
|
+
label: "Move it left",
|
|
10013
|
+
value: "left"
|
|
10014
|
+
}] : [],
|
|
10015
|
+
...i < config.columns.length - 1 ? [{
|
|
10016
|
+
label: "Move it right",
|
|
10017
|
+
value: "right"
|
|
10018
|
+
}] : []
|
|
10019
|
+
],
|
|
10020
|
+
onPick: (what) => {
|
|
10021
|
+
const others = config.columns;
|
|
10022
|
+
const put = (next) => setColumns(others.map((x) => x.status === c.status ? next : x));
|
|
10023
|
+
if (what === "rename") return go.ask(`Name for ${TASK_STATUS_LABEL[c.status]}`, (text) => put({
|
|
10024
|
+
...c,
|
|
10025
|
+
label: text.trim() || TASK_STATUS_LABEL[c.status]
|
|
10026
|
+
}), columnLabel(c));
|
|
10027
|
+
if (what === "visible") {
|
|
10028
|
+
if (c.visible && others.filter((x) => x.visible).length === 1) return go.notify("A board shows at least one column.");
|
|
10029
|
+
return put({
|
|
10030
|
+
...c,
|
|
10031
|
+
visible: !c.visible
|
|
10032
|
+
});
|
|
10033
|
+
}
|
|
10034
|
+
if (what === "cap") return go.pick(`Cap for ${columnLabel(c)}`, [{
|
|
10035
|
+
label: "No cap",
|
|
10036
|
+
value: null
|
|
10037
|
+
}, ...WIP_CAP_CHOICES.map((n) => ({
|
|
10038
|
+
label: String(n),
|
|
10039
|
+
value: n
|
|
10040
|
+
}))], (n) => put(withWipLimit(c, n)));
|
|
10041
|
+
const to = i + (what === "left" ? -1 : 1);
|
|
10042
|
+
const next = others.slice();
|
|
10043
|
+
[next[i], next[to]] = [next[to], next[i]];
|
|
10044
|
+
setColumns(next);
|
|
10045
|
+
}
|
|
10046
|
+
}
|
|
10047
|
+
});
|
|
10048
|
+
const fields = [
|
|
10049
|
+
{
|
|
10050
|
+
label: "Starts from",
|
|
10051
|
+
value: presetName(config.preset),
|
|
10052
|
+
edit: {
|
|
10053
|
+
kind: "pick",
|
|
10054
|
+
title: "Start the board from (its columns and caps are replaced; u undoes it)",
|
|
10055
|
+
choices: BOARD_PRESETS.map((p) => ({
|
|
10056
|
+
label: p.name,
|
|
10057
|
+
value: p.id,
|
|
10058
|
+
hint: p.blurb
|
|
10059
|
+
})),
|
|
10060
|
+
initial: Math.max(0, BOARD_PRESETS.findIndex((p) => p.id === config.preset)),
|
|
10061
|
+
onPick: (v) => store.getState().applyBoardPreset(id, v)
|
|
10062
|
+
}
|
|
10063
|
+
},
|
|
10064
|
+
...config.columns.map(columnField),
|
|
10065
|
+
{
|
|
10066
|
+
label: "Lanes",
|
|
10067
|
+
value: LANES.find((l) => l.value === config.swimlane)?.label ?? "None",
|
|
10068
|
+
edit: {
|
|
10069
|
+
kind: "pick",
|
|
10070
|
+
title: "Lanes across the website's board",
|
|
10071
|
+
choices: LANES.map((l) => ({
|
|
10072
|
+
label: l.label,
|
|
10073
|
+
value: l.value
|
|
10074
|
+
})),
|
|
10075
|
+
initial: LANES.findIndex((l) => l.value === config.swimlane),
|
|
10076
|
+
onPick: (v) => set({ swimlane: v })
|
|
10077
|
+
}
|
|
10078
|
+
},
|
|
10079
|
+
{
|
|
10080
|
+
label: "Cards show",
|
|
10081
|
+
value: CARD_FIELDS.filter((f) => config.cardFields[f.key]).map((f) => f.label.toLowerCase()).join(", ") || "just the title",
|
|
10082
|
+
edit: {
|
|
10083
|
+
kind: "pick",
|
|
10084
|
+
title: "Show or hide on the website's cards",
|
|
10085
|
+
choices: CARD_FIELDS.map((f) => ({
|
|
10086
|
+
label: `${config.cardFields[f.key] ? "Hide" : "Show"} ${f.label.toLowerCase()}`,
|
|
10087
|
+
value: f.key
|
|
10088
|
+
})),
|
|
10089
|
+
onPick: (k) => {
|
|
10090
|
+
const key = k;
|
|
10091
|
+
set({ cardFields: {
|
|
10092
|
+
...config.cardFields,
|
|
10093
|
+
[key]: !config.cardFields[key]
|
|
10094
|
+
} });
|
|
10095
|
+
}
|
|
10096
|
+
}
|
|
10097
|
+
},
|
|
10098
|
+
{
|
|
10099
|
+
label: "Compact cards",
|
|
10100
|
+
value: config.compactCards ? "Yes" : "No",
|
|
10101
|
+
edit: {
|
|
10102
|
+
kind: "pick",
|
|
10103
|
+
choices: [{
|
|
10104
|
+
label: "Yes",
|
|
10105
|
+
value: true
|
|
10106
|
+
}, {
|
|
10107
|
+
label: "No",
|
|
10108
|
+
value: false
|
|
10109
|
+
}],
|
|
10110
|
+
initial: config.compactCards ? 0 : 1,
|
|
10111
|
+
onPick: (v) => set({ compactCards: v })
|
|
10112
|
+
}
|
|
10113
|
+
},
|
|
10114
|
+
...(project.customFields ?? []).map((f) => ({
|
|
10115
|
+
label: `Field “${f.name}”`,
|
|
10116
|
+
value: fieldValue(f),
|
|
10117
|
+
edit: {
|
|
10118
|
+
kind: "pick",
|
|
10119
|
+
title: `The field “${f.name}”`,
|
|
10120
|
+
choices: [
|
|
10121
|
+
{
|
|
10122
|
+
label: "Rename it",
|
|
10123
|
+
value: "rename"
|
|
10124
|
+
},
|
|
10125
|
+
...f.type === "select" ? [{
|
|
10126
|
+
label: "Change its options",
|
|
10127
|
+
value: "options"
|
|
10128
|
+
}] : [],
|
|
10129
|
+
{
|
|
10130
|
+
label: "Take it out",
|
|
10131
|
+
value: "remove"
|
|
10132
|
+
}
|
|
10133
|
+
],
|
|
10134
|
+
onPick: (what) => {
|
|
10135
|
+
const s = store.getState();
|
|
10136
|
+
if (what === "rename") return go.ask("Field name", (text) => text.trim() && s.updateCustomField(id, f.id, { name: text.trim() }), f.name);
|
|
10137
|
+
if (what === "options") return go.ask("Options, split by commas", (text) => s.updateCustomField(id, f.id, { options: optionsOf(text) }), (f.options ?? []).join(", "));
|
|
10138
|
+
go.confirm(`Take out the field “${f.name}” and every task's value for it? u undoes it.`, () => s.removeCustomField(id, f.id), "Take it out");
|
|
10139
|
+
}
|
|
10140
|
+
}
|
|
10141
|
+
})),
|
|
10142
|
+
{
|
|
10143
|
+
label: "Add a field",
|
|
10144
|
+
value: (project.customFields ?? []).length ? "" : "Tasks here have no fields of their own yet",
|
|
10145
|
+
edit: {
|
|
10146
|
+
kind: "text",
|
|
10147
|
+
initial: "",
|
|
10148
|
+
placeholder: "A name, like Size or Customer",
|
|
10149
|
+
onSave: (name) => {
|
|
10150
|
+
const trimmed = name.trim();
|
|
10151
|
+
if (!trimmed) return;
|
|
10152
|
+
go.pick(`What “${trimmed}” holds`, FIELD_TYPES.map((t) => ({
|
|
10153
|
+
label: FIELD_TYPE_LABEL[t],
|
|
10154
|
+
value: t
|
|
10155
|
+
})), (t) => {
|
|
10156
|
+
const type = t;
|
|
10157
|
+
if (type !== "select") return void store.getState().addCustomField(id, {
|
|
10158
|
+
name: trimmed,
|
|
10159
|
+
type
|
|
10160
|
+
});
|
|
10161
|
+
go.ask("Options, split by commas", (text) => {
|
|
10162
|
+
store.getState().addCustomField(id, {
|
|
10163
|
+
name: trimmed,
|
|
10164
|
+
type,
|
|
10165
|
+
options: optionsOf(text)
|
|
10166
|
+
});
|
|
10167
|
+
});
|
|
10168
|
+
});
|
|
10169
|
+
}
|
|
10170
|
+
}
|
|
10171
|
+
},
|
|
10172
|
+
...(project.automations ?? []).map((r, n) => ({
|
|
10173
|
+
label: `Automation ${n + 1}`,
|
|
10174
|
+
value: `${describeRule(r, peopleName)}${r.enabled ? "" : " (off)"}`,
|
|
10175
|
+
edit: {
|
|
10176
|
+
kind: "pick",
|
|
10177
|
+
title: describeTrigger(r),
|
|
10178
|
+
choices: [{
|
|
10179
|
+
label: r.enabled ? "Turn it off" : "Turn it on",
|
|
10180
|
+
value: "toggle"
|
|
10181
|
+
}, {
|
|
10182
|
+
label: "Take it out",
|
|
10183
|
+
value: "remove"
|
|
10184
|
+
}],
|
|
10185
|
+
onPick: (what) => {
|
|
10186
|
+
const s = store.getState();
|
|
10187
|
+
if (what === "toggle") return s.updateAutomation(id, r.id, { enabled: !r.enabled });
|
|
10188
|
+
go.confirm("Take out this automation? u undoes it.", () => s.removeAutomation(id, r.id), "Take it out");
|
|
10189
|
+
}
|
|
10190
|
+
}
|
|
10191
|
+
})),
|
|
10192
|
+
{
|
|
10193
|
+
label: "Add an automation",
|
|
10194
|
+
value: (project.automations ?? []).length ? "" : "When something happens, do something",
|
|
10195
|
+
edit: {
|
|
10196
|
+
kind: "pick",
|
|
10197
|
+
title: "When",
|
|
10198
|
+
choices: Object.keys(TRIGGER_LABEL).map((t) => ({
|
|
10199
|
+
label: TRIGGER_LABEL[t],
|
|
10200
|
+
value: t
|
|
10201
|
+
})),
|
|
10202
|
+
onPick: (t) => askTrigger(t)
|
|
10203
|
+
}
|
|
10204
|
+
}
|
|
10205
|
+
];
|
|
10206
|
+
function askTrigger(type) {
|
|
10207
|
+
if (type === "status_changed_to") return go.pick("When status is set to", statusChoices(), (s) => askAction({
|
|
10208
|
+
type,
|
|
10209
|
+
status: s
|
|
10210
|
+
}));
|
|
10211
|
+
if (type === "priority_changed_to") return go.pick("When priority is set to", priorityChoices(), (p) => askAction({
|
|
10212
|
+
type,
|
|
10213
|
+
priority: p
|
|
10214
|
+
}));
|
|
10215
|
+
askAction({ type });
|
|
10216
|
+
}
|
|
10217
|
+
function askAction(trigger) {
|
|
10218
|
+
go.pick(`${describeTrigger({ trigger })}, then`, Object.keys(ACTION_LABEL).map((a) => ({
|
|
10219
|
+
label: ACTION_LABEL[a],
|
|
10220
|
+
value: a
|
|
10221
|
+
})), (a) => {
|
|
10222
|
+
const type = a;
|
|
10223
|
+
const add = (action) => store.getState().addAutomation(id, {
|
|
10224
|
+
name: describeTrigger({ trigger }),
|
|
10225
|
+
enabled: true,
|
|
10226
|
+
trigger,
|
|
10227
|
+
actions: [action]
|
|
10228
|
+
});
|
|
10229
|
+
if (type === "set_status") return go.pick("Set status to", statusChoices(), (s) => add({
|
|
10230
|
+
type,
|
|
10231
|
+
status: s
|
|
10232
|
+
}));
|
|
10233
|
+
if (type === "set_priority") return go.pick("Set priority to", priorityChoices(), (p) => add({
|
|
10234
|
+
type,
|
|
10235
|
+
priority: p
|
|
10236
|
+
}));
|
|
10237
|
+
if (type === "set_assignee") return go.pick("Assign to", [{
|
|
10238
|
+
label: "Nobody",
|
|
10239
|
+
value: null
|
|
10240
|
+
}, ...people.map((p) => ({
|
|
10241
|
+
label: p.name,
|
|
10242
|
+
value: p.id,
|
|
10243
|
+
hint: p.role
|
|
10244
|
+
}))], (pid) => add({
|
|
10245
|
+
...defaultAction(type),
|
|
10246
|
+
assigneeId: pid
|
|
10247
|
+
}));
|
|
10248
|
+
go.ask("Comment to post", (text) => text.trim() && add({
|
|
10249
|
+
type,
|
|
10250
|
+
commentBody: text.trim()
|
|
10251
|
+
}));
|
|
10252
|
+
});
|
|
10253
|
+
}
|
|
10254
|
+
return /* @__PURE__ */ jsx(DetailPanel, {
|
|
10255
|
+
title: /* @__PURE__ */ jsx(Text, {
|
|
10256
|
+
bold: true,
|
|
10257
|
+
children: project.name
|
|
10258
|
+
}),
|
|
10259
|
+
subtitle: "Board setup · lanes and card options show on the website's board",
|
|
10260
|
+
fields,
|
|
10261
|
+
width,
|
|
10262
|
+
height,
|
|
10263
|
+
active,
|
|
10264
|
+
onClose
|
|
10265
|
+
});
|
|
10266
|
+
}
|
|
10267
|
+
|
|
9802
10268
|
//#endregion
|
|
9803
10269
|
//#region src/lib/recurrence.ts
|
|
9804
10270
|
/** The due date one interval after `from`. */
|
|
@@ -9909,7 +10375,9 @@ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
|
|
|
9909
10375
|
const task = tasks.find((t) => t.id === taskId);
|
|
9910
10376
|
const [at, setAt] = useState(0);
|
|
9911
10377
|
const [editing, setEditing] = useState(null);
|
|
9912
|
-
const
|
|
10378
|
+
const own = projects.find((p) => p.id === task?.projectId)?.customFields ?? [];
|
|
10379
|
+
const stepsFrom = FIELDS$1.length + own.length;
|
|
10380
|
+
const rows = stepsFrom + (task?.checklist.length ?? 0);
|
|
9913
10381
|
const day = today();
|
|
9914
10382
|
useInput((input, key) => {
|
|
9915
10383
|
if (!task) return onClose();
|
|
@@ -9927,7 +10395,9 @@ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
|
|
|
9927
10395
|
store.getState().deleteTask(task.id);
|
|
9928
10396
|
onClose();
|
|
9929
10397
|
}, "Delete");
|
|
9930
|
-
const
|
|
10398
|
+
const custom = own[at - FIELDS$1.length];
|
|
10399
|
+
if (custom && key.return) return editCustom(custom);
|
|
10400
|
+
const item = task.checklist[at - stepsFrom];
|
|
9931
10401
|
if (item && (input === " " || key.return)) return store.getState().toggleChecklistItem(task.id, item.id);
|
|
9932
10402
|
if (item && input === "-") return store.getState().removeChecklistItem(task.id, item.id);
|
|
9933
10403
|
if (key.return && FIELDS$1[at]) return setEditing({
|
|
@@ -9943,6 +10413,23 @@ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
|
|
|
9943
10413
|
const mine = comments.filter((c) => c.taskId === task.id && c.authorId === me);
|
|
9944
10414
|
const blockers = blockersOf(task, tasks);
|
|
9945
10415
|
const done = () => setEditing(null);
|
|
10416
|
+
const setCustom = (fieldId, value) => store.getState().setTaskCustomFieldValue(task.id, fieldId, value);
|
|
10417
|
+
function editCustom(f) {
|
|
10418
|
+
if (!task) return;
|
|
10419
|
+
const value = task.customFieldValues?.[f.id] ?? null;
|
|
10420
|
+
if (f.type === "checkbox") return setCustom(f.id, !value);
|
|
10421
|
+
if (f.type === "select") return go.pick(f.name, [{
|
|
10422
|
+
label: "None",
|
|
10423
|
+
value: null
|
|
10424
|
+
}, ...(f.options ?? []).map((o) => ({
|
|
10425
|
+
label: o,
|
|
10426
|
+
value: o
|
|
10427
|
+
}))], (v) => setCustom(f.id, v));
|
|
10428
|
+
setEditing({
|
|
10429
|
+
kind: "custom",
|
|
10430
|
+
fieldId: f.id
|
|
10431
|
+
});
|
|
10432
|
+
}
|
|
9946
10433
|
const value = {
|
|
9947
10434
|
title: task.title,
|
|
9948
10435
|
status: STATUS_LABEL[task.status],
|
|
@@ -10009,6 +10496,17 @@ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
|
|
|
10009
10496
|
done();
|
|
10010
10497
|
}
|
|
10011
10498
|
});
|
|
10499
|
+
} else if (editing?.kind === "custom") {
|
|
10500
|
+
const f = own.find((x) => x.id === editing.fieldId);
|
|
10501
|
+
if (f) editor = /* @__PURE__ */ jsx(CustomField, {
|
|
10502
|
+
def: f,
|
|
10503
|
+
initial: fieldValueText(f, task.customFieldValues?.[f.id]),
|
|
10504
|
+
onCancel: done,
|
|
10505
|
+
onSubmit: (v) => {
|
|
10506
|
+
setCustom(f.id, v);
|
|
10507
|
+
done();
|
|
10508
|
+
}
|
|
10509
|
+
});
|
|
10012
10510
|
} else if (editing?.kind === "field") {
|
|
10013
10511
|
const f = editing.field;
|
|
10014
10512
|
if (f === "title" || f === "notes") editor = /* @__PURE__ */ jsx(TextField, {
|
|
@@ -10153,6 +10651,22 @@ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
|
|
|
10153
10651
|
value[f.field]
|
|
10154
10652
|
]
|
|
10155
10653
|
}, f.field)),
|
|
10654
|
+
own.map((f, n) => {
|
|
10655
|
+
const on = at === FIELDS$1.length + n;
|
|
10656
|
+
return /* @__PURE__ */ jsxs(Text, {
|
|
10657
|
+
wrap: "truncate-end",
|
|
10658
|
+
color: on ? theme.selected : void 0,
|
|
10659
|
+
bold: on,
|
|
10660
|
+
children: [
|
|
10661
|
+
on ? "› " : " ",
|
|
10662
|
+
/* @__PURE__ */ jsx(Text, {
|
|
10663
|
+
color: theme.muted,
|
|
10664
|
+
children: f.name.slice(0, 8).padEnd(9)
|
|
10665
|
+
}),
|
|
10666
|
+
fieldValueText(f, task.customFieldValues?.[f.id]) || "—"
|
|
10667
|
+
]
|
|
10668
|
+
}, f.id);
|
|
10669
|
+
}),
|
|
10156
10670
|
waits.length > 0 ? /* @__PURE__ */ jsxs(Text, {
|
|
10157
10671
|
color: theme.warn,
|
|
10158
10672
|
wrap: "truncate-end",
|
|
@@ -10172,7 +10686,7 @@ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
|
|
|
10172
10686
|
]
|
|
10173
10687
|
}),
|
|
10174
10688
|
task.checklist.map((item, i) => {
|
|
10175
|
-
const on = at ===
|
|
10689
|
+
const on = at === stepsFrom + i;
|
|
10176
10690
|
return /* @__PURE__ */ jsxs(Text, {
|
|
10177
10691
|
color: on ? theme.selected : void 0,
|
|
10178
10692
|
bold: on,
|
|
@@ -10232,6 +10746,21 @@ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
|
|
|
10232
10746
|
]
|
|
10233
10747
|
});
|
|
10234
10748
|
}
|
|
10749
|
+
/** A text or number field of the project's own; a number that can't be read says so. */
|
|
10750
|
+
function CustomField({ def, initial, onSubmit, onCancel }) {
|
|
10751
|
+
const [error, setError] = useState(null);
|
|
10752
|
+
return /* @__PURE__ */ jsx(TextField, {
|
|
10753
|
+
label: def.name,
|
|
10754
|
+
initial,
|
|
10755
|
+
hint: error ?? (def.type === "number" ? "A number, or empty for none" : "Empty for none"),
|
|
10756
|
+
onCancel,
|
|
10757
|
+
onSubmit: (text) => {
|
|
10758
|
+
const v = readFieldValue(def, text);
|
|
10759
|
+
if (v === void 0) return setError(`“${text}” isn't a number.`);
|
|
10760
|
+
onSubmit(v);
|
|
10761
|
+
}
|
|
10762
|
+
});
|
|
10763
|
+
}
|
|
10235
10764
|
function DueField({ initial, today, onSubmit, onCancel }) {
|
|
10236
10765
|
const [error, setError] = useState(null);
|
|
10237
10766
|
return /* @__PURE__ */ jsx(Box, {
|
|
@@ -10941,6 +11470,10 @@ function App({ ui }) {
|
|
|
10941
11470
|
id: panel.id,
|
|
10942
11471
|
...panelProps
|
|
10943
11472
|
});
|
|
11473
|
+
else if (panel?.kind === "board") panelNode = /* @__PURE__ */ jsx(BoardPanel, {
|
|
11474
|
+
id: panel.id,
|
|
11475
|
+
...panelProps
|
|
11476
|
+
});
|
|
10944
11477
|
else if (panel?.kind === "milestone") panelNode = /* @__PURE__ */ jsx(MilestonePanel, {
|
|
10945
11478
|
id: panel.id,
|
|
10946
11479
|
...panelProps
|
package/lib/cli.mjs
CHANGED
|
@@ -17,6 +17,8 @@ import {
|
|
|
17
17
|
writeSettings,
|
|
18
18
|
} from "./config.mjs";
|
|
19
19
|
import {
|
|
20
|
+
forecastData,
|
|
21
|
+
importText,
|
|
20
22
|
forecastList,
|
|
21
23
|
forecastOne,
|
|
22
24
|
STATUS,
|
|
@@ -122,6 +124,10 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
122
124
|
--say reads it aloud as a
|
|
123
125
|
shipping forecast
|
|
124
126
|
harbor report PROGRAM a program's status report
|
|
127
|
+
harbor export tasks PROJECT|risks|review [--all]
|
|
128
|
+
a spreadsheet (CSV) to stdout
|
|
129
|
+
harbor import FILE [--project P] [--portfolio P] [--no-people]
|
|
130
|
+
add tasks from a CSV file
|
|
125
131
|
harbor premortem PROJECT ["WHAT WENT WRONG"] [--start]
|
|
126
132
|
a project's pre-mortem: start,
|
|
127
133
|
answer or read it
|
|
@@ -444,6 +450,45 @@ const DETAIL = {
|
|
|
444
450
|
" PROJECT is a name, a key like RR, or an id. When two projects share a",
|
|
445
451
|
" name, give the key.",
|
|
446
452
|
],
|
|
453
|
+
forecast: [
|
|
454
|
+
" harbor forecast [--program P] reads when each open project will",
|
|
455
|
+
" likely finish",
|
|
456
|
+
" harbor forecast PROJECT reads one project's forecast and what",
|
|
457
|
+
" it rests on",
|
|
458
|
+
"",
|
|
459
|
+
" Worked out from the crew's pace alone: how many tasks they finished each",
|
|
460
|
+
" week over the last six (or the project's life, if shorter), replayed a",
|
|
461
|
+
" thousand times until its open tasks run out. Probably is half of those",
|
|
462
|
+
" runs, very likely 85%, almost certainly 95%. No estimates or points.",
|
|
463
|
+
" It warns when under two weeks of pace is all there is, and when an open",
|
|
464
|
+
" task is due after the likely finish, since the pace can't see due dates.",
|
|
465
|
+
],
|
|
466
|
+
report: [
|
|
467
|
+
" harbor report PROGRAM reads the program's status report",
|
|
468
|
+
"",
|
|
469
|
+
" Prints Markdown, as the website's Status report button writes it:",
|
|
470
|
+
" health, progress, late work, milestones, risks and the next move.",
|
|
471
|
+
" Ready to paste; harbor report PROGRAM > report.md saves it.",
|
|
472
|
+
],
|
|
473
|
+
export: [
|
|
474
|
+
" harbor export tasks PROJECT reads a project's tasks as CSV",
|
|
475
|
+
" harbor export risks [--all] reads the risk register as CSV",
|
|
476
|
+
" (--all adds closed ones)",
|
|
477
|
+
" harbor export review reads the review as CSV",
|
|
478
|
+
"",
|
|
479
|
+
" The same spreadsheets the website's Export buttons download. They print;",
|
|
480
|
+
" harbor export tasks RR > tasks.csv saves one.",
|
|
481
|
+
],
|
|
482
|
+
import: [
|
|
483
|
+
" harbor import FILE --project P writes: adds the file's tasks",
|
|
484
|
+
" harbor import FILE --portfolio P writes: makes projects as the file",
|
|
485
|
+
" names them",
|
|
486
|
+
"",
|
|
487
|
+
" FILE is CSV or tab-separated: a spreadsheet, or a Jira, Asana or Linear",
|
|
488
|
+
" export. It needs a column of titles; rows naming a Project column go",
|
|
489
|
+
" there. People it names who aren't in the harbor are added unless you say",
|
|
490
|
+
" --no-people. A task already in its project by title is left alone.",
|
|
491
|
+
],
|
|
447
492
|
note: [
|
|
448
493
|
" harbor note PROJECT reads its notes for later: who left",
|
|
449
494
|
" each, what opens it, and the opened ones",
|
|
@@ -507,6 +552,21 @@ const DETAIL = {
|
|
|
507
552
|
" PROJECT is a name, a key like RR, or an id. When two projects share a",
|
|
508
553
|
" name, give the key.",
|
|
509
554
|
],
|
|
555
|
+
risk: [
|
|
556
|
+
' harbor risk "TITLE" --project P writes: logs a risk on the project',
|
|
557
|
+
' harbor risk "TITLE" --program P writes: logs one on the program',
|
|
558
|
+
" [--impact 1-5] [--probability 1-5] [--owner NAME] [--due DATE]",
|
|
559
|
+
],
|
|
560
|
+
issue: [
|
|
561
|
+
' harbor issue "TITLE" --project P writes: logs an issue on the project',
|
|
562
|
+
' harbor issue "TITLE" --program P writes: logs one on the program',
|
|
563
|
+
" [--impact 1-5] [--owner NAME] [--due DATE]",
|
|
564
|
+
],
|
|
565
|
+
decision: [
|
|
566
|
+
' harbor decision "TITLE" --project P writes: logs a decision to make',
|
|
567
|
+
' harbor decision "TITLE" --program P writes: logs one on the program',
|
|
568
|
+
" [--owner NAME] [--due DATE] (the date to decide by)",
|
|
569
|
+
],
|
|
510
570
|
};
|
|
511
571
|
|
|
512
572
|
/** The lines of the help about one command, or null when it has none. */
|
|
@@ -1094,8 +1154,21 @@ export async function run(argv, io = {}) {
|
|
|
1094
1154
|
case "forecast": {
|
|
1095
1155
|
const { positionals, values, harbor } = parse(spec([], ["program"]));
|
|
1096
1156
|
const project = positionals.join(" ").trim();
|
|
1097
|
-
|
|
1098
|
-
|
|
1157
|
+
// --json gets each forecast flat beside the project's name (forecastData).
|
|
1158
|
+
const withFinish = (p) => ({ ...p, finish: p });
|
|
1159
|
+
if (project)
|
|
1160
|
+
return call(
|
|
1161
|
+
"get_project",
|
|
1162
|
+
{ harbor, project },
|
|
1163
|
+
(d) => forecastOne(withFinish(d)),
|
|
1164
|
+
forecastData,
|
|
1165
|
+
);
|
|
1166
|
+
return call(
|
|
1167
|
+
"list_projects",
|
|
1168
|
+
{ harbor, program: values.program, limit: 100 },
|
|
1169
|
+
(d) => forecastList({ projects: d.projects.map(withFinish) }),
|
|
1170
|
+
forecastData,
|
|
1171
|
+
);
|
|
1099
1172
|
}
|
|
1100
1173
|
case "hill": {
|
|
1101
1174
|
const { positionals, values, harbor } = parse(spec([], ["program", "at"]));
|
|
@@ -1475,6 +1548,44 @@ export async function run(argv, io = {}) {
|
|
|
1475
1548
|
if (!program) throw new HarborError('Give a program: harbor report "NAME".');
|
|
1476
1549
|
return call("status_report", { harbor, program }, (r) => r.markdown);
|
|
1477
1550
|
}
|
|
1551
|
+
case "export": {
|
|
1552
|
+
const { positionals, values, harbor } = parse(spec(["all"]));
|
|
1553
|
+
const [what, ...name] = positionals;
|
|
1554
|
+
if (!["tasks", "risks", "review"].includes(what))
|
|
1555
|
+
throw new HarborError("Say what to export: harbor export tasks PROJECT, risks or review.");
|
|
1556
|
+
const project = name.join(" ").trim();
|
|
1557
|
+
if (what === "tasks" && !project)
|
|
1558
|
+
throw new HarborError('Give the project: harbor export tasks "NAME".');
|
|
1559
|
+
if (what !== "tasks") none(name);
|
|
1560
|
+
return call(
|
|
1561
|
+
"export_csv",
|
|
1562
|
+
{ harbor, what, project: project || undefined, all: Boolean(values.all) },
|
|
1563
|
+
(r) => r.csv.replace(/\r\n/g, "\n"),
|
|
1564
|
+
);
|
|
1565
|
+
}
|
|
1566
|
+
case "import": {
|
|
1567
|
+
const { positionals, values, harbor } = parse(spec(["no-people"], ["project", "portfolio"]));
|
|
1568
|
+
const [file, ...extra] = positionals;
|
|
1569
|
+
if (!file) throw new HarborError("Give the file: harbor import tasks.csv --project NAME.");
|
|
1570
|
+
none(extra);
|
|
1571
|
+
let csv;
|
|
1572
|
+
try {
|
|
1573
|
+
csv = readFileSync(file, "utf8");
|
|
1574
|
+
} catch {
|
|
1575
|
+
throw new HarborError(`Can't read ${file}.`);
|
|
1576
|
+
}
|
|
1577
|
+
return call(
|
|
1578
|
+
"import_tasks",
|
|
1579
|
+
{
|
|
1580
|
+
harbor,
|
|
1581
|
+
csv,
|
|
1582
|
+
project: values.project,
|
|
1583
|
+
portfolio: values.portfolio,
|
|
1584
|
+
addPeople: !values["no-people"],
|
|
1585
|
+
},
|
|
1586
|
+
importText,
|
|
1587
|
+
);
|
|
1588
|
+
}
|
|
1478
1589
|
case "premortem": {
|
|
1479
1590
|
const { positionals, values, harbor } = parse(spec(["start"]));
|
|
1480
1591
|
const [project, ...words] = positionals;
|
package/lib/format.mjs
CHANGED
|
@@ -216,9 +216,32 @@ function chance(c) {
|
|
|
216
216
|
return `${Math.round(c * 20) * 5}%`;
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
+
/**
|
|
220
|
+
* The forecast's dates as phrases, a date said once: when half and 85% of
|
|
221
|
+
* runs finish the same day, "very likely by" it is. At most `most` of them.
|
|
222
|
+
*/
|
|
223
|
+
function finishPhrases(f, most = 3) {
|
|
224
|
+
const steps = [
|
|
225
|
+
[f.likely, "probably"],
|
|
226
|
+
[f.veryLikely, "very likely by"],
|
|
227
|
+
[f.almostCertain, "almost certainly by"],
|
|
228
|
+
].filter(([date]) => date);
|
|
229
|
+
const kept = steps.filter(([date], i) => date !== steps[i + 1]?.[0]);
|
|
230
|
+
return kept.slice(0, most).map(([date, words]) => `${words} ${date}`);
|
|
231
|
+
}
|
|
232
|
+
|
|
219
233
|
function finishRange(f) {
|
|
220
234
|
if (!f.likely) return "not in the next two years at this pace";
|
|
221
|
-
return
|
|
235
|
+
return finishPhrases(f, 2).join(", ");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** What the pace can't see, as short notes for the list. */
|
|
239
|
+
function finishNotes(f) {
|
|
240
|
+
return [
|
|
241
|
+
f.rough ? "rough" : null,
|
|
242
|
+
f.shortHistory ? "under 2 weeks of pace" : null,
|
|
243
|
+
f.lastDue ? `${f.lastDue.key ?? `“${f.lastDue.title}”`} due ${f.lastDue.due}` : null,
|
|
244
|
+
].filter(Boolean);
|
|
222
245
|
}
|
|
223
246
|
|
|
224
247
|
/** When each open project will likely finish at its crew's recent pace. */
|
|
@@ -232,7 +255,7 @@ export function forecastList(r) {
|
|
|
232
255
|
const name = p.name.length > width ? `${p.name.slice(0, width - 1)}…` : p.name.padEnd(width);
|
|
233
256
|
if (f.none) return `${name} no forecast: ${f.none.replace(/^\w/, (c) => c.toLowerCase())}`;
|
|
234
257
|
const end = p.ends ? ` · ${chance(f.chanceByEnd)} by its end, ${p.ends}` : "";
|
|
235
|
-
return `${name} ${finishRange(f)}${end}
|
|
258
|
+
return [`${name} ${finishRange(f)}${end}`, ...finishNotes(f)].join(" · ");
|
|
236
259
|
})
|
|
237
260
|
.join("\n");
|
|
238
261
|
}
|
|
@@ -241,10 +264,10 @@ export function forecastList(r) {
|
|
|
241
264
|
export function forecastOne(p) {
|
|
242
265
|
const f = p.finish ?? {};
|
|
243
266
|
if (f.none) return `${p.name}: ${f.none}`;
|
|
244
|
-
const
|
|
267
|
+
const [first, ...more] = finishPhrases(f);
|
|
245
268
|
const lines = [
|
|
246
269
|
f.likely
|
|
247
|
-
? `${p.name} will ${
|
|
270
|
+
? `${p.name} will ${first.replace(/^(probably|very likely|almost certainly)/, "$1 finish")}${more.map((x) => `, ${x}`).join("")}.`
|
|
248
271
|
: `${p.name} won't finish in the next two years at this pace.`,
|
|
249
272
|
];
|
|
250
273
|
if (p.ends) {
|
|
@@ -255,9 +278,33 @@ export function forecastOne(p) {
|
|
|
255
278
|
`${f.openTasks} task${f.openTasks === 1 ? "" : "s"} open; the crew finishes about ${f.finishedPerWeek} a week (${f.basedOn}).`,
|
|
256
279
|
);
|
|
257
280
|
if (f.rough) lines.push("Only a few tasks have finished, so treat this as rough.");
|
|
281
|
+
if (f.shortHistory)
|
|
282
|
+
lines.push(
|
|
283
|
+
"That pace comes from under two weeks of work; tasks logged as done in a batch make it look faster than it is.",
|
|
284
|
+
);
|
|
285
|
+
if (f.lastDue) {
|
|
286
|
+
const which = f.lastDue.key ? `${f.lastDue.key} “${f.lastDue.title}”` : `“${f.lastDue.title}”`;
|
|
287
|
+
lines.push(
|
|
288
|
+
`${which} is due ${f.lastDue.due}, after the likely finish, so the plan runs longer than the pace says.`,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
258
291
|
return lines.join("\n");
|
|
259
292
|
}
|
|
260
293
|
|
|
294
|
+
/** forecast --json: the finish forecast itself, with only what names the project around it. */
|
|
295
|
+
export function forecastData(r) {
|
|
296
|
+
const one = (p) => ({
|
|
297
|
+
id: p.id,
|
|
298
|
+
name: p.name,
|
|
299
|
+
...(p.key ? { key: p.key } : {}),
|
|
300
|
+
...(p.program !== undefined ? { program: p.program } : {}),
|
|
301
|
+
ends: p.ends ?? null,
|
|
302
|
+
...p.finish,
|
|
303
|
+
});
|
|
304
|
+
if (!r.projects) return one(r);
|
|
305
|
+
return { projects: r.projects.filter((p) => p.health !== "done").map(one) };
|
|
306
|
+
}
|
|
307
|
+
|
|
261
308
|
/** A project's pre-mortem, as get_premortem answers it. */
|
|
262
309
|
export function premortemText(r) {
|
|
263
310
|
if (!r.running) {
|
|
@@ -370,6 +417,25 @@ export function hillList(r) {
|
|
|
370
417
|
return blocks.join("\n\n");
|
|
371
418
|
}
|
|
372
419
|
|
|
420
|
+
/** What harbor import did, as import_tasks answers it. */
|
|
421
|
+
export function importText(r) {
|
|
422
|
+
const n = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
423
|
+
const from = r.from ? ` from ${r.from}` : "";
|
|
424
|
+
const lines = [
|
|
425
|
+
r.imported
|
|
426
|
+
? `Imported ${n(r.imported, "task")}${from} into ${r.portfolio}.`
|
|
427
|
+
: `Nothing imported${from}.`,
|
|
428
|
+
];
|
|
429
|
+
if (r.newPrograms?.length) lines.push(`New programs: ${r.newPrograms.join(", ")}.`);
|
|
430
|
+
if (r.newProjects?.length) lines.push(`New projects: ${r.newProjects.join(", ")}.`);
|
|
431
|
+
if (r.newPeople?.length) lines.push(`New people: ${r.newPeople.join(", ")}.`);
|
|
432
|
+
if (r.alreadyThere) lines.push(`${n(r.alreadyThere, "task")} already there, left alone.`);
|
|
433
|
+
if (r.leftOut) lines.push(`${n(r.leftOut, "row")} left out (canceled, won't do or duplicate).`);
|
|
434
|
+
if (r.waitsOnLinks) lines.push(`${n(r.waitsOnLinks, "link")} between tasks became "waits on".`);
|
|
435
|
+
for (const p of r.problems ?? []) lines.push(`Row ${p.row}: ${p.message}`);
|
|
436
|
+
return lines.join("\n");
|
|
437
|
+
}
|
|
438
|
+
|
|
373
439
|
/** A project's notes for later, as get_project lists them. */
|
|
374
440
|
export function notesText(project, notes) {
|
|
375
441
|
if (!notes?.length)
|