@harborppm/cli 0.9.2 → 0.10.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 -1
- package/bin/harbor.mjs +7 -5
- package/dist/app.mjs +40 -15
- package/lib/cli.mjs +84 -1
- package/lib/format.mjs +52 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -64,10 +64,16 @@ It runs on the same engine as the website: health, task keys, limits and undo wo
|
|
|
64
64
|
```
|
|
65
65
|
harbor harbors the harbors you belong to
|
|
66
66
|
harbor use HARBOR|none the harbor to work in when you're in several
|
|
67
|
+
harbor programs programs and their projects
|
|
68
|
+
harbor projects [words] [--program P] projects with health and open work
|
|
67
69
|
harbor mine what's on you, late first
|
|
68
70
|
harbor tasks [words] [--project P] [--assignee me|none|NAME] [--late] [--status S,S]
|
|
69
71
|
harbor show TASK one task in full
|
|
70
72
|
|
|
73
|
+
harbor program "NAME" [--portfolio P] [--lead me|NAME] [--starts DATE]
|
|
74
|
+
[--ends DATE]
|
|
75
|
+
harbor project "NAME" --program P [--lead me|NAME] [--starts DATE]
|
|
76
|
+
[--ends DATE] [--budget N]
|
|
71
77
|
harbor add "TITLE" --project P [--assignee me|NAME] [--due DATE]
|
|
72
78
|
[--priority low|medium|high|urgent] [--status S] [--over-limit]
|
|
73
79
|
harbor start TASK | review TASK | done TASK [--confirm] [--over-limit]
|
|
@@ -81,7 +87,7 @@ harbor issue "TITLE" --project P|--program P [--impact 1-5] [--owner NAME]
|
|
|
81
87
|
[--due DATE]
|
|
82
88
|
```
|
|
83
89
|
|
|
84
|
-
Every command takes `--json` for the raw result, and `harbor COMMAND --help` shows just that command. Statuses are backlog, todo, doing, review and done. A `DATE` is YYYY-MM-DD, `today`, `tomorrow`, or `+N` for N days from today.
|
|
90
|
+
Every command takes `--json` for the raw result (a refusal then comes out as `{"error": "…"}` on standard output, still with exit code 1), and `harbor COMMAND --help` shows just that command. Statuses are backlog, todo, doing, review and done. A `DATE` is YYYY-MM-DD, `today`, `tomorrow`, or `+N` for N days from today.
|
|
85
91
|
|
|
86
92
|
When you belong to more than one harbor, say which: `--harbor NAME` on the command, or `harbor use NAME` once (it's saved with your sign-in; `harbor use none` forgets it). With neither, the commands ask rather than guess, so nothing lands in the wrong harbor. The full-screen app shares the same choice: it opens the harbor `harbor use` set, asks which one the first time if none is set, and a harbor you switch to in the app becomes the one the commands use.
|
|
87
93
|
|
package/bin/harbor.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { VERSION, run } from "../lib/cli.mjs";
|
|
3
3
|
import { HarborError } from "../lib/mcp.mjs";
|
|
4
|
-
import { plain } from "../lib/plain.mjs";
|
|
4
|
+
import { jsonText, plain } from "../lib/plain.mjs";
|
|
5
5
|
import { updateNotice } from "../lib/update.mjs";
|
|
6
6
|
|
|
7
7
|
const argv = process.argv.slice(2);
|
|
@@ -24,10 +24,12 @@ run(argv).then(
|
|
|
24
24
|
err instanceof HarborError ||
|
|
25
25
|
err?.name === "HarborError" ||
|
|
26
26
|
/^(Unknown option|--\S+ (needs|takes|is))/.test(err?.message ?? "");
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
const message = known ? err.message : `Something went wrong: ${err?.message ?? err}`;
|
|
28
|
+
// With --json a script or agent reads the refusal as data, on stdout like
|
|
29
|
+
// a result; jsonText keeps it exact. Otherwise Harbor's refusals can quote
|
|
30
|
+
// a task's title, so they're printed plain.
|
|
31
|
+
if (argv.includes("--json")) process.stdout.write(`${jsonText({ error: message })}\n`);
|
|
32
|
+
else process.stderr.write(`${plain(message)}\n`);
|
|
31
33
|
await tell();
|
|
32
34
|
process.exit(1);
|
|
33
35
|
},
|
package/dist/app.mjs
CHANGED
|
@@ -942,7 +942,7 @@ function stepsFor(kind, data, ctx, me) {
|
|
|
942
942
|
key: "name",
|
|
943
943
|
label: "Name",
|
|
944
944
|
kind: "text",
|
|
945
|
-
placeholder: "Fall catalogue shoot"
|
|
945
|
+
placeholder: "e.g. Fall catalogue shoot"
|
|
946
946
|
},
|
|
947
947
|
...ctx.programId ? [] : [{
|
|
948
948
|
key: "programId",
|
|
@@ -977,7 +977,7 @@ function stepsFor(kind, data, ctx, me) {
|
|
|
977
977
|
key: "name",
|
|
978
978
|
label: "Name",
|
|
979
979
|
kind: "text",
|
|
980
|
-
placeholder: "Spring range"
|
|
980
|
+
placeholder: "e.g. Spring range"
|
|
981
981
|
},
|
|
982
982
|
...ctx.portfolioId ? [] : [{
|
|
983
983
|
key: "portfolioId",
|
|
@@ -1005,7 +1005,7 @@ function stepsFor(kind, data, ctx, me) {
|
|
|
1005
1005
|
key: "name",
|
|
1006
1006
|
label: "Name",
|
|
1007
1007
|
kind: "text",
|
|
1008
|
-
placeholder: "Samples approved"
|
|
1008
|
+
placeholder: "e.g. Samples approved"
|
|
1009
1009
|
}, {
|
|
1010
1010
|
key: "dueDate",
|
|
1011
1011
|
label: "Due",
|
|
@@ -2349,7 +2349,7 @@ function Editor({ label, edit, done }) {
|
|
|
2349
2349
|
if (edit.kind === "date") return /* @__PURE__ */ jsx(TextField, {
|
|
2350
2350
|
label,
|
|
2351
2351
|
initial: edit.initial ?? "",
|
|
2352
|
-
placeholder: "
|
|
2352
|
+
placeholder: "YYYY-MM-DD, today, tomorrow, +3, none",
|
|
2353
2353
|
hint: error ?? "A date, today, tomorrow, +3 for three days on, or none",
|
|
2354
2354
|
onCancel: done,
|
|
2355
2355
|
onSubmit: (text) => {
|
|
@@ -2505,7 +2505,7 @@ function Stepper({ title, steps, width, onDone, onCancel }) {
|
|
|
2505
2505
|
onPick: next
|
|
2506
2506
|
}, step.key) : /* @__PURE__ */ jsx(TextField, {
|
|
2507
2507
|
label,
|
|
2508
|
-
placeholder: step.kind === "date" ? "
|
|
2508
|
+
placeholder: step.kind === "date" ? "YYYY-MM-DD, today, +30" : step.kind === "text" ? step.placeholder : void 0,
|
|
2509
2509
|
hint: error ?? (step.optional ? "Enter to skip · Esc to leave it" : void 0),
|
|
2510
2510
|
onCancel,
|
|
2511
2511
|
onSubmit: (text) => {
|
|
@@ -3068,11 +3068,12 @@ function inboxOf(data, personId, now = clockNow()) {
|
|
|
3068
3068
|
return who ? firstName(who.name) : "Someone";
|
|
3069
3069
|
}))];
|
|
3070
3070
|
const blocked = waiting.length === 1 ? `“${waiting[0].title}”` : `${waiting.length} tasks`;
|
|
3071
|
+
const upstream = openBlockerNames(blocker, data.tasks);
|
|
3071
3072
|
pings.push({
|
|
3072
3073
|
id: waiting.length === 1 ? `wait:${waiting[0].id}` : `wait:${blocker.id}:${shortHash(waiting.map((t) => t.id).sort().join(","))}`,
|
|
3073
3074
|
kind: "waiting",
|
|
3074
3075
|
title: `${listNames(names)} ${names.length === 1 ? "is" : "are"} waiting on you`,
|
|
3075
|
-
detail: `${blocked} ${waiting.length === 1 ? "is" : "are"} blocked by “${blocker.title}”${project ? ` · ${project.name}` : ""}`,
|
|
3076
|
+
detail: `${blocked} ${waiting.length === 1 ? "is" : "are"} blocked by “${blocker.title}”${project ? ` · ${project.name}` : ""}${upstream ? `. Yours waits on ${upstream} first.` : ""}`,
|
|
3076
3077
|
at: waiting.reduce((latest, t) => t.createdAt > latest ? t.createdAt : latest, ""),
|
|
3077
3078
|
taskId: blocker.id,
|
|
3078
3079
|
personId: waiting[0].assigneeId,
|
|
@@ -3887,6 +3888,7 @@ const HELP = [
|
|
|
3887
3888
|
["u / r", "undo your last change / retry saving"],
|
|
3888
3889
|
["v", "on a project: board, list, budget"],
|
|
3889
3890
|
["m / R", "new milestone on a program / log a risk"],
|
|
3891
|
+
["D", "show or hide Done projects and programs"],
|
|
3890
3892
|
["c / a", "on a task: comment / add a checklist step"],
|
|
3891
3893
|
["q", "quit (saves first)"]
|
|
3892
3894
|
];
|
|
@@ -4687,11 +4689,25 @@ function PersonPanel({ id, width, height, go, onClose }) {
|
|
|
4687
4689
|
});
|
|
4688
4690
|
}
|
|
4689
4691
|
|
|
4692
|
+
//#endregion
|
|
4693
|
+
//#region src/lib/selectors.ts
|
|
4694
|
+
/** Projects (or programs) still running, and the ones set to Done, which lists tuck away until asked for. */
|
|
4695
|
+
function splitDone(items) {
|
|
4696
|
+
const open = [];
|
|
4697
|
+
const done = [];
|
|
4698
|
+
for (const item of items) (item.health === "done" ? done : open).push(item);
|
|
4699
|
+
return {
|
|
4700
|
+
open,
|
|
4701
|
+
done
|
|
4702
|
+
};
|
|
4703
|
+
}
|
|
4704
|
+
|
|
4690
4705
|
//#endregion
|
|
4691
4706
|
//#region src/tui/views/program.tsx
|
|
4692
4707
|
/**
|
|
4693
4708
|
* One program: its health (and why, when set by hand), next move, its
|
|
4694
|
-
* projects and its milestones. ←→ between the two lists.
|
|
4709
|
+
* projects and its milestones. ←→ between the two lists. Projects set to
|
|
4710
|
+
* Done stay out of the list until D shows them.
|
|
4695
4711
|
*/
|
|
4696
4712
|
function ProgramView({ programId, active, width, height, go, onBack }) {
|
|
4697
4713
|
const { store, today } = useDeps();
|
|
@@ -4701,7 +4717,9 @@ function ProgramView({ programId, active, width, height, go, onBack }) {
|
|
|
4701
4717
|
const milestones = useHarbor((s) => s.milestones);
|
|
4702
4718
|
const people = useHarbor((s) => s.people);
|
|
4703
4719
|
const portfolio = useHarbor((s) => s.portfolios.find((p) => p.id === program?.portfolioId));
|
|
4704
|
-
const
|
|
4720
|
+
const [showDone, setShowDone] = useState(false);
|
|
4721
|
+
const split = useMemo(() => splitDone(projects.filter((p) => p.programId === programId)), [projects, programId]);
|
|
4722
|
+
const mine = showDone ? [...split.open, ...split.done] : split.open;
|
|
4705
4723
|
const marks = useMemo(() => milestonesOf({ milestones }, programId, today()), [
|
|
4706
4724
|
milestones,
|
|
4707
4725
|
programId,
|
|
@@ -4720,6 +4738,10 @@ function ProgramView({ programId, active, width, height, go, onBack }) {
|
|
|
4720
4738
|
if (input === "n") return go.create("project", { programId });
|
|
4721
4739
|
if (input === "m") return go.create("milestone", { programId });
|
|
4722
4740
|
if (input === "R") return go.create("raid", {});
|
|
4741
|
+
if (input === "D" && split.done.length > 0) {
|
|
4742
|
+
setSide("projects");
|
|
4743
|
+
return setShowDone((v) => !v);
|
|
4744
|
+
}
|
|
4723
4745
|
if (side === "projects") {
|
|
4724
4746
|
if (key.upArrow || input === "k") setPAt((i) => step(i, -1, mine.length));
|
|
4725
4747
|
else if (key.downArrow || input === "j") setPAt((i) => step(i, 1, mine.length));
|
|
@@ -4794,12 +4816,12 @@ function ProgramView({ programId, active, width, height, go, onBack }) {
|
|
|
4794
4816
|
marginRight: 1,
|
|
4795
4817
|
children: [
|
|
4796
4818
|
/* @__PURE__ */ jsx(Heading, {
|
|
4797
|
-
right: `${mine.length}`,
|
|
4819
|
+
right: split.done.length > 0 ? `${mine.length} · ${split.done.length} done ${showDone ? "shown" : "hidden"} (D)` : `${mine.length}`,
|
|
4798
4820
|
children: side === "projects" ? "› Projects" : " Projects"
|
|
4799
4821
|
}),
|
|
4800
4822
|
mine.length === 0 ? /* @__PURE__ */ jsx(Text, {
|
|
4801
4823
|
color: theme.muted,
|
|
4802
|
-
children: "None yet. n adds one."
|
|
4824
|
+
children: split.done.length > 0 ? "Every project here is done. D shows them." : "None yet. n adds one."
|
|
4803
4825
|
}) : null,
|
|
4804
4826
|
windowOf(mine, pAt, rows).items.map((p) => {
|
|
4805
4827
|
const on = active && side === "projects" && p.id === project?.id;
|
|
@@ -4873,18 +4895,21 @@ function ProgramView({ programId, active, width, height, go, onBack }) {
|
|
|
4873
4895
|
]
|
|
4874
4896
|
});
|
|
4875
4897
|
}
|
|
4876
|
-
/** One portfolio: its health and its programs, each with theirs. */
|
|
4898
|
+
/** One portfolio: its health and its programs, each with theirs; Done ones wait for D. */
|
|
4877
4899
|
function PortfolioView({ portfolioId, active, width, height, go, onBack }) {
|
|
4878
4900
|
const portfolio = useHarbor((s) => s.portfolios.find((p) => p.id === portfolioId));
|
|
4879
4901
|
const programs = useHarbor((s) => s.programs);
|
|
4880
4902
|
const projects = useHarbor((s) => s.projects);
|
|
4881
|
-
const
|
|
4903
|
+
const [showDone, setShowDone] = useState(false);
|
|
4904
|
+
const split = useMemo(() => splitDone(programs.filter((p) => p.portfolioId === portfolioId)), [programs, portfolioId]);
|
|
4905
|
+
const list = showDone ? [...split.open, ...split.done] : split.open;
|
|
4882
4906
|
const [at, setAt] = useState(0);
|
|
4883
4907
|
const program = list[Math.min(at, list.length - 1)];
|
|
4884
4908
|
useInput((input, key) => {
|
|
4885
4909
|
if (key.escape || key.backspace) return onBack();
|
|
4886
4910
|
if (input === "e") return go.openPanel("portfolio", portfolioId);
|
|
4887
4911
|
if (input === "n") return go.create("program", { portfolioId });
|
|
4912
|
+
if (input === "D" && split.done.length > 0) return setShowDone((v) => !v);
|
|
4888
4913
|
if (key.upArrow || input === "k") setAt((i) => step(i, -1, list.length));
|
|
4889
4914
|
else if (key.downArrow || input === "j") setAt((i) => step(i, 1, list.length));
|
|
4890
4915
|
else if (key.return && program) go.openProgram(program.id);
|
|
@@ -4928,12 +4953,12 @@ function PortfolioView({ portfolioId, active, width, height, go, onBack }) {
|
|
|
4928
4953
|
}),
|
|
4929
4954
|
/* @__PURE__ */ jsx(Text, { children: " " }),
|
|
4930
4955
|
/* @__PURE__ */ jsx(Heading, {
|
|
4931
|
-
right: `${list.length} program${list.length === 1 ? "" : "s"}`,
|
|
4956
|
+
right: `${list.length} program${list.length === 1 ? "" : "s"}${split.done.length > 0 ? ` · ${split.done.length} done ${showDone ? "shown" : "hidden"} (D)` : ""}`,
|
|
4932
4957
|
children: "Programs"
|
|
4933
4958
|
}),
|
|
4934
4959
|
list.length === 0 ? /* @__PURE__ */ jsx(Text, {
|
|
4935
4960
|
color: theme.muted,
|
|
4936
|
-
children: "None yet. n adds one."
|
|
4961
|
+
children: split.done.length > 0 ? "Every program here is done. D shows them." : "None yet. n adds one."
|
|
4937
4962
|
}) : null,
|
|
4938
4963
|
windowOf(list, at, height - 5).items.map((p) => {
|
|
4939
4964
|
const on = active && p.id === program?.id;
|
|
@@ -5762,7 +5787,7 @@ function DueField({ initial, today, onSubmit, onCancel }) {
|
|
|
5762
5787
|
children: /* @__PURE__ */ jsx(TextField, {
|
|
5763
5788
|
label: "Due",
|
|
5764
5789
|
initial,
|
|
5765
|
-
placeholder: "
|
|
5790
|
+
placeholder: "YYYY-MM-DD, today, tomorrow, +3, none",
|
|
5766
5791
|
hint: error ?? "A date, today, tomorrow, +3 for three days on, or none",
|
|
5767
5792
|
onCancel,
|
|
5768
5793
|
onSubmit: (text) => {
|
package/lib/cli.mjs
CHANGED
|
@@ -16,7 +16,15 @@ import {
|
|
|
16
16
|
writeCredentials,
|
|
17
17
|
writeSettings,
|
|
18
18
|
} from "./config.mjs";
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
harbors,
|
|
21
|
+
myWork,
|
|
22
|
+
programTree,
|
|
23
|
+
projectList,
|
|
24
|
+
taskLine,
|
|
25
|
+
taskList,
|
|
26
|
+
todayLocal,
|
|
27
|
+
} from "./format.mjs";
|
|
20
28
|
import { colourDepth, logoLines } from "./logo.mjs";
|
|
21
29
|
import { HarborError, createClient } from "./mcp.mjs";
|
|
22
30
|
import { jsonText, plain } from "./plain.mjs";
|
|
@@ -32,11 +40,17 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
32
40
|
harbor theme [auto|light|dark] the app's colours
|
|
33
41
|
harbor --version which version this is
|
|
34
42
|
|
|
43
|
+
harbor programs programs and their projects
|
|
44
|
+
harbor projects [words] [--program P] projects with health and open work
|
|
35
45
|
harbor mine what's on you, late first
|
|
36
46
|
harbor tasks [words] [--project P] [--assignee me|none|NAME]
|
|
37
47
|
[--late] [--status S,S]
|
|
38
48
|
harbor show TASK one task (TASK: a key like RR-42)
|
|
39
49
|
|
|
50
|
+
harbor program "NAME" [--portfolio P] [--lead me|NAME] [--starts DATE]
|
|
51
|
+
[--ends DATE]
|
|
52
|
+
harbor project "NAME" --program P [--lead me|NAME] [--starts DATE]
|
|
53
|
+
[--ends DATE] [--budget N]
|
|
40
54
|
harbor add "TITLE" --project P [--assignee me|NAME] [--due DATE]
|
|
41
55
|
[--priority low|medium|high|urgent] [--status S]
|
|
42
56
|
[--over-limit]
|
|
@@ -147,6 +161,12 @@ const FLAG = {
|
|
|
147
161
|
title: "--title",
|
|
148
162
|
project: "--project",
|
|
149
163
|
program: "--program",
|
|
164
|
+
portfolio: "--portfolio",
|
|
165
|
+
lead: "--lead",
|
|
166
|
+
starts: "--starts",
|
|
167
|
+
ends: "--ends",
|
|
168
|
+
budget: "--budget",
|
|
169
|
+
name: "the name",
|
|
150
170
|
owner: "--owner",
|
|
151
171
|
impact: "--impact",
|
|
152
172
|
probability: "--probability",
|
|
@@ -502,6 +522,69 @@ export async function run(argv, io = {}) {
|
|
|
502
522
|
out(plain(`Commands now use ${hits[0].name}.`));
|
|
503
523
|
return 0;
|
|
504
524
|
}
|
|
525
|
+
case "programs": {
|
|
526
|
+
const { positionals, harbor } = parse(spec());
|
|
527
|
+
none(positionals);
|
|
528
|
+
return call("harbor_overview", { harbor }, programTree);
|
|
529
|
+
}
|
|
530
|
+
case "projects": {
|
|
531
|
+
const { positionals, values, harbor } = parse(spec([], ["program"]));
|
|
532
|
+
return call(
|
|
533
|
+
"list_projects",
|
|
534
|
+
{
|
|
535
|
+
harbor,
|
|
536
|
+
query: positionals.join(" ") || undefined,
|
|
537
|
+
program: values.program,
|
|
538
|
+
limit: 100,
|
|
539
|
+
},
|
|
540
|
+
projectList,
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
case "program": {
|
|
544
|
+
const { positionals, values, harbor } = parse(
|
|
545
|
+
spec([], ["portfolio", "lead", "starts", "ends"]),
|
|
546
|
+
);
|
|
547
|
+
return call(
|
|
548
|
+
"create_program",
|
|
549
|
+
{
|
|
550
|
+
harbor,
|
|
551
|
+
name: text(positionals, "the program's name"),
|
|
552
|
+
portfolio: values.portfolio,
|
|
553
|
+
lead: values.lead,
|
|
554
|
+
starts: dueDate(values.starts),
|
|
555
|
+
ends: dueDate(values.ends),
|
|
556
|
+
},
|
|
557
|
+
(r) =>
|
|
558
|
+
`Opened the program ${r.created.name} (${r.created.starts} to ${r.created.ends}, lead ${r.created.lead ?? "nobody"}).`,
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
case "project": {
|
|
562
|
+
const { positionals, values, harbor } = parse(
|
|
563
|
+
spec([], ["program", "lead", "starts", "ends", "budget"]),
|
|
564
|
+
);
|
|
565
|
+
if (!values.program) throw new HarborError("Say which program: --program NAME.");
|
|
566
|
+
let budget;
|
|
567
|
+
if (values.budget !== undefined) {
|
|
568
|
+
budget = Number(values.budget.replace(/[,_$£€]/g, ""));
|
|
569
|
+
if (!Number.isFinite(budget) || budget < 0) {
|
|
570
|
+
throw new HarborError("--budget is a number, like 25000.");
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return call(
|
|
574
|
+
"create_project",
|
|
575
|
+
{
|
|
576
|
+
harbor,
|
|
577
|
+
name: text(positionals, "the project's name"),
|
|
578
|
+
program: values.program,
|
|
579
|
+
lead: values.lead,
|
|
580
|
+
starts: dueDate(values.starts),
|
|
581
|
+
ends: dueDate(values.ends),
|
|
582
|
+
budget,
|
|
583
|
+
},
|
|
584
|
+
(r) =>
|
|
585
|
+
`Opened the project ${r.created.name} in ${r.created.program} (${r.created.starts} to ${r.created.ends}, lead ${r.created.lead ?? "nobody"}). Add work with harbor add "TITLE" --project "${r.created.name}".`,
|
|
586
|
+
);
|
|
587
|
+
}
|
|
505
588
|
case "mine": {
|
|
506
589
|
const { positionals, harbor } = parse(spec());
|
|
507
590
|
none(positionals);
|
package/lib/format.mjs
CHANGED
|
@@ -69,6 +69,58 @@ export function taskList(r, opts) {
|
|
|
69
69
|
return r.tasks.map((t) => taskLine(t, opts)).join("\n") + more;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
const HEALTH = { "on-track": "on track", "at-risk": "at risk", "off-track": "off track" };
|
|
73
|
+
const health = (h) => HEALTH[h] ?? h ?? null;
|
|
74
|
+
|
|
75
|
+
/** Each portfolio's programs and their projects, indented, as harbor_overview gives them. */
|
|
76
|
+
export function programTree(r) {
|
|
77
|
+
const portfolios = r.portfolios ?? [];
|
|
78
|
+
if (!portfolios.some((pf) => pf.programs.length)) {
|
|
79
|
+
return 'No programs yet. Open one with harbor program "NAME".';
|
|
80
|
+
}
|
|
81
|
+
const out = [];
|
|
82
|
+
for (const pf of portfolios) {
|
|
83
|
+
if (portfolios.length > 1) out.push(pf.name);
|
|
84
|
+
const pad = portfolios.length > 1 ? " " : "";
|
|
85
|
+
for (const pg of pf.programs) {
|
|
86
|
+
const bits = [health(pg.health), pg.ends ? `ends ${pg.ends}` : null].filter(Boolean);
|
|
87
|
+
out.push(`${pad}${pg.name} (${bits.join(" · ")})`);
|
|
88
|
+
for (const pj of pg.projects) {
|
|
89
|
+
const work = [
|
|
90
|
+
health(pj.health),
|
|
91
|
+
`${pj.openTasks} open`,
|
|
92
|
+
pj.lateTasks ? `${pj.lateTasks} late` : null,
|
|
93
|
+
pj.ends ? `ends ${pj.ends}` : null,
|
|
94
|
+
].filter(Boolean);
|
|
95
|
+
out.push(`${pad} ${pj.name} (${work.join(" · ")})`);
|
|
96
|
+
}
|
|
97
|
+
if (!pg.projects.length) out.push(`${pad} no projects yet`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return out.join("\n");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Projects one per line, name first so it can be passed to --project. */
|
|
104
|
+
export function projectList(r) {
|
|
105
|
+
if (!r.projects?.length) return "No projects match.";
|
|
106
|
+
const more = r.total > r.projects.length ? `\n…and ${r.total - r.projects.length} more.` : "";
|
|
107
|
+
return (
|
|
108
|
+
r.projects
|
|
109
|
+
.map((p) => {
|
|
110
|
+
const late = p.tasks.late ? `, ${p.tasks.late} late` : "";
|
|
111
|
+
const bits = [
|
|
112
|
+
p.program,
|
|
113
|
+
health(p.health),
|
|
114
|
+
`${p.tasks.total - p.tasks.done} open${late}`,
|
|
115
|
+
p.owner,
|
|
116
|
+
p.ends ? `ends ${p.ends}` : null,
|
|
117
|
+
].filter(Boolean);
|
|
118
|
+
return `${p.name} (${bits.join(" · ")})`;
|
|
119
|
+
})
|
|
120
|
+
.join("\n") + more
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
72
124
|
export function harbors(r) {
|
|
73
125
|
const list = r.harbors ?? [];
|
|
74
126
|
if (!list.length) return "You aren't in any harbor yet. Run harbor to make one.";
|