@harborppm/cli 0.52.0 → 0.54.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 +14 -1
- package/dist/app.mjs +21047 -18840
- package/lib/cli.mjs +204 -11
- package/lib/format.mjs +73 -1
- package/package.json +1 -1
package/lib/cli.mjs
CHANGED
|
@@ -35,6 +35,8 @@ import {
|
|
|
35
35
|
notesText,
|
|
36
36
|
daysText,
|
|
37
37
|
soundingText,
|
|
38
|
+
lifeboatsText,
|
|
39
|
+
followedText,
|
|
38
40
|
soundingsText,
|
|
39
41
|
voyageText,
|
|
40
42
|
whyText,
|
|
@@ -112,6 +114,10 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
112
114
|
[--off]
|
|
113
115
|
a project's public roadmap: read,
|
|
114
116
|
publish or take it down
|
|
117
|
+
harbor shore PROGRAM [--url URL] [--on KINDS] [--off KINDS] [--test]
|
|
118
|
+
[--remove]
|
|
119
|
+
a program's chat channel (Slack,
|
|
120
|
+
Discord, Teams) and its posts
|
|
115
121
|
|
|
116
122
|
harbor init [PROJECT] [--hook] [--dry-run] keep a project's tasks as files in
|
|
117
123
|
this repository (.harbor/), one
|
|
@@ -125,7 +131,9 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
125
131
|
harbor program "NAME" [--portfolio P] [--lead me|NAME] [--starts DATE]
|
|
126
132
|
[--ends DATE]
|
|
127
133
|
harbor project "NAME" --program P [--lead me|NAME] [--starts DATE]
|
|
128
|
-
[--ends DATE] [--budget N]
|
|
134
|
+
[--ends DATE] [--budget N] [--from "DONE PROJECT"]
|
|
135
|
+
--from: plan it from a finished
|
|
136
|
+
project as that one really ran
|
|
129
137
|
harbor add "TITLE" --project P [--assignee me|none|NAME] [--due DATE]
|
|
130
138
|
[--priority low|medium|high|urgent] [--status S]
|
|
131
139
|
[--over-limit]
|
|
@@ -155,12 +163,17 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
155
163
|
[--title T] [--description T] [--mitigation T]
|
|
156
164
|
[--contingency T] [--resolution T] [--rationale T]
|
|
157
165
|
[--notes T] [--turned-down T] [--look-again DATE|none]
|
|
166
|
+
[--still-stands WHY]
|
|
158
167
|
one risk, issue or decision in
|
|
159
168
|
full, or change it
|
|
160
169
|
harbor review [WORDS] [--mine|--owner NAME] [--portfolio P] [--done]
|
|
161
170
|
the review: programs worst first,
|
|
162
171
|
each with its next move (reads
|
|
163
172
|
only; harbor submit moves a task)
|
|
173
|
+
harbor bridge [--harbor H] run the review as a meeting: one
|
|
174
|
+
project a screen, what changed
|
|
175
|
+
since the last one, decisions,
|
|
176
|
+
actions and risks logged live
|
|
164
177
|
harbor next-move PROGRAM "TEXT" write a program's next move
|
|
165
178
|
harbor health --program P|--project P|--portfolio P STATE [--note WHY]
|
|
166
179
|
set its health by hand, or auto
|
|
@@ -197,6 +210,10 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
197
210
|
weeks, one at a time: still
|
|
198
211
|
needed, a new date, or drop it
|
|
199
212
|
harbor fog keep TASK it's still needed: out of the fog
|
|
213
|
+
harbor lifeboats [--program P] work only one person knows, each
|
|
214
|
+
with a second pair of hands
|
|
215
|
+
harbor lifeboats N [--person NAME] adds that second pair of hands
|
|
216
|
+
harbor follow TASK|RISK --person NAME someone follows it too
|
|
200
217
|
harbor agent [--agent NAME] the tasks given to an AI agent on
|
|
201
218
|
the crew, in the order to do them
|
|
202
219
|
harbor agent report TASK "WHAT YOU DID" [--start] [--agent NAME]
|
|
@@ -528,6 +545,23 @@ const DETAIL = {
|
|
|
528
545
|
" PROJECT is a name, a key like RR, or an id. When two projects share a",
|
|
529
546
|
" name, give the key.",
|
|
530
547
|
],
|
|
548
|
+
shore: [
|
|
549
|
+
" harbor shore PROGRAM reads the program's chat channel and",
|
|
550
|
+
" which posts go there",
|
|
551
|
+
" harbor shore PROGRAM --url URL writes: posts go to this Slack, Discord",
|
|
552
|
+
" or Teams incoming-webhook address",
|
|
553
|
+
" harbor shore PROGRAM --on KINDS writes: turns posts on; KINDS are",
|
|
554
|
+
" forecast, health and notes, with commas",
|
|
555
|
+
" harbor shore PROGRAM --off KINDS writes: turns them off",
|
|
556
|
+
" harbor shore PROGRAM --test writes: sends a test post",
|
|
557
|
+
" harbor shore PROGRAM --remove writes: stops posting and forgets the",
|
|
558
|
+
" address",
|
|
559
|
+
"",
|
|
560
|
+
" forecast is the morning harbor forecast, health a line when the program or",
|
|
561
|
+
" one of its projects changes health, notes a note for later when it opens.",
|
|
562
|
+
" Every post links back to Harbor. A new channel posts nothing until you turn",
|
|
563
|
+
" something on. The address is a secret: once saved, only a hint of it shows.",
|
|
564
|
+
],
|
|
531
565
|
roadmap: [
|
|
532
566
|
" harbor roadmap PROJECT reads whether it's public, and its link",
|
|
533
567
|
" harbor roadmap PROJECT --publish writes: publishes it, or keeps it live",
|
|
@@ -694,6 +728,20 @@ const DETAIL = {
|
|
|
694
728
|
" Piped, or with --json, it lists. The one-at-a-time sweep needs full access",
|
|
695
729
|
" (harbor login without --read-only), since dropping a task deletes it.",
|
|
696
730
|
],
|
|
731
|
+
lifeboats: [
|
|
732
|
+
" harbor lifeboats [--program P] reads: work only one person has",
|
|
733
|
+
" touched (a project, their corner of",
|
|
734
|
+
" one, a task others wait on, their",
|
|
735
|
+
" serious risks), numbered, each with",
|
|
736
|
+
" someone who has room to take a look",
|
|
737
|
+
" harbor lifeboats N [--person NAME] writes: puts that person (or NAME)",
|
|
738
|
+
" on line N as a second pair of hands",
|
|
739
|
+
],
|
|
740
|
+
follow: [
|
|
741
|
+
" harbor follow TASK --person NAME writes: NAME follows the task too,",
|
|
742
|
+
" without taking it over",
|
|
743
|
+
' harbor follow "RISK" --person NAME the same for a risk or issue',
|
|
744
|
+
],
|
|
697
745
|
handback: [
|
|
698
746
|
" harbor handback PERSON writes, straight away: gives back",
|
|
699
747
|
" what was handed over for their time",
|
|
@@ -758,6 +806,10 @@ const DETAIL = {
|
|
|
758
806
|
" today unless --decided-by or",
|
|
759
807
|
" --decided-on say otherwise)",
|
|
760
808
|
' harbor raid "TITLE" --update "TEXT" writes: adds a line to its updates',
|
|
809
|
+
' harbor raid "TITLE" --still-stands "WHY"',
|
|
810
|
+
" writes: a decision looked at again",
|
|
811
|
+
" still stands (\"\" for no reason);",
|
|
812
|
+
" clears its look again",
|
|
761
813
|
"",
|
|
762
814
|
" Also --owner me|none|NAME, --impact 1-5, --probability 1-5 (risks),",
|
|
763
815
|
" --due DATE|none (review by, or decide by), --title, --description,",
|
|
@@ -767,6 +819,20 @@ const DETAIL = {
|
|
|
767
819
|
" enough of it to pick one out; harbor risks lists them. Changes several",
|
|
768
820
|
" at once, as one change.",
|
|
769
821
|
],
|
|
822
|
+
bridge: [
|
|
823
|
+
" harbor bridge opens the full-screen app on the",
|
|
824
|
+
" review meeting (b on the Programs",
|
|
825
|
+
" screen, or : Run the review meeting)",
|
|
826
|
+
"",
|
|
827
|
+
" One open project a screen, with only what changed since its program was",
|
|
828
|
+
" last reviewed (a week back the first time): health moves, work finished,",
|
|
829
|
+
" newly late or added, risks raised, decisions made. ←→ move between",
|
|
830
|
+
" projects; d logs a decision, a an action (who takes it, due in a week),",
|
|
831
|
+
" r a risk, m the program's next move; e ends it. The summary lists what",
|
|
832
|
+
" was logged; p posts it to each program's chat channel where one is set",
|
|
833
|
+
" (Ship to shore, set on the website), y copies it. The next meeting",
|
|
834
|
+
" counts from when this one ended.",
|
|
835
|
+
],
|
|
770
836
|
review: [
|
|
771
837
|
" harbor review reads the review: every program,",
|
|
772
838
|
" worst health first, then soonest end,",
|
|
@@ -990,7 +1056,7 @@ export async function run(argv, io = {}) {
|
|
|
990
1056
|
}
|
|
991
1057
|
|
|
992
1058
|
// On its own in a terminal: the full-screen app. Piped or scripted: the help.
|
|
993
|
-
if (!command || command === "app") {
|
|
1059
|
+
if (!command || command === "app" || command === "bridge") {
|
|
994
1060
|
const terminal = io.terminal ?? Boolean(process.stdout.isTTY && process.stdin.isTTY);
|
|
995
1061
|
if (!command && !terminal) {
|
|
996
1062
|
out(HELP);
|
|
@@ -999,7 +1065,7 @@ export async function run(argv, io = {}) {
|
|
|
999
1065
|
if (!terminal) throw new HarborError("The full-screen app needs a terminal.");
|
|
1000
1066
|
const { values } = parseArgs(rest, { flags: [], options: ["harbor", "server"] });
|
|
1001
1067
|
// In a folder with a local plan (harbor init --local), the app opens on it.
|
|
1002
|
-
if (!values.harbor && !values.server) {
|
|
1068
|
+
if (!values.harbor && !values.server && command !== "bridge") {
|
|
1003
1069
|
const bundled = await loadApp();
|
|
1004
1070
|
const dir = await bundled.localPlanAt(io.cwd ?? process.cwd());
|
|
1005
1071
|
if (dir) {
|
|
@@ -1056,6 +1122,7 @@ export async function run(argv, io = {}) {
|
|
|
1056
1122
|
},
|
|
1057
1123
|
theme: (await readSettings(env)).theme,
|
|
1058
1124
|
browse: io.browse ?? openBrowser,
|
|
1125
|
+
...(command === "bridge" ? { startIn: "bridge" } : {}),
|
|
1059
1126
|
});
|
|
1060
1127
|
return 0;
|
|
1061
1128
|
}
|
|
@@ -1301,6 +1368,12 @@ export async function run(argv, io = {}) {
|
|
|
1301
1368
|
`⚓ Saved on ${result.project.name}. It's on the Forecast tab under Open dry dock; --apply makes it real.`,
|
|
1302
1369
|
),
|
|
1303
1370
|
);
|
|
1371
|
+
else if (result.drops.length)
|
|
1372
|
+
out(
|
|
1373
|
+
plain(
|
|
1374
|
+
`Nothing was changed. --apply --confirm would delete ${result.drops.join(", ")}; --save keeps the changes to show someone.`,
|
|
1375
|
+
),
|
|
1376
|
+
);
|
|
1304
1377
|
else if (result.changes.length)
|
|
1305
1378
|
out(
|
|
1306
1379
|
"Nothing was changed. Add --apply to make these changes, or --save to keep them to show someone.",
|
|
@@ -1511,7 +1584,7 @@ export async function run(argv, io = {}) {
|
|
|
1511
1584
|
}
|
|
1512
1585
|
case "project": {
|
|
1513
1586
|
const { positionals, values, harbor } = parse(
|
|
1514
|
-
spec([], ["program", "lead", "starts", "ends", "budget"]),
|
|
1587
|
+
spec([], ["program", "lead", "starts", "ends", "budget", "from"]),
|
|
1515
1588
|
);
|
|
1516
1589
|
if (!values.program) throw new HarborError("Say which program: --program NAME.");
|
|
1517
1590
|
let budget;
|
|
@@ -1531,9 +1604,18 @@ export async function run(argv, io = {}) {
|
|
|
1531
1604
|
starts: dueDate(values.starts),
|
|
1532
1605
|
ends: dueDate(values.ends),
|
|
1533
1606
|
budget,
|
|
1607
|
+
planFrom: values.from,
|
|
1608
|
+
},
|
|
1609
|
+
(r) => {
|
|
1610
|
+
const opened = `Opened the project ${r.created.name} in ${r.created.program} (${r.created.starts} to ${r.created.ends}, lead ${r.created.lead ?? "nobody"}).`;
|
|
1611
|
+
const f = r.plannedFrom;
|
|
1612
|
+
if (!f)
|
|
1613
|
+
return `${opened} Add work with harbor add "TITLE" --project "${r.created.name}".`;
|
|
1614
|
+
const came = f.risks
|
|
1615
|
+
? `, and ${f.risks} risk${f.risks === 1 ? "" : "s"} that came true`
|
|
1616
|
+
: "";
|
|
1617
|
+
return `${opened}\nPlanned from ${f.project}: ${f.tasks} task${f.tasks === 1 ? "" : "s"}, each due when it really finished last time${came}. ${f.says}`;
|
|
1534
1618
|
},
|
|
1535
|
-
(r) =>
|
|
1536
|
-
`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}".`,
|
|
1537
1619
|
);
|
|
1538
1620
|
}
|
|
1539
1621
|
case "forecast": {
|
|
@@ -1798,6 +1880,75 @@ export async function run(argv, io = {}) {
|
|
|
1798
1880
|
);
|
|
1799
1881
|
return 0;
|
|
1800
1882
|
}
|
|
1883
|
+
case "shore": {
|
|
1884
|
+
const { positionals, values, harbor } = parse(spec(["test", "remove"], ["url", "on", "off"]));
|
|
1885
|
+
const program = text(positionals, "which program");
|
|
1886
|
+
const kinds = (flag) => {
|
|
1887
|
+
if (values[flag] === undefined) return undefined;
|
|
1888
|
+
const list = values[flag]
|
|
1889
|
+
.split(",")
|
|
1890
|
+
.map((k) => k.trim().toLowerCase())
|
|
1891
|
+
.filter(Boolean);
|
|
1892
|
+
const bad = list.find((k) => !["forecast", "health", "notes"].includes(k));
|
|
1893
|
+
if (bad || !list.length)
|
|
1894
|
+
throw new HarborError(`--${flag} takes forecast, health or notes, with commas between.`);
|
|
1895
|
+
return list;
|
|
1896
|
+
};
|
|
1897
|
+
const on = kinds("on");
|
|
1898
|
+
const off = kinds("off");
|
|
1899
|
+
if (on && off && on.some((k) => off.includes(k)))
|
|
1900
|
+
throw new HarborError("Say each kind of post once, with --on or --off.");
|
|
1901
|
+
if (values.remove && (values.url || on || off || values.test))
|
|
1902
|
+
throw new HarborError("--remove goes on its own.");
|
|
1903
|
+
if (!hasFullAccess(creds)) {
|
|
1904
|
+
throw new HarborError("Chat channels need full access. Run harbor login, then try again.");
|
|
1905
|
+
}
|
|
1906
|
+
const r = await client.app({
|
|
1907
|
+
op: "shore",
|
|
1908
|
+
...(harbor ? { harbor } : {}),
|
|
1909
|
+
program,
|
|
1910
|
+
...(values.url ? { url: values.url } : {}),
|
|
1911
|
+
...(on ? { on } : {}),
|
|
1912
|
+
...(off ? { off } : {}),
|
|
1913
|
+
...(values.test ? { test: true } : {}),
|
|
1914
|
+
...(values.remove ? { remove: true } : {}),
|
|
1915
|
+
});
|
|
1916
|
+
if (asJson) {
|
|
1917
|
+
out(jsonText(r));
|
|
1918
|
+
return 0;
|
|
1919
|
+
}
|
|
1920
|
+
if (!r.hook) {
|
|
1921
|
+
out(
|
|
1922
|
+
plain(
|
|
1923
|
+
r.removed
|
|
1924
|
+
? `${r.program} doesn't post to a chat channel any more.`
|
|
1925
|
+
: `${r.program} has no chat channel. harbor shore "${r.program}" --url URL --on forecast,health adds one: make an incoming webhook in the channel's settings and paste its address.`,
|
|
1926
|
+
),
|
|
1927
|
+
);
|
|
1928
|
+
return 0;
|
|
1929
|
+
}
|
|
1930
|
+
const label = {
|
|
1931
|
+
forecast: "morning forecast",
|
|
1932
|
+
health: "health changes",
|
|
1933
|
+
notes: "notes when they open",
|
|
1934
|
+
};
|
|
1935
|
+
const posts = ["forecast", "health", "notes"].filter((k) => r.hook[k]).map((k) => label[k]);
|
|
1936
|
+
const lines = [
|
|
1937
|
+
`${r.program} posts to ${r.hook.service} (${r.hook.hint}): ${posts.length ? posts.join(", ") : "nothing yet; --on forecast,health,notes turns posts on"}.`,
|
|
1938
|
+
];
|
|
1939
|
+
if (r.hook.failures > 0)
|
|
1940
|
+
lines.push(
|
|
1941
|
+
`The last ${r.hook.failures} post${r.hook.failures === 1 ? "" : "s"} didn't go through. Check the address, or --url swaps it.`,
|
|
1942
|
+
);
|
|
1943
|
+
if ("tested" in r)
|
|
1944
|
+
lines.push(
|
|
1945
|
+
r.tested
|
|
1946
|
+
? "Sent a test post."
|
|
1947
|
+
: "The channel didn't take the test post. Check the address.",
|
|
1948
|
+
);
|
|
1949
|
+
out(plain(lines.join("\n")));
|
|
1950
|
+
return 0;
|
|
1951
|
+
}
|
|
1801
1952
|
case "add": {
|
|
1802
1953
|
const { positionals, values, harbor } = parse(
|
|
1803
1954
|
spec(["over-limit"], ["project", "assignee", "due", "priority", "status"]),
|
|
@@ -2234,11 +2385,7 @@ export async function run(argv, io = {}) {
|
|
|
2234
2385
|
await stalePromptCache(env);
|
|
2235
2386
|
return 0;
|
|
2236
2387
|
}
|
|
2237
|
-
return call(
|
|
2238
|
-
"fog",
|
|
2239
|
-
{ harbor, project: values.project, limit: 100 },
|
|
2240
|
-
(r) => fogText(r),
|
|
2241
|
-
);
|
|
2388
|
+
return call("fog", { harbor, project: values.project, limit: 100 }, (r) => fogText(r));
|
|
2242
2389
|
}
|
|
2243
2390
|
case "handover": {
|
|
2244
2391
|
const { positionals, values, harbor } = parse(
|
|
@@ -2295,6 +2442,50 @@ export async function run(argv, io = {}) {
|
|
|
2295
2442
|
`${r.updated.key ?? r.updated.title} is ${r.updated.status === "review" ? "in review for a person to check" : "in play"}, with ${r.commentedAs}'s note.`,
|
|
2296
2443
|
);
|
|
2297
2444
|
}
|
|
2445
|
+
case "lifeboats": {
|
|
2446
|
+
const { positionals, values, harbor } = parse(spec([], ["program", "person"]));
|
|
2447
|
+
if (positionals.length > 1)
|
|
2448
|
+
throw new HarborError("harbor lifeboats takes one line number: harbor lifeboats 2.");
|
|
2449
|
+
if (!positionals.length) {
|
|
2450
|
+
if (values.person)
|
|
2451
|
+
throw new HarborError("--person goes with a line: harbor lifeboats 2 --person NAME.");
|
|
2452
|
+
return call("lifeboats", { harbor, program: values.program, limit: 100 }, (r) =>
|
|
2453
|
+
lifeboatsText(r),
|
|
2454
|
+
);
|
|
2455
|
+
}
|
|
2456
|
+
const n = Number(positionals[0]);
|
|
2457
|
+
if (!Number.isInteger(n) || n < 1)
|
|
2458
|
+
throw new HarborError("Give a line number from harbor lifeboats, like harbor lifeboats 2.");
|
|
2459
|
+
const list = await client.call("lifeboats", {
|
|
2460
|
+
harbor,
|
|
2461
|
+
program: values.program,
|
|
2462
|
+
limit: 100,
|
|
2463
|
+
});
|
|
2464
|
+
const line = list.lines[n - 1];
|
|
2465
|
+
if (!line)
|
|
2466
|
+
throw new HarborError(
|
|
2467
|
+
list.count
|
|
2468
|
+
? `There ${list.count === 1 ? "is 1 line" : `are ${list.count} lines`}; harbor lifeboats lists them.`
|
|
2469
|
+
: "Nothing only one person knows right now.",
|
|
2470
|
+
);
|
|
2471
|
+
return call("follow_work", { harbor, lifeboat: line.id, person: values.person }, (r) =>
|
|
2472
|
+
followedText(r),
|
|
2473
|
+
);
|
|
2474
|
+
}
|
|
2475
|
+
case "follow": {
|
|
2476
|
+
const { positionals, values, harbor } = parse(spec([], ["person"]));
|
|
2477
|
+
const what = positionals.join(" ").trim();
|
|
2478
|
+
if (!what) throw new HarborError("Say what: harbor follow TASK --person NAME.");
|
|
2479
|
+
if (!values.person)
|
|
2480
|
+
throw new HarborError("Say who: harbor follow TASK --person NAME (or me).");
|
|
2481
|
+
// A task key reads as a task; anything else is a risk's title.
|
|
2482
|
+
const isKey = /^[A-Za-z][A-Za-z0-9]*-\d+$/.test(what);
|
|
2483
|
+
return call(
|
|
2484
|
+
"follow_work",
|
|
2485
|
+
{ harbor, ...(isKey ? { task: what } : { raid: what }), person: values.person },
|
|
2486
|
+
(r) => followedText(r),
|
|
2487
|
+
);
|
|
2488
|
+
}
|
|
2298
2489
|
case "handback": {
|
|
2299
2490
|
const { positionals, harbor } = parse(spec());
|
|
2300
2491
|
const person = positionals.join(" ").trim();
|
|
@@ -2329,6 +2520,7 @@ export async function run(argv, io = {}) {
|
|
|
2329
2520
|
"decided-on",
|
|
2330
2521
|
"turned-down",
|
|
2331
2522
|
"look-again",
|
|
2523
|
+
"still-stands",
|
|
2332
2524
|
...fields,
|
|
2333
2525
|
],
|
|
2334
2526
|
),
|
|
@@ -2350,6 +2542,7 @@ export async function run(argv, io = {}) {
|
|
|
2350
2542
|
revisitOn:
|
|
2351
2543
|
values["look-again"] === undefined ? undefined : nullable(dueDate(values["look-again"])),
|
|
2352
2544
|
update: values.update,
|
|
2545
|
+
stillStands: values["still-stands"],
|
|
2353
2546
|
...Object.fromEntries(fields.map((f) => [f, values[f]])),
|
|
2354
2547
|
};
|
|
2355
2548
|
const changes = Object.entries(args).filter(
|
package/lib/format.mjs
CHANGED
|
@@ -393,11 +393,52 @@ export function forecastList(r) {
|
|
|
393
393
|
const name = p.name.length > width ? `${p.name.slice(0, width - 1)}…` : p.name.padEnd(width);
|
|
394
394
|
if (f.none) return `${name} no forecast: ${f.none.replace(/^\w/, (c) => c.toLowerCase())}`;
|
|
395
395
|
const end = p.ends ? ` · ${chance(f.chanceByEnd)} by its end, ${p.ends}` : "";
|
|
396
|
-
|
|
396
|
+
const slip =
|
|
397
|
+
f.costOfSlip?.atLikely >= 100 ? `late adds about ${dollars(f.costOfSlip.atLikely)}` : null;
|
|
398
|
+
return [`${name} ${finishRange(f)}${end}`, ...finishNotes(f), slip]
|
|
399
|
+
.filter(Boolean)
|
|
400
|
+
.join(" · ");
|
|
397
401
|
})
|
|
398
402
|
.join("\n");
|
|
399
403
|
}
|
|
400
404
|
|
|
405
|
+
/** Money to the nearest hundred, as "$4,800" (src/lib/slip-cost.ts's dollars). */
|
|
406
|
+
function dollars(n) {
|
|
407
|
+
const r = Math.round(Math.abs(n) / 100) * 100;
|
|
408
|
+
return `${n < 0 && r > 0 ? "-" : ""}$${r.toLocaleString("en-US")}`;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* The cost of a slip, as the Forecast tab says it: what each week late
|
|
413
|
+
* costs at the project's recent spend, what the likely and very likely
|
|
414
|
+
* dates add, and where the 85% date leaves the budget. Empty when the
|
|
415
|
+
* project has no budget or recent spend.
|
|
416
|
+
*/
|
|
417
|
+
function slipLines(f) {
|
|
418
|
+
const c = f.costOfSlip;
|
|
419
|
+
if (!c || c.perWeekLate < 50) return [];
|
|
420
|
+
const lines = [
|
|
421
|
+
`Each week late costs about ${dollars(c.perWeekLate)}, from the spend ledger's last eight weeks.`,
|
|
422
|
+
];
|
|
423
|
+
const adds = [
|
|
424
|
+
c.atLikely >= 100 ? `finishing ${f.likely} adds about ${dollars(c.atLikely)}` : null,
|
|
425
|
+
c.atVeryLikely >= 100 && f.veryLikely !== f.likely
|
|
426
|
+
? `${f.veryLikely} about ${dollars(c.atVeryLikely)}`
|
|
427
|
+
: null,
|
|
428
|
+
].filter(Boolean);
|
|
429
|
+
if (adds.length) lines.push(`Past its end date, ${adds.join("; ")}.`);
|
|
430
|
+
const over = c.overBudgetAtVeryLikely;
|
|
431
|
+
if (over !== undefined && f.veryLikely)
|
|
432
|
+
lines.push(
|
|
433
|
+
Math.abs(over) < 100
|
|
434
|
+
? `By ${f.veryLikely} it would use about the whole budget.`
|
|
435
|
+
: over > 0
|
|
436
|
+
? `By ${f.veryLikely} it would end about ${dollars(over)} over budget.`
|
|
437
|
+
: `By ${f.veryLikely} it would still be about ${dollars(-over)} under budget.`,
|
|
438
|
+
);
|
|
439
|
+
return lines;
|
|
440
|
+
}
|
|
441
|
+
|
|
401
442
|
/** One project's forecast, in a few sentences. */
|
|
402
443
|
export function forecastOne(p) {
|
|
403
444
|
const f = p.finish ?? {};
|
|
@@ -426,6 +467,7 @@ export function forecastOne(p) {
|
|
|
426
467
|
`${which} is due ${f.lastDue.due}, later than the crew's pace alone would finish, so the dates wait for it.`,
|
|
427
468
|
);
|
|
428
469
|
}
|
|
470
|
+
lines.push(...slipLines(f));
|
|
429
471
|
return lines.join("\n");
|
|
430
472
|
}
|
|
431
473
|
|
|
@@ -835,3 +877,33 @@ export function fogText(r, { sweepHint = true } = {}) {
|
|
|
835
877
|
);
|
|
836
878
|
return lines.join("\n");
|
|
837
879
|
}
|
|
880
|
+
|
|
881
|
+
/** harbor lifeboats: work only one person knows, numbered for harbor lifeboats N. */
|
|
882
|
+
export function lifeboatsText(r) {
|
|
883
|
+
const where = r.program ? ` in ${r.program}` : "";
|
|
884
|
+
if (!r.count) return `Nothing${where} that only one person knows. Good.`;
|
|
885
|
+
const lines = [
|
|
886
|
+
`${r.count} ${r.count === 1 ? "thing" : "things"} only one person knows${where}: nobody else has been assigned, commented or written in ${r.count === 1 ? "it" : "them"}.`,
|
|
887
|
+
"",
|
|
888
|
+
];
|
|
889
|
+
r.lines.forEach((l, n) => {
|
|
890
|
+
lines.push(` ${String(n + 1).padStart(2)}. ${l.what}`);
|
|
891
|
+
const bits = [`only ${l.onlyKnownBy}`, l.detail, l.due ? `due ${l.due}` : null];
|
|
892
|
+
lines.push(` ${bits.filter(Boolean).join(" · ")}`);
|
|
893
|
+
if (l.suggest)
|
|
894
|
+
lines.push(` ${l.suggest.person} could take a second look: ${l.suggest.why}`);
|
|
895
|
+
});
|
|
896
|
+
if (r.more) lines.push("", `And ${r.count - r.lines.length} more.`);
|
|
897
|
+
for (const a of r.away ?? []) lines.push("", a);
|
|
898
|
+
lines.push(
|
|
899
|
+
"",
|
|
900
|
+
"harbor lifeboats N adds the suggested second pair of hands (--person NAME for someone else).",
|
|
901
|
+
);
|
|
902
|
+
return lines.join("\n");
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/** What follow_work did. */
|
|
906
|
+
export function followedText(r) {
|
|
907
|
+
const f = r.followed;
|
|
908
|
+
return `${f.person} is a second pair of hands on ${f.what}: they follow it now, and it stays with whoever has it.`;
|
|
909
|
+
}
|