@harborppm/cli 0.36.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 +35 -2
- package/dist/app.mjs +4091 -898
- package/lib/cli.mjs +223 -2
- package/lib/format.mjs +70 -4
- package/package.json +1 -1
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,
|
|
@@ -86,6 +88,14 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
86
88
|
harbor roadmap PROJECT [--dates|--no-dates] [--off]
|
|
87
89
|
publish a project's public roadmap
|
|
88
90
|
|
|
91
|
+
harbor init [PROJECT] [--hook] keep a project's tasks as files in
|
|
92
|
+
this repository (.harbor/), one
|
|
93
|
+
per task, in step both ways
|
|
94
|
+
harbor init --local [NAME] a plan in this folder alone, no
|
|
95
|
+
account; harbor here opens it
|
|
96
|
+
harbor sync [--quiet] send the files' edits to Harbor
|
|
97
|
+
and bring its changes back
|
|
98
|
+
|
|
89
99
|
harbor program "NAME" [--portfolio P] [--lead me|NAME] [--starts DATE]
|
|
90
100
|
[--ends DATE]
|
|
91
101
|
harbor project "NAME" --program P [--lead me|NAME] [--starts DATE]
|
|
@@ -114,6 +124,10 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
114
124
|
--say reads it aloud as a
|
|
115
125
|
shipping forecast
|
|
116
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
|
|
117
131
|
harbor premortem PROJECT ["WHAT WENT WRONG"] [--start]
|
|
118
132
|
a project's pre-mortem: start,
|
|
119
133
|
answer or read it
|
|
@@ -389,6 +403,30 @@ export function voyageForTerminal(r, project = r.project) {
|
|
|
389
403
|
|
|
390
404
|
/** Commands whose forms do different things: one line each, saying which read and which write. */
|
|
391
405
|
const DETAIL = {
|
|
406
|
+
init: [
|
|
407
|
+
" harbor init [PROJECT] [--hook] writes: .harbor/ at the top of this",
|
|
408
|
+
" repository, one file per task in",
|
|
409
|
+
" PROJECT (a key like RR or a name;",
|
|
410
|
+
" the folder's name when left out)",
|
|
411
|
+
" harbor init --local [NAME] writes: a plan that lives only in",
|
|
412
|
+
" this folder, with no account;",
|
|
413
|
+
" harbor here opens it full screen",
|
|
414
|
+
"",
|
|
415
|
+
" Each file has title, status, owner, due, priority and waits between --- lines,",
|
|
416
|
+
" and the task's notes below. Edit them, add new ones (any name; Harbor gives",
|
|
417
|
+
" them a key), review them in pull requests, then run harbor sync. --hook adds",
|
|
418
|
+
" a git post-commit hook that runs harbor sync after each commit. Run init on",
|
|
419
|
+
" a local plan to move its tasks into PROJECT.",
|
|
420
|
+
],
|
|
421
|
+
sync: [
|
|
422
|
+
" harbor sync [--quiet] writes: sends what the files changed to",
|
|
423
|
+
" Harbor and rewrites the files from it",
|
|
424
|
+
"",
|
|
425
|
+
" A field you changed and nobody changed on the website goes to Harbor; one",
|
|
426
|
+
" changed in both places keeps the website's, and says so. Removing a file",
|
|
427
|
+
" doesn't delete the task (mark it done instead). On a fresh clone, run",
|
|
428
|
+
" harbor sync before editing. --quiet prints only problems.",
|
|
429
|
+
],
|
|
392
430
|
limit: [
|
|
393
431
|
" harbor limit PROJECT reads its board's column limits",
|
|
394
432
|
" harbor limit PROJECT COLUMN N writes: COLUMN takes at most N tasks",
|
|
@@ -412,6 +450,45 @@ const DETAIL = {
|
|
|
412
450
|
" PROJECT is a name, a key like RR, or an id. When two projects share a",
|
|
413
451
|
" name, give the key.",
|
|
414
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
|
+
],
|
|
415
492
|
note: [
|
|
416
493
|
" harbor note PROJECT reads its notes for later: who left",
|
|
417
494
|
" each, what opens it, and the opened ones",
|
|
@@ -475,6 +552,21 @@ const DETAIL = {
|
|
|
475
552
|
" PROJECT is a name, a key like RR, or an id. When two projects share a",
|
|
476
553
|
" name, give the key.",
|
|
477
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
|
+
],
|
|
478
570
|
};
|
|
479
571
|
|
|
480
572
|
/** The lines of the help about one command, or null when it has none. */
|
|
@@ -642,6 +734,15 @@ export async function run(argv, io = {}) {
|
|
|
642
734
|
}
|
|
643
735
|
if (!terminal) throw new HarborError("The full-screen app needs a terminal.");
|
|
644
736
|
const { values } = parseArgs(rest, { flags: [], options: ["harbor", "server"] });
|
|
737
|
+
// In a folder with a local plan (harbor init --local), the app opens on it.
|
|
738
|
+
if (!values.harbor && !values.server) {
|
|
739
|
+
const bundled = await loadApp();
|
|
740
|
+
const dir = await bundled.localPlanAt(io.cwd ?? process.cwd());
|
|
741
|
+
if (dir) {
|
|
742
|
+
await bundled.runLocalApp({ dir, theme: (await readSettings(env)).theme });
|
|
743
|
+
return 0;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
645
746
|
let creds = await readCredentials(env);
|
|
646
747
|
if (!hasFullAccess(creds)) {
|
|
647
748
|
out(
|
|
@@ -840,6 +941,75 @@ export async function run(argv, io = {}) {
|
|
|
840
941
|
return 0;
|
|
841
942
|
}
|
|
842
943
|
|
|
944
|
+
if (command === "init" || command === "sync") {
|
|
945
|
+
const init = command === "init";
|
|
946
|
+
const { values, positionals } = parseArgs(rest, {
|
|
947
|
+
flags: init ? ["local", "hook", "json"] : ["quiet", "json"],
|
|
948
|
+
options: init ? ["harbor"] : [],
|
|
949
|
+
});
|
|
950
|
+
const cwd = io.cwd ?? process.cwd();
|
|
951
|
+
const lines = [];
|
|
952
|
+
const say = values.json
|
|
953
|
+
? (line) => lines.push(line)
|
|
954
|
+
: values.quiet
|
|
955
|
+
? (line) => {
|
|
956
|
+
if (!line.startsWith("⚓")) out(plain(line));
|
|
957
|
+
}
|
|
958
|
+
: (line) => out(plain(line));
|
|
959
|
+
const app = await loadApp();
|
|
960
|
+
if (init && values.local) {
|
|
961
|
+
if (positionals.length > 1)
|
|
962
|
+
throw new HarborError(`harbor init --local takes one name; put it in quotes.`);
|
|
963
|
+
const config = await app.initLocalHere({ cwd, name: positionals[0], say });
|
|
964
|
+
if (values.json) out(jsonText({ plan: config, notes: lines }));
|
|
965
|
+
return 0;
|
|
966
|
+
}
|
|
967
|
+
if (positionals.length > (init ? 1 : 0))
|
|
968
|
+
throw new HarborError(
|
|
969
|
+
init
|
|
970
|
+
? "harbor init takes one project; put a name with spaces in quotes."
|
|
971
|
+
: "harbor sync takes no words; it syncs the plan of the folder you're in.",
|
|
972
|
+
);
|
|
973
|
+
// Plans move through /api/app, as the full-screen app does: full access.
|
|
974
|
+
const transport = async () => {
|
|
975
|
+
const creds = await readCredentials(env);
|
|
976
|
+
if (!hasFullAccess(creds))
|
|
977
|
+
throw new HarborError(
|
|
978
|
+
creds
|
|
979
|
+
? "This sign-in can only read. Run harbor login (without --read-only) to keep plans in step."
|
|
980
|
+
: "Not signed in. Run harbor login, or harbor signup if you're new; harbor init --local needs no account.",
|
|
981
|
+
);
|
|
982
|
+
return app.planTransport(
|
|
983
|
+
tokenSource({ creds, save: (c) => writeCredentials(c, env), fetcher }),
|
|
984
|
+
fetcher,
|
|
985
|
+
);
|
|
986
|
+
};
|
|
987
|
+
if (init) {
|
|
988
|
+
const creds = await readCredentials(env);
|
|
989
|
+
const config = await app.initHere({
|
|
990
|
+
cwd,
|
|
991
|
+
transport: await transport(),
|
|
992
|
+
project: positionals[0],
|
|
993
|
+
hook: Boolean(values.hook),
|
|
994
|
+
say,
|
|
995
|
+
pickHarbor: (list) => {
|
|
996
|
+
if (values.harbor) return app.harborNamed(list, values.harbor);
|
|
997
|
+
const saved = list.find((h) => h.id === creds?.harbor);
|
|
998
|
+
if (saved) return saved;
|
|
999
|
+
if (list.length === 1) return list[0];
|
|
1000
|
+
throw new HarborError(
|
|
1001
|
+
`You're in ${list.length} harbors (${list.map((h) => h.name).join(", ")}). Add --harbor NAME, or run harbor use NAME first.`,
|
|
1002
|
+
);
|
|
1003
|
+
},
|
|
1004
|
+
});
|
|
1005
|
+
if (values.json) out(jsonText({ plan: config, notes: lines }));
|
|
1006
|
+
return 0;
|
|
1007
|
+
}
|
|
1008
|
+
const report = await app.syncHere({ cwd, transport, say });
|
|
1009
|
+
if (values.json) out(jsonText({ ...(report ?? { local: true }), notes: lines }));
|
|
1010
|
+
return 0;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
843
1013
|
// A mistyped command says so, signed in or not.
|
|
844
1014
|
if (commandHelp(command) === null) {
|
|
845
1015
|
throw new HarborError(noCommand(command));
|
|
@@ -984,8 +1154,21 @@ export async function run(argv, io = {}) {
|
|
|
984
1154
|
case "forecast": {
|
|
985
1155
|
const { positionals, values, harbor } = parse(spec([], ["program"]));
|
|
986
1156
|
const project = positionals.join(" ").trim();
|
|
987
|
-
|
|
988
|
-
|
|
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
|
+
);
|
|
989
1172
|
}
|
|
990
1173
|
case "hill": {
|
|
991
1174
|
const { positionals, values, harbor } = parse(spec([], ["program", "at"]));
|
|
@@ -1365,6 +1548,44 @@ export async function run(argv, io = {}) {
|
|
|
1365
1548
|
if (!program) throw new HarborError('Give a program: harbor report "NAME".');
|
|
1366
1549
|
return call("status_report", { harbor, program }, (r) => r.markdown);
|
|
1367
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
|
+
}
|
|
1368
1589
|
case "premortem": {
|
|
1369
1590
|
const { positionals, values, harbor } = parse(spec(["start"]));
|
|
1370
1591
|
const [project, ...words] = positionals;
|
package/lib/format.mjs
CHANGED
|
@@ -216,9 +216,32 @@ function chance(c) {
|
|
|
216
216
|
return `${Math.round(c * 20) * 5}%`;
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
+
/**
|
|
220
|
+
* The forecast's dates as phrases, a date said once: when half and 85% of
|
|
221
|
+
* runs finish the same day, "very likely by" it is. At most `most` of them.
|
|
222
|
+
*/
|
|
223
|
+
function finishPhrases(f, most = 3) {
|
|
224
|
+
const steps = [
|
|
225
|
+
[f.likely, "probably"],
|
|
226
|
+
[f.veryLikely, "very likely by"],
|
|
227
|
+
[f.almostCertain, "almost certainly by"],
|
|
228
|
+
].filter(([date]) => date);
|
|
229
|
+
const kept = steps.filter(([date], i) => date !== steps[i + 1]?.[0]);
|
|
230
|
+
return kept.slice(0, most).map(([date, words]) => `${words} ${date}`);
|
|
231
|
+
}
|
|
232
|
+
|
|
219
233
|
function finishRange(f) {
|
|
220
234
|
if (!f.likely) return "not in the next two years at this pace";
|
|
221
|
-
return
|
|
235
|
+
return finishPhrases(f, 2).join(", ");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** What the pace can't see, as short notes for the list. */
|
|
239
|
+
function finishNotes(f) {
|
|
240
|
+
return [
|
|
241
|
+
f.rough ? "rough" : null,
|
|
242
|
+
f.shortHistory ? "under 2 weeks of pace" : null,
|
|
243
|
+
f.lastDue ? `${f.lastDue.key ?? `“${f.lastDue.title}”`} due ${f.lastDue.due}` : null,
|
|
244
|
+
].filter(Boolean);
|
|
222
245
|
}
|
|
223
246
|
|
|
224
247
|
/** When each open project will likely finish at its crew's recent pace. */
|
|
@@ -232,7 +255,7 @@ export function forecastList(r) {
|
|
|
232
255
|
const name = p.name.length > width ? `${p.name.slice(0, width - 1)}…` : p.name.padEnd(width);
|
|
233
256
|
if (f.none) return `${name} no forecast: ${f.none.replace(/^\w/, (c) => c.toLowerCase())}`;
|
|
234
257
|
const end = p.ends ? ` · ${chance(f.chanceByEnd)} by its end, ${p.ends}` : "";
|
|
235
|
-
return `${name} ${finishRange(f)}${end}
|
|
258
|
+
return [`${name} ${finishRange(f)}${end}`, ...finishNotes(f)].join(" · ");
|
|
236
259
|
})
|
|
237
260
|
.join("\n");
|
|
238
261
|
}
|
|
@@ -241,10 +264,10 @@ export function forecastList(r) {
|
|
|
241
264
|
export function forecastOne(p) {
|
|
242
265
|
const f = p.finish ?? {};
|
|
243
266
|
if (f.none) return `${p.name}: ${f.none}`;
|
|
244
|
-
const
|
|
267
|
+
const [first, ...more] = finishPhrases(f);
|
|
245
268
|
const lines = [
|
|
246
269
|
f.likely
|
|
247
|
-
? `${p.name} will ${
|
|
270
|
+
? `${p.name} will ${first.replace(/^(probably|very likely|almost certainly)/, "$1 finish")}${more.map((x) => `, ${x}`).join("")}.`
|
|
248
271
|
: `${p.name} won't finish in the next two years at this pace.`,
|
|
249
272
|
];
|
|
250
273
|
if (p.ends) {
|
|
@@ -255,9 +278,33 @@ export function forecastOne(p) {
|
|
|
255
278
|
`${f.openTasks} task${f.openTasks === 1 ? "" : "s"} open; the crew finishes about ${f.finishedPerWeek} a week (${f.basedOn}).`,
|
|
256
279
|
);
|
|
257
280
|
if (f.rough) lines.push("Only a few tasks have finished, so treat this as rough.");
|
|
281
|
+
if (f.shortHistory)
|
|
282
|
+
lines.push(
|
|
283
|
+
"That pace comes from under two weeks of work; tasks logged as done in a batch make it look faster than it is.",
|
|
284
|
+
);
|
|
285
|
+
if (f.lastDue) {
|
|
286
|
+
const which = f.lastDue.key ? `${f.lastDue.key} “${f.lastDue.title}”` : `“${f.lastDue.title}”`;
|
|
287
|
+
lines.push(
|
|
288
|
+
`${which} is due ${f.lastDue.due}, after the likely finish, so the plan runs longer than the pace says.`,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
258
291
|
return lines.join("\n");
|
|
259
292
|
}
|
|
260
293
|
|
|
294
|
+
/** forecast --json: the finish forecast itself, with only what names the project around it. */
|
|
295
|
+
export function forecastData(r) {
|
|
296
|
+
const one = (p) => ({
|
|
297
|
+
id: p.id,
|
|
298
|
+
name: p.name,
|
|
299
|
+
...(p.key ? { key: p.key } : {}),
|
|
300
|
+
...(p.program !== undefined ? { program: p.program } : {}),
|
|
301
|
+
ends: p.ends ?? null,
|
|
302
|
+
...p.finish,
|
|
303
|
+
});
|
|
304
|
+
if (!r.projects) return one(r);
|
|
305
|
+
return { projects: r.projects.filter((p) => p.health !== "done").map(one) };
|
|
306
|
+
}
|
|
307
|
+
|
|
261
308
|
/** A project's pre-mortem, as get_premortem answers it. */
|
|
262
309
|
export function premortemText(r) {
|
|
263
310
|
if (!r.running) {
|
|
@@ -370,6 +417,25 @@ export function hillList(r) {
|
|
|
370
417
|
return blocks.join("\n\n");
|
|
371
418
|
}
|
|
372
419
|
|
|
420
|
+
/** What harbor import did, as import_tasks answers it. */
|
|
421
|
+
export function importText(r) {
|
|
422
|
+
const n = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
423
|
+
const from = r.from ? ` from ${r.from}` : "";
|
|
424
|
+
const lines = [
|
|
425
|
+
r.imported
|
|
426
|
+
? `Imported ${n(r.imported, "task")}${from} into ${r.portfolio}.`
|
|
427
|
+
: `Nothing imported${from}.`,
|
|
428
|
+
];
|
|
429
|
+
if (r.newPrograms?.length) lines.push(`New programs: ${r.newPrograms.join(", ")}.`);
|
|
430
|
+
if (r.newProjects?.length) lines.push(`New projects: ${r.newProjects.join(", ")}.`);
|
|
431
|
+
if (r.newPeople?.length) lines.push(`New people: ${r.newPeople.join(", ")}.`);
|
|
432
|
+
if (r.alreadyThere) lines.push(`${n(r.alreadyThere, "task")} already there, left alone.`);
|
|
433
|
+
if (r.leftOut) lines.push(`${n(r.leftOut, "row")} left out (canceled, won't do or duplicate).`);
|
|
434
|
+
if (r.waitsOnLinks) lines.push(`${n(r.waitsOnLinks, "link")} between tasks became "waits on".`);
|
|
435
|
+
for (const p of r.problems ?? []) lines.push(`Row ${p.row}: ${p.message}`);
|
|
436
|
+
return lines.join("\n");
|
|
437
|
+
}
|
|
438
|
+
|
|
373
439
|
/** A project's notes for later, as get_project lists them. */
|
|
374
440
|
export function notesText(project, notes) {
|
|
375
441
|
if (!notes?.length)
|