@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 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 (health, lead, dates)"],
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 rows = FIELDS$1.length + (task?.checklist.length ?? 0);
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 item = task.checklist[at - FIELDS$1.length];
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 === FIELDS$1.length + i;
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
- if (project) return call("get_project", { harbor, project }, forecastOne);
1098
- return call("list_projects", { harbor, program: values.program, limit: 100 }, forecastList);
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 `probably ${f.likely}${f.veryLikely ? `, very likely by ${f.veryLikely}` : ""}`;
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}${f.rough ? " · rough" : ""}`;
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 range = finishRange(f);
267
+ const [first, ...more] = finishPhrases(f);
245
268
  const lines = [
246
269
  f.likely
247
- ? `${p.name} will ${range.replace(/^probably/, "probably finish")}${f.almostCertain ? `, almost certainly by ${f.almostCertain}` : ""}.`
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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.37.0",
3
+ "version": "0.38.0",
4
4
  "description": "Harbor from the terminal: a full-screen app with everything, plus commands for scripts and agents.",
5
5
  "keywords": [
6
6
  "harbor",