@harborppm/cli 0.37.0 → 0.39.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 +7 -3
- package/dist/app.mjs +539 -6
- package/lib/cli.mjs +154 -8
- package/lib/format.mjs +70 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -57,7 +57,7 @@ Your harbor is the same one at [harborppm.com](https://harborppm.com/today?utm_s
|
|
|
57
57
|
- **GitHub**: a pull request that names a task's key (RR-42) shows on the task and moves it along.
|
|
58
58
|
- **Claude and other AI apps**, connected over MCP from [harborppm.com/ai](https://harborppm.com/ai?utm_source=npm&utm_medium=readme&utm_campaign=cli).
|
|
59
59
|
|
|
60
|
-
`harbor open RR-42` opens a task on the website (in the right harbor, even when the browser had another one open), `harbor open Ridge` a project, and `harbor open` on its own opens Today. `harbor invite sam@example.com` sends Sam the same invite letter the People page does (`--viewer` to let them look but not change anything); only a harbor's owner can invite. `harbor calendar` prints your private calendar link, to add to Google, Apple or Outlook calendar: your due dates, milestones and sign-offs beside your meetings, kept up to date (`--program NAME` for everything dated in one program, `--new` to swap the link for a fresh one, `--off` to turn it off). `harbor roadmap "Next steps"`
|
|
60
|
+
`harbor open RR-42` opens a task on the website (in the right harbor, even when the browser had another one open), `harbor open Ridge` a project, and `harbor open` on its own opens Today. `harbor invite sam@example.com` sends Sam the same invite letter the People page does (`--viewer` to let them look but not change anything); only a harbor's owner can invite. `harbor calendar` prints your private calendar link, to add to Google, Apple or Outlook calendar: your due dates, milestones and sign-offs beside your meetings, kept up to date (`--program NAME` for everything dated in one program, `--new` to swap the link for a fresh one, `--off` to turn it off). `harbor roadmap "Next steps"` says whether a project is a public roadmap page and prints its link; `--publish` publishes it (Now, Next and Shipped, task titles only, on a link that can't be guessed, which stays the same while it's live), `--publish --dates` adds the day each thing shipped (titles only is the default), and `--off` takes it down at once. Only a harbor's owner can publish. `harbor projects --json` gives each project's `public` and `url`.
|
|
61
61
|
|
|
62
62
|
The website has the commands too: turn on **Command line at the bottom** in your menu, press `/`, and type `mine`, `add "Call the mill" #ridge @sam --due fri` or `done RR-12`.
|
|
63
63
|
|
|
@@ -129,7 +129,7 @@ harbor show TASK one task in full, with its web link
|
|
|
129
129
|
harbor open [TASK|PROJECT] open it on the website (or Today)
|
|
130
130
|
harbor invite EMAIL [--viewer] invite someone to your harbor by email
|
|
131
131
|
harbor calendar [--program P] [--new|--off] your private calendar link (.ics)
|
|
132
|
-
harbor roadmap PROJECT [--
|
|
132
|
+
harbor roadmap PROJECT [--publish] [--off] a project's public roadmap: read, publish or take it down
|
|
133
133
|
|
|
134
134
|
harbor program "NAME" [--portfolio P] [--lead me|NAME] [--starts DATE]
|
|
135
135
|
[--ends DATE]
|
|
@@ -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,
|
|
@@ -83,8 +85,10 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
83
85
|
harbor prompt --init SHELL how to add it: zsh, bash, fish
|
|
84
86
|
or starship
|
|
85
87
|
harbor calendar [--program P] [--new|--off] your private calendar link (.ics)
|
|
86
|
-
harbor roadmap PROJECT [--dates|--no-dates] [--
|
|
87
|
-
|
|
88
|
+
harbor roadmap PROJECT [--publish [--dates|--no-dates] [--site|--no-site]]
|
|
89
|
+
[--off]
|
|
90
|
+
a project's public roadmap: read,
|
|
91
|
+
publish or take it down
|
|
88
92
|
|
|
89
93
|
harbor init [PROJECT] [--hook] keep a project's tasks as files in
|
|
90
94
|
this repository (.harbor/), one
|
|
@@ -122,6 +126,10 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
122
126
|
--say reads it aloud as a
|
|
123
127
|
shipping forecast
|
|
124
128
|
harbor report PROGRAM a program's status report
|
|
129
|
+
harbor export tasks PROJECT|risks|review [--all]
|
|
130
|
+
a spreadsheet (CSV) to stdout
|
|
131
|
+
harbor import FILE [--project P] [--portfolio P] [--no-people]
|
|
132
|
+
add tasks from a CSV file
|
|
125
133
|
harbor premortem PROJECT ["WHAT WENT WRONG"] [--start]
|
|
126
134
|
a project's pre-mortem: start,
|
|
127
135
|
answer or read it
|
|
@@ -444,6 +452,68 @@ const DETAIL = {
|
|
|
444
452
|
" PROJECT is a name, a key like RR, or an id. When two projects share a",
|
|
445
453
|
" name, give the key.",
|
|
446
454
|
],
|
|
455
|
+
roadmap: [
|
|
456
|
+
" harbor roadmap PROJECT reads whether it's public, and its link",
|
|
457
|
+
" harbor roadmap PROJECT --publish writes: publishes it, or keeps it live",
|
|
458
|
+
" with any change below; the link stays",
|
|
459
|
+
" the same while it's live",
|
|
460
|
+
" harbor roadmap PROJECT --publish --dates",
|
|
461
|
+
" writes: also shows the day each thing",
|
|
462
|
+
" shipped (--no-dates: titles only, the",
|
|
463
|
+
" default)",
|
|
464
|
+
" harbor roadmap PROJECT --publish --site",
|
|
465
|
+
" writes: also shows it at",
|
|
466
|
+
" harborppm.com/roadmap, in place of any",
|
|
467
|
+
" other (Harbor's own team only;",
|
|
468
|
+
" --no-site, the default, takes it off)",
|
|
469
|
+
" harbor roadmap PROJECT --off writes: takes it down at once; the link",
|
|
470
|
+
" then says it isn't published, and",
|
|
471
|
+
" publishing again makes a new link",
|
|
472
|
+
"",
|
|
473
|
+
" A visitor sees Now, Next and Shipped: task titles only, with no people,",
|
|
474
|
+
" keys, notes, comments or due dates. Anyone with the link can open it; the",
|
|
475
|
+
" link is long and random, so it can't be guessed. Only the harbor's owner",
|
|
476
|
+
" can publish.",
|
|
477
|
+
],
|
|
478
|
+
forecast: [
|
|
479
|
+
" harbor forecast [--program P] reads when each open project will",
|
|
480
|
+
" likely finish",
|
|
481
|
+
" harbor forecast PROJECT reads one project's forecast and what",
|
|
482
|
+
" it rests on",
|
|
483
|
+
"",
|
|
484
|
+
" Worked out from the crew's pace alone: how many tasks they finished each",
|
|
485
|
+
" week over the last six (or the project's life, if shorter), replayed a",
|
|
486
|
+
" thousand times until its open tasks run out. Probably is half of those",
|
|
487
|
+
" runs, very likely 85%, almost certainly 95%. No estimates or points.",
|
|
488
|
+
" It warns when under two weeks of pace is all there is, and when an open",
|
|
489
|
+
" task is due after the likely finish, since the pace can't see due dates.",
|
|
490
|
+
],
|
|
491
|
+
report: [
|
|
492
|
+
" harbor report PROGRAM reads the program's status report",
|
|
493
|
+
"",
|
|
494
|
+
" Prints Markdown, as the website's Status report button writes it:",
|
|
495
|
+
" health, progress, late work, milestones, risks and the next move.",
|
|
496
|
+
" Ready to paste; harbor report PROGRAM > report.md saves it.",
|
|
497
|
+
],
|
|
498
|
+
export: [
|
|
499
|
+
" harbor export tasks PROJECT reads a project's tasks as CSV",
|
|
500
|
+
" harbor export risks [--all] reads the risk register as CSV",
|
|
501
|
+
" (--all adds closed ones)",
|
|
502
|
+
" harbor export review reads the review as CSV",
|
|
503
|
+
"",
|
|
504
|
+
" The same spreadsheets the website's Export buttons download. They print;",
|
|
505
|
+
" harbor export tasks RR > tasks.csv saves one.",
|
|
506
|
+
],
|
|
507
|
+
import: [
|
|
508
|
+
" harbor import FILE --project P writes: adds the file's tasks",
|
|
509
|
+
" harbor import FILE --portfolio P writes: makes projects as the file",
|
|
510
|
+
" names them",
|
|
511
|
+
"",
|
|
512
|
+
" FILE is CSV or tab-separated: a spreadsheet, or a Jira, Asana or Linear",
|
|
513
|
+
" export. It needs a column of titles; rows naming a Project column go",
|
|
514
|
+
" there. People it names who aren't in the harbor are added unless you say",
|
|
515
|
+
" --no-people. A task already in its project by title is left alone.",
|
|
516
|
+
],
|
|
447
517
|
note: [
|
|
448
518
|
" harbor note PROJECT reads its notes for later: who left",
|
|
449
519
|
" each, what opens it, and the opened ones",
|
|
@@ -507,6 +577,21 @@ const DETAIL = {
|
|
|
507
577
|
" PROJECT is a name, a key like RR, or an id. When two projects share a",
|
|
508
578
|
" name, give the key.",
|
|
509
579
|
],
|
|
580
|
+
risk: [
|
|
581
|
+
' harbor risk "TITLE" --project P writes: logs a risk on the project',
|
|
582
|
+
' harbor risk "TITLE" --program P writes: logs one on the program',
|
|
583
|
+
" [--impact 1-5] [--probability 1-5] [--owner NAME] [--due DATE]",
|
|
584
|
+
],
|
|
585
|
+
issue: [
|
|
586
|
+
' harbor issue "TITLE" --project P writes: logs an issue on the project',
|
|
587
|
+
' harbor issue "TITLE" --program P writes: logs one on the program',
|
|
588
|
+
" [--impact 1-5] [--owner NAME] [--due DATE]",
|
|
589
|
+
],
|
|
590
|
+
decision: [
|
|
591
|
+
' harbor decision "TITLE" --project P writes: logs a decision to make',
|
|
592
|
+
' harbor decision "TITLE" --program P writes: logs one on the program',
|
|
593
|
+
" [--owner NAME] [--due DATE] (the date to decide by)",
|
|
594
|
+
],
|
|
510
595
|
};
|
|
511
596
|
|
|
512
597
|
/** The lines of the help about one command, or null when it has none. */
|
|
@@ -1094,8 +1179,21 @@ export async function run(argv, io = {}) {
|
|
|
1094
1179
|
case "forecast": {
|
|
1095
1180
|
const { positionals, values, harbor } = parse(spec([], ["program"]));
|
|
1096
1181
|
const project = positionals.join(" ").trim();
|
|
1097
|
-
|
|
1098
|
-
|
|
1182
|
+
// --json gets each forecast flat beside the project's name (forecastData).
|
|
1183
|
+
const withFinish = (p) => ({ ...p, finish: p });
|
|
1184
|
+
if (project)
|
|
1185
|
+
return call(
|
|
1186
|
+
"get_project",
|
|
1187
|
+
{ harbor, project },
|
|
1188
|
+
(d) => forecastOne(withFinish(d)),
|
|
1189
|
+
forecastData,
|
|
1190
|
+
);
|
|
1191
|
+
return call(
|
|
1192
|
+
"list_projects",
|
|
1193
|
+
{ harbor, program: values.program, limit: 100 },
|
|
1194
|
+
(d) => forecastList({ projects: d.projects.map(withFinish) }),
|
|
1195
|
+
forecastData,
|
|
1196
|
+
);
|
|
1099
1197
|
}
|
|
1100
1198
|
case "hill": {
|
|
1101
1199
|
const { positionals, values, harbor } = parse(spec([], ["program", "at"]));
|
|
@@ -1276,20 +1374,28 @@ export async function run(argv, io = {}) {
|
|
|
1276
1374
|
}
|
|
1277
1375
|
case "roadmap": {
|
|
1278
1376
|
const { positionals, values, harbor } = parse(
|
|
1279
|
-
spec(["dates", "no-dates", "site", "no-site", "off"]),
|
|
1377
|
+
spec(["publish", "dates", "no-dates", "site", "no-site", "off"]),
|
|
1280
1378
|
);
|
|
1281
1379
|
const project = text(positionals, "which project");
|
|
1282
1380
|
if (values.dates && values["no-dates"])
|
|
1283
1381
|
throw new HarborError("Say --dates or --no-dates, not both.");
|
|
1284
1382
|
if (values.site && values["no-site"])
|
|
1285
1383
|
throw new HarborError("Say --site or --no-site, not both.");
|
|
1384
|
+
if (values.off && values.publish) throw new HarborError("Say --publish or --off, not both.");
|
|
1385
|
+
const changes = ["dates", "no-dates", "site", "no-site"].filter((f) => values[f]);
|
|
1386
|
+
if (changes.length && !values.publish) {
|
|
1387
|
+
throw new HarborError(
|
|
1388
|
+
`Nothing changed. Add --publish to change what the page shows: harbor roadmap "${project}" --publish ${changes.map((f) => `--${f}`).join(" ")}`,
|
|
1389
|
+
);
|
|
1390
|
+
}
|
|
1286
1391
|
if (!hasFullAccess(creds)) {
|
|
1287
|
-
throw new HarborError("
|
|
1392
|
+
throw new HarborError("Roadmaps need full access. Run harbor login, then try again.");
|
|
1288
1393
|
}
|
|
1289
1394
|
const r = await client.app({
|
|
1290
1395
|
op: "roadmap",
|
|
1291
1396
|
...(harbor ? { harbor } : {}),
|
|
1292
1397
|
project,
|
|
1398
|
+
...(values.publish ? { publish: true } : {}),
|
|
1293
1399
|
...(values.off ? { off: true } : {}),
|
|
1294
1400
|
...(values.dates || values["no-dates"] ? { showDates: Boolean(values.dates) } : {}),
|
|
1295
1401
|
...(values.site || values["no-site"] ? { onSite: Boolean(values.site) } : {}),
|
|
@@ -1301,7 +1407,9 @@ export async function run(argv, io = {}) {
|
|
|
1301
1407
|
if (!r.url) {
|
|
1302
1408
|
out(
|
|
1303
1409
|
plain(
|
|
1304
|
-
|
|
1410
|
+
values.off
|
|
1411
|
+
? `${r.project} isn't published any more. Its link now says the roadmap isn't published.`
|
|
1412
|
+
: `${r.project} isn't public. harbor roadmap "${r.project}" --publish publishes it.`,
|
|
1305
1413
|
),
|
|
1306
1414
|
);
|
|
1307
1415
|
return 0;
|
|
@@ -1309,7 +1417,7 @@ export async function run(argv, io = {}) {
|
|
|
1309
1417
|
const shows = r.showDates ? "titles and the day each shipped" : "titles only";
|
|
1310
1418
|
out(
|
|
1311
1419
|
plain(
|
|
1312
|
-
`${r.project}'s public roadmap (Now, Next and Shipped; ${shows}, no people):\n\n ${r.url}${r.siteUrl ? `\n\nIt's also Harbor's own roadmap at ${r.siteUrl}.` : ""}\n\nharbor roadmap "${r.project}" --off takes it down.`,
|
|
1420
|
+
`${r.project}${values.publish ? " is public" : "'s public roadmap"} (Now, Next and Shipped; ${shows}, no people):\n\n ${r.url}${r.siteUrl ? `\n\nIt's also Harbor's own roadmap at ${r.siteUrl}.` : ""}\n\nharbor roadmap "${r.project}" --off takes it down.`,
|
|
1313
1421
|
),
|
|
1314
1422
|
);
|
|
1315
1423
|
return 0;
|
|
@@ -1475,6 +1583,44 @@ export async function run(argv, io = {}) {
|
|
|
1475
1583
|
if (!program) throw new HarborError('Give a program: harbor report "NAME".');
|
|
1476
1584
|
return call("status_report", { harbor, program }, (r) => r.markdown);
|
|
1477
1585
|
}
|
|
1586
|
+
case "export": {
|
|
1587
|
+
const { positionals, values, harbor } = parse(spec(["all"]));
|
|
1588
|
+
const [what, ...name] = positionals;
|
|
1589
|
+
if (!["tasks", "risks", "review"].includes(what))
|
|
1590
|
+
throw new HarborError("Say what to export: harbor export tasks PROJECT, risks or review.");
|
|
1591
|
+
const project = name.join(" ").trim();
|
|
1592
|
+
if (what === "tasks" && !project)
|
|
1593
|
+
throw new HarborError('Give the project: harbor export tasks "NAME".');
|
|
1594
|
+
if (what !== "tasks") none(name);
|
|
1595
|
+
return call(
|
|
1596
|
+
"export_csv",
|
|
1597
|
+
{ harbor, what, project: project || undefined, all: Boolean(values.all) },
|
|
1598
|
+
(r) => r.csv.replace(/\r\n/g, "\n"),
|
|
1599
|
+
);
|
|
1600
|
+
}
|
|
1601
|
+
case "import": {
|
|
1602
|
+
const { positionals, values, harbor } = parse(spec(["no-people"], ["project", "portfolio"]));
|
|
1603
|
+
const [file, ...extra] = positionals;
|
|
1604
|
+
if (!file) throw new HarborError("Give the file: harbor import tasks.csv --project NAME.");
|
|
1605
|
+
none(extra);
|
|
1606
|
+
let csv;
|
|
1607
|
+
try {
|
|
1608
|
+
csv = readFileSync(file, "utf8");
|
|
1609
|
+
} catch {
|
|
1610
|
+
throw new HarborError(`Can't read ${file}.`);
|
|
1611
|
+
}
|
|
1612
|
+
return call(
|
|
1613
|
+
"import_tasks",
|
|
1614
|
+
{
|
|
1615
|
+
harbor,
|
|
1616
|
+
csv,
|
|
1617
|
+
project: values.project,
|
|
1618
|
+
portfolio: values.portfolio,
|
|
1619
|
+
addPeople: !values["no-people"],
|
|
1620
|
+
},
|
|
1621
|
+
importText,
|
|
1622
|
+
);
|
|
1623
|
+
}
|
|
1478
1624
|
case "premortem": {
|
|
1479
1625
|
const { positionals, values, harbor } = parse(spec(["start"]));
|
|
1480
1626
|
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)
|