@harborppm/cli 0.50.0 → 0.52.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 +5 -1
- package/dist/app.mjs +324 -28
- package/lib/cli.mjs +56 -6
- package/lib/format.mjs +38 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -126,6 +126,8 @@ harbor try PROJECT --drop RL-4 --borrow Sam=3
|
|
|
126
126
|
dry dock: the forecast with changes tried
|
|
127
127
|
on a copy (--apply makes them, --save keeps
|
|
128
128
|
them to show someone; harbor help try)
|
|
129
|
+
harbor why PROJECT|TASK|MILESTONE what's holding it up: the line of task
|
|
130
|
+
waits that sets its date, slack per link
|
|
129
131
|
harbor hill [--program P] where each project is on its hill
|
|
130
132
|
harbor hill PROJECT --at 0-100|top|done move it: up to 50 is figuring it out,
|
|
131
133
|
past 50 is getting it done
|
|
@@ -155,9 +157,11 @@ harbor risk "TITLE" --project P|--program P [--impact 1-5] [--probability 1-5]
|
|
|
155
157
|
harbor issue "TITLE" --project P|--program P [--impact 1-5] [--owner NAME]
|
|
156
158
|
[--due DATE]
|
|
157
159
|
harbor decision "TITLE" --project P|--program P [--owner NAME] [--due DATE]
|
|
160
|
+
[--turned-down T] [--look-again DATE] [--supersedes "OLD"]
|
|
158
161
|
harbor risks [words] [--kind risk,issue,decision] [--project P] [--closed]
|
|
159
162
|
harbor raid "TITLE" [--status S] [--owner me|none|NAME] [--impact 1-5] [--probability 1-5]
|
|
160
|
-
[--due DATE|none] [--update TEXT] [--mitigation T] [--resolution T]
|
|
163
|
+
[--due DATE|none] [--update TEXT] [--mitigation T] [--resolution T]
|
|
164
|
+
[--turned-down T] [--look-again DATE|none] …
|
|
161
165
|
one risk, issue or decision in full, or change it
|
|
162
166
|
harbor review [WORDS] [--mine|--owner NAME] [--portfolio P] [--done]
|
|
163
167
|
the review: programs worst first, with next moves
|
package/dist/app.mjs
CHANGED
|
@@ -2069,7 +2069,8 @@ function normalizeRaidItem(r) {
|
|
|
2069
2069
|
updates: Array.isArray(r.updates) ? r.updates : [],
|
|
2070
2070
|
scoreHistory: Array.isArray(r.scoreHistory) && r.scoreHistory.length ? r.scoreHistory : void 0,
|
|
2071
2071
|
...kind === "risk" && typeof r.happened === "boolean" ? { happened: r.happened } : {},
|
|
2072
|
-
...kind === "decision" ? decisionFields(r) : {}
|
|
2072
|
+
...kind === "decision" ? decisionFields(r) : {},
|
|
2073
|
+
...ids$1(r.followerIds).length ? { followerIds: ids$1(r.followerIds) } : {}
|
|
2073
2074
|
};
|
|
2074
2075
|
}
|
|
2075
2076
|
/** A revisit condition that still says something, or null. */
|
|
@@ -3157,16 +3158,16 @@ const TRIALS = 1e3;
|
|
|
3157
3158
|
const MAX_WEEKS = 104;
|
|
3158
3159
|
/** Fewer finished tasks than this and the forecast says it's rough. */
|
|
3159
3160
|
const ROUGH_BELOW = 5;
|
|
3160
|
-
const noon$
|
|
3161
|
+
const noon$2 = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
|
|
3161
3162
|
/** Tasks finished in each of the last weeks, oldest first. Done tasks without a date, or dated after now, count as finished now. */
|
|
3162
3163
|
function weeklyFinished(tasks, startDate, now, weeks = 6) {
|
|
3163
|
-
const today = noon$
|
|
3164
|
-
const age = Math.floor(differenceInCalendarDays(today, noon$
|
|
3164
|
+
const today = noon$2(todayISO(now));
|
|
3165
|
+
const age = Math.floor(differenceInCalendarDays(today, noon$2(startDate)) / 7) + 1;
|
|
3165
3166
|
const span = Math.max(1, Math.min(weeks, age));
|
|
3166
3167
|
const counts = new Array(span).fill(0);
|
|
3167
3168
|
for (const t of tasks) {
|
|
3168
3169
|
if (t.status !== "done") continue;
|
|
3169
|
-
const at = t.completedAt ? noon$
|
|
3170
|
+
const at = t.completedAt ? noon$2(t.completedAt) : today;
|
|
3170
3171
|
const back = Math.max(0, differenceInCalendarDays(today, at));
|
|
3171
3172
|
const week = Math.floor(back / 7);
|
|
3172
3173
|
if (week < span) counts[span - 1 - week] += 1;
|
|
@@ -3272,7 +3273,7 @@ function tideForecast(project, tasks, now, opts = {}) {
|
|
|
3272
3273
|
remaining
|
|
3273
3274
|
};
|
|
3274
3275
|
const today = todayISO(now);
|
|
3275
|
-
const base = noon$
|
|
3276
|
+
const base = noon$2(today);
|
|
3276
3277
|
const paced = simulate(remaining, weekly, hash(`${project.id}|${remaining}|${today}`), Math.max(0, opts.pace ?? 1));
|
|
3277
3278
|
const at = (share, list = runs) => list[Math.min(list.length - 1, Math.ceil(share * list.length) - 1)];
|
|
3278
3279
|
let lastDue = null;
|
|
@@ -3280,12 +3281,12 @@ function tideForecast(project, tasks, now, opts = {}) {
|
|
|
3280
3281
|
if (t.status === "done" || !t.dueDate) continue;
|
|
3281
3282
|
if (!lastDue || t.dueDate > lastDue.dueDate) lastDue = t;
|
|
3282
3283
|
}
|
|
3283
|
-
const floor = lastDue ? differenceInCalendarDays(noon$
|
|
3284
|
+
const floor = lastDue ? differenceInCalendarDays(noon$2(lastDue.dueDate), base) : 0;
|
|
3284
3285
|
const sounded = soundingFloors(tasks, opts.reflections, base, hash(`${project.id}|sound|${today}`));
|
|
3285
3286
|
const runs = paced.map((d, i) => Math.max(d, floor, sounded.floors?.[i] ?? 0)).sort((a, b) => a - b);
|
|
3286
3287
|
const heldBy = lastDue && floor > at(.5, paced) ? lastDue : null;
|
|
3287
3288
|
const dayOf = (days) => Number.isFinite(days) ? todayISO(addDays(base, days)) : null;
|
|
3288
|
-
const toEnd = differenceInCalendarDays(noon$
|
|
3289
|
+
const toEnd = differenceInCalendarDays(noon$2(project.endDate), base);
|
|
3289
3290
|
const byEnd = runs.filter((d) => d <= toEnd).length / runs.length;
|
|
3290
3291
|
const last = Math.max(Number.isFinite(at(.99)) ? at(.99) : 728, toEnd + 14, 7);
|
|
3291
3292
|
const curve = [];
|
|
@@ -14719,6 +14720,24 @@ var init_assignment = __esmMin((() => {}));
|
|
|
14719
14720
|
//#region src/lib/slices/tasks.ts
|
|
14720
14721
|
function tasksSlice({ set, get, write }) {
|
|
14721
14722
|
return {
|
|
14723
|
+
followWork: (personId, taskIds, raidIds = []) => {
|
|
14724
|
+
const tasks = new Set(taskIds);
|
|
14725
|
+
const raid = new Set(raidIds);
|
|
14726
|
+
const add = (ids) => ids?.includes(personId) ? ids : [...ids ?? [], personId];
|
|
14727
|
+
write({
|
|
14728
|
+
tasks: get().tasks.map((t) => tasks.has(t.id) && t.assigneeId !== personId ? {
|
|
14729
|
+
...t,
|
|
14730
|
+
followerIds: add(t.followerIds)
|
|
14731
|
+
} : t),
|
|
14732
|
+
raidItems: get().raidItems.map((r) => raid.has(r.id) && r.ownerId !== personId ? {
|
|
14733
|
+
...r,
|
|
14734
|
+
followerIds: add(r.followerIds),
|
|
14735
|
+
updatedAt: stamp()
|
|
14736
|
+
} : r)
|
|
14737
|
+
});
|
|
14738
|
+
const who = get().people.find((p) => p.id === personId);
|
|
14739
|
+
get().log(`added ${who?.name ?? "someone"} as a second pair of hands on ${taskIds.length + raidIds.length} item${taskIds.length + raidIds.length === 1 ? "" : "s"}`, "/people");
|
|
14740
|
+
},
|
|
14722
14741
|
addTask: (t, opts) => {
|
|
14723
14742
|
if (!opts?.allowOverCap) {
|
|
14724
14743
|
if (columnWip(get(), t.projectId, t.status, 1)) return "";
|
|
@@ -16947,7 +16966,7 @@ function dueFrom(value, now) {
|
|
|
16947
16966
|
* The CLI saves through the sync loop afterwards.
|
|
16948
16967
|
*/
|
|
16949
16968
|
init_fog();
|
|
16950
|
-
const plural$
|
|
16969
|
+
const plural$3 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
16951
16970
|
async function sweepFog(opts) {
|
|
16952
16971
|
const { store, ask, say, today } = opts;
|
|
16953
16972
|
const start = store.getState();
|
|
@@ -16963,7 +16982,7 @@ async function sweepFog(opts) {
|
|
|
16963
16982
|
say("No fog: every open task was touched in the last three weeks.");
|
|
16964
16983
|
return tally;
|
|
16965
16984
|
}
|
|
16966
|
-
say(`Clear the fog · ${plural$
|
|
16985
|
+
say(`Clear the fog · ${plural$3(queue.length, "task")} nobody has opened, moved or commented on for three weeks.`);
|
|
16967
16986
|
say("k still needed d new date x drop it s skip q stop\n");
|
|
16968
16987
|
let stopped = false;
|
|
16969
16988
|
for (let i = 0; i < queue.length && !stopped; i++) {
|
|
@@ -18262,9 +18281,9 @@ var commands_exports = /* @__PURE__ */ __exportAll({
|
|
|
18262
18281
|
/** The one line a sync ends with; a dry run's says what it would have done. */
|
|
18263
18282
|
function syncLine(report, dryRun = false) {
|
|
18264
18283
|
const parts = [
|
|
18265
|
-
report.sent.length ? `${dryRun ? "would send" : "sent"} ${plural$
|
|
18266
|
-
report.added.length ? `${dryRun ? "would add" : "added"} ${plural$
|
|
18267
|
-
report.took.length ? `${dryRun ? "would bring in" : "brought in"} ${plural$
|
|
18284
|
+
report.sent.length ? `${dryRun ? "would send" : "sent"} ${plural$2(report.sent.length, "change")} to Harbor` : "",
|
|
18285
|
+
report.added.length ? `${dryRun ? "would add" : "added"} ${plural$2(report.added.length, "task")}` : "",
|
|
18286
|
+
report.took.length ? `${dryRun ? "would bring in" : "brought in"} ${plural$2(report.took.length, "change")} from Harbor` : ""
|
|
18268
18287
|
].filter(Boolean);
|
|
18269
18288
|
if (dryRun) return `⚓ Dry run: ${parts.length ? parts.join(", ") : "already in step with Harbor"}. Nothing was written or sent.`;
|
|
18270
18289
|
return parts.length ? `⚓ ${parts.join(", ")}.` : "⚓ Already in step with Harbor.";
|
|
@@ -18319,13 +18338,13 @@ async function initHere(opts) {
|
|
|
18319
18338
|
dryRun: opts.dryRun
|
|
18320
18339
|
});
|
|
18321
18340
|
const { project, harbor } = made.config;
|
|
18322
|
-
const counted = made.open === made.tasks ? plural$
|
|
18341
|
+
const counted = made.open === made.tasks ? plural$2(made.tasks, "task") : `${made.open} open and ${made.tasks - made.open} done ${made.tasks === 1 ? "task" : "tasks"}`;
|
|
18323
18342
|
if (opts.dryRun) {
|
|
18324
|
-
opts.say(made.moved ? `Would move this folder's ${plural$
|
|
18343
|
+
opts.say(made.moved ? `Would move this folder's ${plural$2(made.moved, "task")} into ${project.name} (${project.key}) in ${harbor.name}.` : `Would make ${shownDir(opts.cwd, dir)} for ${project.name} (${project.key}) in ${harbor.name}, with a file for each of its ${counted}.`);
|
|
18325
18344
|
opts.say("Dry run: nothing was written or sent. Run it again without --dry-run to do it.");
|
|
18326
18345
|
return made.config;
|
|
18327
18346
|
}
|
|
18328
|
-
opts.say(made.moved ? `Moved this folder's ${plural$
|
|
18347
|
+
opts.say(made.moved ? `Moved this folder's ${plural$2(made.moved, "task")} into ${project.name} in ${harbor.name}.` : `Made ${shownDir(opts.cwd, dir)} for ${project.name} (${project.key}) in ${harbor.name}, with a file for each of its ${counted}. Commit it like any code.`);
|
|
18329
18348
|
opts.say("Edit a file, or add one, then run harbor sync. Changes on the website come back the same way.");
|
|
18330
18349
|
if (opts.hook) opts.say(await addHook(home));
|
|
18331
18350
|
return made.config;
|
|
@@ -18386,12 +18405,12 @@ async function openLocalPlan(dir, say) {
|
|
|
18386
18405
|
}]
|
|
18387
18406
|
};
|
|
18388
18407
|
}
|
|
18389
|
-
var plural$
|
|
18408
|
+
var plural$2, shownDir, HOOK;
|
|
18390
18409
|
var init_commands = __esmMin((() => {
|
|
18391
18410
|
init_errors();
|
|
18392
18411
|
init_folder();
|
|
18393
18412
|
init_repo_sync();
|
|
18394
|
-
plural$
|
|
18413
|
+
plural$2 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
18395
18414
|
shownDir = (cwd, dir) => `${relative(cwd, dir) || ".harbor"}/`;
|
|
18396
18415
|
HOOK = "harbor sync --quiet || true";
|
|
18397
18416
|
}));
|
|
@@ -21167,7 +21186,7 @@ function bucketFor(state, inst) {
|
|
|
21167
21186
|
return bucket;
|
|
21168
21187
|
}
|
|
21169
21188
|
let handoff;
|
|
21170
|
-
const open = [];
|
|
21189
|
+
const open$1 = [];
|
|
21171
21190
|
const memo = {
|
|
21172
21191
|
alloc(_inst, payload, empty) {
|
|
21173
21192
|
const bucket = handoff;
|
|
@@ -21178,7 +21197,7 @@ const memo = {
|
|
|
21178
21197
|
issues: null
|
|
21179
21198
|
};
|
|
21180
21199
|
bucket.set(payload.value, entry);
|
|
21181
|
-
open.push(entry);
|
|
21200
|
+
open$1.push(entry);
|
|
21182
21201
|
return empty;
|
|
21183
21202
|
},
|
|
21184
21203
|
guard(inst) {
|
|
@@ -21244,10 +21263,10 @@ const memo = {
|
|
|
21244
21263
|
return payload;
|
|
21245
21264
|
}
|
|
21246
21265
|
handoff = bucket;
|
|
21247
|
-
const depth = open.length;
|
|
21266
|
+
const depth = open$1.length;
|
|
21248
21267
|
const result = base(payload, ctx);
|
|
21249
21268
|
handoff = void 0;
|
|
21250
|
-
const entry = open.length > depth ? open.pop() : void 0;
|
|
21269
|
+
const entry = open$1.length > depth ? open$1.pop() : void 0;
|
|
21251
21270
|
if (result instanceof Promise) return result.then((r) => {
|
|
21252
21271
|
if (entry) entry.issues = r.issues.length ? cloneIssues(r.issues) : NO_ISSUES;
|
|
21253
21272
|
return r;
|
|
@@ -23619,18 +23638,18 @@ init_format();
|
|
|
23619
23638
|
const BURN_DAYS = 56;
|
|
23620
23639
|
/** A window shorter than a week says too little to read a rate from. */
|
|
23621
23640
|
const MIN_DAYS = 7;
|
|
23622
|
-
const noon = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
|
|
23641
|
+
const noon$1 = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
|
|
23623
23642
|
/**
|
|
23624
23643
|
* Spend a day over the last BURN_DAYS (or the project's life, when it's
|
|
23625
23644
|
* younger), from the ledger; null when nothing was spent in that time.
|
|
23626
23645
|
*/
|
|
23627
23646
|
function burnPerDay(entries, startDate, now) {
|
|
23628
|
-
const today = noon(todayISO(now));
|
|
23629
|
-
const age = differenceInCalendarDays(today, noon(startDate)) + 1;
|
|
23647
|
+
const today = noon$1(todayISO(now));
|
|
23648
|
+
const age = differenceInCalendarDays(today, noon$1(startDate)) + 1;
|
|
23630
23649
|
const span = Math.max(MIN_DAYS, Math.min(56, age));
|
|
23631
23650
|
let sum = 0;
|
|
23632
23651
|
for (const e of entries) {
|
|
23633
|
-
const back = differenceInCalendarDays(today, noon(e.date));
|
|
23652
|
+
const back = differenceInCalendarDays(today, noon$1(e.date));
|
|
23634
23653
|
if (back >= 0 && back < span) sum += e.amount;
|
|
23635
23654
|
}
|
|
23636
23655
|
return sum > 0 ? sum / span : null;
|
|
@@ -23647,8 +23666,8 @@ function slipCost(project, tide, entries, now) {
|
|
|
23647
23666
|
const from = project.endDate > today ? project.endDate : today;
|
|
23648
23667
|
const point = (date) => {
|
|
23649
23668
|
if (!date) return null;
|
|
23650
|
-
const daysLate = Math.max(0, differenceInCalendarDays(noon(date), noon(from)));
|
|
23651
|
-
const ahead = Math.max(0, differenceInCalendarDays(noon(date), noon(today)));
|
|
23669
|
+
const daysLate = Math.max(0, differenceInCalendarDays(noon$1(date), noon$1(from)));
|
|
23670
|
+
const ahead = Math.max(0, differenceInCalendarDays(noon$1(date), noon$1(today)));
|
|
23652
23671
|
return {
|
|
23653
23672
|
date,
|
|
23654
23673
|
daysLate,
|
|
@@ -23663,6 +23682,209 @@ function slipCost(project, tide, entries, now) {
|
|
|
23663
23682
|
};
|
|
23664
23683
|
}
|
|
23665
23684
|
|
|
23685
|
+
//#endregion
|
|
23686
|
+
//#region src/lib/long-pole.ts
|
|
23687
|
+
init_date_fns();
|
|
23688
|
+
init_blockers();
|
|
23689
|
+
init_format();
|
|
23690
|
+
init_indexes();
|
|
23691
|
+
const noon = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
|
|
23692
|
+
const days = (from, to) => differenceInCalendarDays(noon(to), noon(from));
|
|
23693
|
+
const later = (a, b) => !a ? b : !b ? a : a >= b ? a : b;
|
|
23694
|
+
const open = (t) => t.status !== "done";
|
|
23695
|
+
/** True when `a` finishes later than `b`, or as late behind a longer line. */
|
|
23696
|
+
const beats = (a, b) => !b || a.finish > b.finish || a.finish === b.finish && a.depth > b.depth;
|
|
23697
|
+
/**
|
|
23698
|
+
* Soonest finish for open tasks, and how many open links lie behind each
|
|
23699
|
+
* (itself included), worked out once per task however many wait on it. A
|
|
23700
|
+
* loop is cut where it closes. Undated tasks with nothing dated behind them
|
|
23701
|
+
* have no finish.
|
|
23702
|
+
*/
|
|
23703
|
+
function finishes(all, today) {
|
|
23704
|
+
const memo = /* @__PURE__ */ new Map();
|
|
23705
|
+
const onPath = /* @__PURE__ */ new Set();
|
|
23706
|
+
const walk = (t) => {
|
|
23707
|
+
const known = memo.get(t.id);
|
|
23708
|
+
if (known) return known;
|
|
23709
|
+
onPath.add(t.id);
|
|
23710
|
+
let behind = null;
|
|
23711
|
+
for (const b of blockersIn(t, all)) {
|
|
23712
|
+
if (!open(b) || onPath.has(b.id)) continue;
|
|
23713
|
+
const w = walk(b);
|
|
23714
|
+
if (w.finish && beats(w, behind)) behind = w;
|
|
23715
|
+
}
|
|
23716
|
+
const own = t.dueDate ? later(t.dueDate.slice(0, 10), today) : null;
|
|
23717
|
+
onPath.delete(t.id);
|
|
23718
|
+
const out = {
|
|
23719
|
+
finish: later(own, behind?.finish ?? null),
|
|
23720
|
+
depth: (behind?.depth ?? 0) + 1
|
|
23721
|
+
};
|
|
23722
|
+
memo.set(t.id, out);
|
|
23723
|
+
return out;
|
|
23724
|
+
};
|
|
23725
|
+
return { walk };
|
|
23726
|
+
}
|
|
23727
|
+
/** The line of open tasks ending at `last`, through the blocker that finishes last at each step. */
|
|
23728
|
+
function lineTo(last, all, walk) {
|
|
23729
|
+
const line = [last];
|
|
23730
|
+
const seen = /* @__PURE__ */ new Set([last.id]);
|
|
23731
|
+
let cursor = last;
|
|
23732
|
+
for (;;) {
|
|
23733
|
+
let next = null;
|
|
23734
|
+
let best = null;
|
|
23735
|
+
for (const b of blockersIn(cursor, all)) {
|
|
23736
|
+
if (!open(b) || seen.has(b.id)) continue;
|
|
23737
|
+
const w = walk(b);
|
|
23738
|
+
if (w.finish && beats(w, best)) {
|
|
23739
|
+
best = w;
|
|
23740
|
+
next = b;
|
|
23741
|
+
}
|
|
23742
|
+
}
|
|
23743
|
+
if (!next) break;
|
|
23744
|
+
seen.add(next.id);
|
|
23745
|
+
line.unshift(next);
|
|
23746
|
+
cursor = next;
|
|
23747
|
+
}
|
|
23748
|
+
return line;
|
|
23749
|
+
}
|
|
23750
|
+
function linksOf(line, walk, target, today) {
|
|
23751
|
+
return line.map((task, i) => {
|
|
23752
|
+
const finish = walk(task).finish;
|
|
23753
|
+
const next = line[i + 1];
|
|
23754
|
+
const against = next ? next.dueDate?.slice(0, 10) ?? null : target;
|
|
23755
|
+
return {
|
|
23756
|
+
task,
|
|
23757
|
+
finish,
|
|
23758
|
+
late: Boolean(task.dueDate && task.dueDate.slice(0, 10) < today),
|
|
23759
|
+
slack: against ? days(finish, against) : null
|
|
23760
|
+
};
|
|
23761
|
+
});
|
|
23762
|
+
}
|
|
23763
|
+
function chainOf(line, walk, target, today) {
|
|
23764
|
+
const links = linksOf(line, walk, target, today);
|
|
23765
|
+
const finish = links[links.length - 1].finish;
|
|
23766
|
+
return {
|
|
23767
|
+
kind: "chain",
|
|
23768
|
+
links,
|
|
23769
|
+
finish,
|
|
23770
|
+
target,
|
|
23771
|
+
over: target ? days(target, finish) : null
|
|
23772
|
+
};
|
|
23773
|
+
}
|
|
23774
|
+
/** What sets a project's date: the line behind its open task that finishes last. */
|
|
23775
|
+
function projectPole(project, all, now) {
|
|
23776
|
+
const today = todayISO(now);
|
|
23777
|
+
const mine = tasksOf(all, project.id).filter(open);
|
|
23778
|
+
if (mine.length === 0) return {
|
|
23779
|
+
kind: "none",
|
|
23780
|
+
reason: "Nothing is open, so nothing holds it up."
|
|
23781
|
+
};
|
|
23782
|
+
const { walk } = finishes(all, today);
|
|
23783
|
+
let last = null;
|
|
23784
|
+
let best = null;
|
|
23785
|
+
for (const t of mine) {
|
|
23786
|
+
const w = walk(t);
|
|
23787
|
+
if (w.finish && beats(w, best)) {
|
|
23788
|
+
best = w;
|
|
23789
|
+
last = t;
|
|
23790
|
+
}
|
|
23791
|
+
}
|
|
23792
|
+
if (!last) return {
|
|
23793
|
+
kind: "none",
|
|
23794
|
+
reason: "None of its open tasks has a due date, so there's no date to trace."
|
|
23795
|
+
};
|
|
23796
|
+
return chainOf(lineTo(last, all, walk), walk, project.endDate.slice(0, 10), today);
|
|
23797
|
+
}
|
|
23798
|
+
/** What holds one task up: the line of open tasks it waits on, ending at it. */
|
|
23799
|
+
function taskPole(task, all, now) {
|
|
23800
|
+
const today = todayISO(now);
|
|
23801
|
+
if (!open(task)) return {
|
|
23802
|
+
kind: "none",
|
|
23803
|
+
reason: "It's done, so nothing holds it up."
|
|
23804
|
+
};
|
|
23805
|
+
const { walk } = finishes(all, today);
|
|
23806
|
+
if (!walk(task).finish) return {
|
|
23807
|
+
kind: "none",
|
|
23808
|
+
reason: "Neither it nor anything it waits on has a due date, so there's no date to trace."
|
|
23809
|
+
};
|
|
23810
|
+
return chainOf(lineTo(task, all, walk), walk, task.dueDate?.slice(0, 10) ?? null, today);
|
|
23811
|
+
}
|
|
23812
|
+
/**
|
|
23813
|
+
* What holds a milestone up: of the projects it waits on that still have
|
|
23814
|
+
* open work, the one finishing last (its end date, or later when its tasks
|
|
23815
|
+
* run past it), and that project's own long pole.
|
|
23816
|
+
*/
|
|
23817
|
+
function milestonePole(milestone, data, now) {
|
|
23818
|
+
if (milestone.done) return {
|
|
23819
|
+
kind: "none",
|
|
23820
|
+
reason: "It's done, so nothing holds it up."
|
|
23821
|
+
};
|
|
23822
|
+
const waited = blockersIn(milestone, data.projects);
|
|
23823
|
+
if (waited.length === 0) return {
|
|
23824
|
+
kind: "none",
|
|
23825
|
+
reason: "It doesn't wait on any project. Link the projects it needs to trace what sets it."
|
|
23826
|
+
};
|
|
23827
|
+
const due = milestone.dueDate.slice(0, 10);
|
|
23828
|
+
const runs = waited.flatMap((project) => {
|
|
23829
|
+
if (project.health === "done" || !tasksOf(data.tasks, project.id).some(open)) return [];
|
|
23830
|
+
const pole = projectPole(project, data.tasks, now);
|
|
23831
|
+
const end = project.endDate.slice(0, 10);
|
|
23832
|
+
return [{
|
|
23833
|
+
project,
|
|
23834
|
+
pole,
|
|
23835
|
+
finish: pole.kind === "chain" ? later(pole.finish, end) ?? end : end
|
|
23836
|
+
}];
|
|
23837
|
+
});
|
|
23838
|
+
if (runs.length === 0) return {
|
|
23839
|
+
kind: "none",
|
|
23840
|
+
reason: "Every project it waits on is finished."
|
|
23841
|
+
};
|
|
23842
|
+
runs.sort((a, b) => b.finish.localeCompare(a.finish));
|
|
23843
|
+
const first = runs[0];
|
|
23844
|
+
const others = runs.slice(1).map((r) => ({
|
|
23845
|
+
project: r.project,
|
|
23846
|
+
finish: r.finish,
|
|
23847
|
+
slack: days(r.finish, due)
|
|
23848
|
+
}));
|
|
23849
|
+
return {
|
|
23850
|
+
kind: "chain",
|
|
23851
|
+
links: first.pole.kind === "chain" ? first.pole.links.map((l, i, all) => i === all.length - 1 ? {
|
|
23852
|
+
...l,
|
|
23853
|
+
slack: days(l.finish, due)
|
|
23854
|
+
} : l) : [],
|
|
23855
|
+
finish: first.finish,
|
|
23856
|
+
target: due,
|
|
23857
|
+
over: days(due, first.finish),
|
|
23858
|
+
via: {
|
|
23859
|
+
project: first.project,
|
|
23860
|
+
finish: first.finish
|
|
23861
|
+
},
|
|
23862
|
+
others
|
|
23863
|
+
};
|
|
23864
|
+
}
|
|
23865
|
+
const plural$1 = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
23866
|
+
/** A link's slack in a few words: "4 days spare", "no slack", "3 days late for what's next". */
|
|
23867
|
+
function slackText(slack, last = false) {
|
|
23868
|
+
if (slack == null) return last ? "no date to hold" : "next has no due date";
|
|
23869
|
+
if (slack > 0) return `${plural$1(slack, "day")} spare`;
|
|
23870
|
+
if (slack === 0) return "no slack";
|
|
23871
|
+
return last ? `runs ${plural$1(-slack, "day")} over` : `pushes the next ${plural$1(-slack, "day")}`;
|
|
23872
|
+
}
|
|
23873
|
+
/**
|
|
23874
|
+
* The sentence above the line: what sets the date and how it compares with
|
|
23875
|
+
* the date being held. `name` turns a task into what to call it (its key).
|
|
23876
|
+
*/
|
|
23877
|
+
function poleHeadline(pole, name, held) {
|
|
23878
|
+
if (pole.kind !== "chain") return null;
|
|
23879
|
+
const last = pole.links[pole.links.length - 1];
|
|
23880
|
+
const lead = pole.via ? `${pole.via.project.name} finishes ${shortDate(pole.finish)}` : `${last ? name(last.task) : "It"} can finish ${shortDate(pole.finish)}`;
|
|
23881
|
+
const tail = pole.via && last && last.finish < pole.finish ? ` Its tasks can all be done by ${shortDate(last.finish)}; its end date sets it.` : "";
|
|
23882
|
+
if (pole.over == null || !pole.target) return `${lead}.${tail}`;
|
|
23883
|
+
if (pole.over > 0) return `${lead}, ${plural$1(pole.over, "day")} after ${held} (${shortDate(pole.target)}).${tail}`;
|
|
23884
|
+
if (pole.over === 0) return `${lead}, right on ${held}.${tail}`;
|
|
23885
|
+
return `${lead}, ${plural$1(-pole.over, "day")} before ${held}.${tail}`;
|
|
23886
|
+
}
|
|
23887
|
+
|
|
23666
23888
|
//#endregion
|
|
23667
23889
|
//#region src/lib/csv.ts
|
|
23668
23890
|
/**
|
|
@@ -24940,6 +25162,80 @@ const HARBOR_TOOLS = [
|
|
|
24940
25162
|
};
|
|
24941
25163
|
}
|
|
24942
25164
|
}),
|
|
25165
|
+
tool({
|
|
25166
|
+
name: "long_pole",
|
|
25167
|
+
title: "What's holding this up",
|
|
25168
|
+
description: "The long pole behind a project, a task or a milestone: of all the task waits behind it, the one line that sets its date, first blocker to last. Each link has its due date, the soonest it can finish (its due date, later when what it waits on finishes after, today at the earliest for late work) and slack: days it can slip before it moves the next link, or the end; negative when it already does. over is how many days the line finishes past the date held (a project's end, a task's or milestone's due date). For a milestone, via is the project it waits on that finishes last, and others are the rest with their slack." + DATA_NOTE,
|
|
25169
|
+
input: object({
|
|
25170
|
+
harbor: harborArg,
|
|
25171
|
+
of: string().min(1).max(200).describe("A project (id, name or key like \"RR\"), a task key (\"RR-42\") or a milestone (id or name).")
|
|
25172
|
+
}),
|
|
25173
|
+
run: (ctx, input) => {
|
|
25174
|
+
const { data } = ctx;
|
|
25175
|
+
const people = byId(data.people);
|
|
25176
|
+
const projects = byId(data.projects);
|
|
25177
|
+
const keyOf = (t) => taskKey(t, projects.get(t.projectId));
|
|
25178
|
+
const name = (t) => keyOf(t) ?? `“${t.title}”`;
|
|
25179
|
+
const answer = (kind, what, pole, held) => {
|
|
25180
|
+
if (pole.kind !== "chain") return {
|
|
25181
|
+
of: {
|
|
25182
|
+
kind,
|
|
25183
|
+
name: what
|
|
25184
|
+
},
|
|
25185
|
+
none: pole.reason
|
|
25186
|
+
};
|
|
25187
|
+
return {
|
|
25188
|
+
of: {
|
|
25189
|
+
kind,
|
|
25190
|
+
name: what
|
|
25191
|
+
},
|
|
25192
|
+
headline: poleHeadline(pole, name, held),
|
|
25193
|
+
finish: pole.finish,
|
|
25194
|
+
held: pole.target,
|
|
25195
|
+
over: pole.over,
|
|
25196
|
+
...pole.via ? { via: {
|
|
25197
|
+
project: pole.via.project.name,
|
|
25198
|
+
finish: pole.via.finish
|
|
25199
|
+
} } : {},
|
|
25200
|
+
chain: pole.links.map((l, i) => ({
|
|
25201
|
+
id: l.task.id,
|
|
25202
|
+
...keyOf(l.task) ? { key: keyOf(l.task) } : {},
|
|
25203
|
+
title: l.task.title,
|
|
25204
|
+
status: l.task.status,
|
|
25205
|
+
assignee: nameOf(people, l.task.assigneeId),
|
|
25206
|
+
due: dayOf(l.task.dueDate),
|
|
25207
|
+
canFinish: l.finish,
|
|
25208
|
+
late: l.late,
|
|
25209
|
+
slack: l.slack,
|
|
25210
|
+
slackText: slackText(l.slack, i === pole.links.length - 1)
|
|
25211
|
+
})),
|
|
25212
|
+
...pole.others ? { others: pole.others.map((o) => ({
|
|
25213
|
+
project: o.project.name,
|
|
25214
|
+
finish: o.finish,
|
|
25215
|
+
slack: o.slack
|
|
25216
|
+
})) } : {}
|
|
25217
|
+
};
|
|
25218
|
+
};
|
|
25219
|
+
const task = data.tasks.find((x) => x.id === input.of.trim()) ?? findByKey(data, input.of);
|
|
25220
|
+
if (task) return answer("task", name(task), taskPole(task, data.tasks, ctx.now), "its due date");
|
|
25221
|
+
let project = null;
|
|
25222
|
+
try {
|
|
25223
|
+
project = findOne(data.projects, input.of, "project");
|
|
25224
|
+
} catch (err) {
|
|
25225
|
+
if (!(err instanceof ToolError) || !/^No project matches/.test(err.message)) throw err;
|
|
25226
|
+
const milestones = data.milestones.filter((m) => !m.done);
|
|
25227
|
+
let milestone;
|
|
25228
|
+
try {
|
|
25229
|
+
milestone = findOne(milestones.length ? milestones : data.milestones, input.of, "milestone");
|
|
25230
|
+
} catch (inner) {
|
|
25231
|
+
if (inner instanceof ToolError && /^No milestone matches/.test(inner.message)) throw new ToolError(`No project, task key or open milestone matches "${input.of.trim()}".`);
|
|
25232
|
+
throw inner;
|
|
25233
|
+
}
|
|
25234
|
+
return answer("milestone", milestone.name, milestonePole(milestone, data, ctx.now), "the milestone");
|
|
25235
|
+
}
|
|
25236
|
+
return answer("project", project.name, projectPole(project, data.tasks, ctx.now), "the end date");
|
|
25237
|
+
}
|
|
25238
|
+
}),
|
|
24943
25239
|
tool({
|
|
24944
25240
|
name: "shipping_forecast",
|
|
24945
25241
|
title: "The harbor forecast",
|
package/lib/cli.mjs
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
soundingText,
|
|
38
38
|
soundingsText,
|
|
39
39
|
voyageText,
|
|
40
|
+
whyText,
|
|
40
41
|
handoverText,
|
|
41
42
|
fogText,
|
|
42
43
|
agentWorkText,
|
|
@@ -89,6 +90,8 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
89
90
|
dry dock: the forecast with plan
|
|
90
91
|
changes tried on a copy; nothing
|
|
91
92
|
changes without --apply
|
|
93
|
+
harbor why PROJECT|TASK|MILESTONE what's holding it up: the line of
|
|
94
|
+
task waits that sets its date
|
|
92
95
|
harbor hill [--program P] where each project is on its hill
|
|
93
96
|
harbor hill PROJECT --at 0-100|top|done move it: up to 50 is figuring it
|
|
94
97
|
out, past 50 is getting it done
|
|
@@ -142,6 +145,8 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
142
145
|
harbor issue "TITLE" --project P|--program P [--impact 1-5] [--owner NAME]
|
|
143
146
|
[--due DATE]
|
|
144
147
|
harbor decision "TITLE" --project P|--program P [--owner NAME] [--due DATE]
|
|
148
|
+
[--turned-down T] [--look-again DATE]
|
|
149
|
+
[--supersedes "OLD DECISION"]
|
|
145
150
|
harbor risks [words] [--kind risk,issue,decision] [--project P] [--closed]
|
|
146
151
|
the register, worst first
|
|
147
152
|
(--closed adds closed ones)
|
|
@@ -149,7 +154,7 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
149
154
|
[--probability 1-5] [--due DATE|none] [--update TEXT]
|
|
150
155
|
[--title T] [--description T] [--mitigation T]
|
|
151
156
|
[--contingency T] [--resolution T] [--rationale T]
|
|
152
|
-
[--notes T]
|
|
157
|
+
[--notes T] [--turned-down T] [--look-again DATE|none]
|
|
153
158
|
one risk, issue or decision in
|
|
154
159
|
full, or change it
|
|
155
160
|
harbor review [WORDS] [--mine|--owner NAME] [--portfolio P] [--done]
|
|
@@ -719,6 +724,20 @@ const DETAIL = {
|
|
|
719
724
|
" never marks its own work Done; a person does, on the website or in the",
|
|
720
725
|
" terminal app.",
|
|
721
726
|
],
|
|
727
|
+
why: [
|
|
728
|
+
" harbor why PROJECT the line of task waits that sets a",
|
|
729
|
+
" project's date, against its end",
|
|
730
|
+
" harbor why TASK the waits behind one task (a key",
|
|
731
|
+
" like RR-42), against its due date",
|
|
732
|
+
" harbor why MILESTONE the project a milestone waits on",
|
|
733
|
+
" that finishes last, and its line",
|
|
734
|
+
"",
|
|
735
|
+
" Tasks have due dates, not lengths, so a task can finish on its due date",
|
|
736
|
+
" at the soonest, never before what it waits on, and never in the past.",
|
|
737
|
+
" Each link's slack is the days it can slip before it moves the next one",
|
|
738
|
+
" (or the end); a negative one already does. PROJECT is a name, a key",
|
|
739
|
+
" like RR, or an id.",
|
|
740
|
+
],
|
|
722
741
|
voyage: [
|
|
723
742
|
" harbor voyage PROJECT reads a Done project's voyage log",
|
|
724
743
|
' harbor voyage PROJECT "YOUR LINE" writes: adds or changes your line in it',
|
|
@@ -743,7 +762,8 @@ const DETAIL = {
|
|
|
743
762
|
" Also --owner me|none|NAME, --impact 1-5, --probability 1-5 (risks),",
|
|
744
763
|
" --due DATE|none (review by, or decide by), --title, --description,",
|
|
745
764
|
" --mitigation and --contingency (risks), --resolution (issues and",
|
|
746
|
-
" decisions), --rationale
|
|
765
|
+
" decisions), --rationale, --turned-down and --look-again DATE|none",
|
|
766
|
+
" (decisions) and --notes. TITLE is its title or",
|
|
747
767
|
" enough of it to pick one out; harbor risks lists them. Changes several",
|
|
748
768
|
" at once, as one change.",
|
|
749
769
|
],
|
|
@@ -794,6 +814,12 @@ const DETAIL = {
|
|
|
794
814
|
' harbor decision "TITLE" --project P writes: logs a decision to make',
|
|
795
815
|
' harbor decision "TITLE" --program P writes: logs one on the program',
|
|
796
816
|
" [--owner NAME] [--due DATE] (the date to decide by)",
|
|
817
|
+
" [--turned-down T] (what was turned down, and why not)",
|
|
818
|
+
" [--look-again DATE] (when it comes back to the top of the brief)",
|
|
819
|
+
' harbor decision "TITLE" --supersedes "OLD"',
|
|
820
|
+
" writes: a decision that replaces an",
|
|
821
|
+
" earlier one, which reads as",
|
|
822
|
+
" superseded",
|
|
797
823
|
],
|
|
798
824
|
};
|
|
799
825
|
|
|
@@ -850,6 +876,9 @@ function distance(a, b) {
|
|
|
850
876
|
/** A mistyped command, with the one it was probably meant to be. */
|
|
851
877
|
export function noCommand(name) {
|
|
852
878
|
if (name.startsWith("-")) return `${name} goes after a command, like harbor mine ${name}.`;
|
|
879
|
+
// A plural kind reads the register, not the command that writes one.
|
|
880
|
+
if (["decisions", "issues"].includes(name))
|
|
881
|
+
return `No command "${name}". harbor risks --kind ${name.slice(0, -1)} lists them.`;
|
|
853
882
|
const near = COMMANDS.filter((c) => distance(name, c) <= (c.length > 4 ? 2 : 1));
|
|
854
883
|
const guess = near.length === 1 ? ` Did you mean harbor ${near[0]}?` : "";
|
|
855
884
|
return `No command "${name}".${guess} Run harbor help.`;
|
|
@@ -1963,7 +1992,7 @@ export async function run(argv, io = {}) {
|
|
|
1963
1992
|
includeClosed: Boolean(values.closed),
|
|
1964
1993
|
limit: 50,
|
|
1965
1994
|
},
|
|
1966
|
-
(r) => raidList(r, positionals.join(" ")),
|
|
1995
|
+
(r) => raidList(r, positionals.join(" "), kind),
|
|
1967
1996
|
);
|
|
1968
1997
|
}
|
|
1969
1998
|
case "report": {
|
|
@@ -2161,6 +2190,12 @@ export async function run(argv, io = {}) {
|
|
|
2161
2190
|
soundingsText(r),
|
|
2162
2191
|
);
|
|
2163
2192
|
}
|
|
2193
|
+
case "why": {
|
|
2194
|
+
const { positionals, harbor } = parse(spec());
|
|
2195
|
+
const of = positionals.join(" ").trim();
|
|
2196
|
+
if (!of) throw new HarborError("Say what: a project, a task key like RR-42, or a milestone.");
|
|
2197
|
+
return call("long_pole", { harbor, of }, whyText);
|
|
2198
|
+
}
|
|
2164
2199
|
case "voyage": {
|
|
2165
2200
|
const { positionals, harbor } = parse(spec());
|
|
2166
2201
|
const [project, ...words] = positionals;
|
|
@@ -2292,6 +2327,8 @@ export async function run(argv, io = {}) {
|
|
|
2292
2327
|
"update",
|
|
2293
2328
|
"decided-by",
|
|
2294
2329
|
"decided-on",
|
|
2330
|
+
"turned-down",
|
|
2331
|
+
"look-again",
|
|
2295
2332
|
...fields,
|
|
2296
2333
|
],
|
|
2297
2334
|
),
|
|
@@ -2309,6 +2346,9 @@ export async function run(argv, io = {}) {
|
|
|
2309
2346
|
decidedBy: values["decided-by"],
|
|
2310
2347
|
decidedOn:
|
|
2311
2348
|
values["decided-on"] === undefined ? undefined : nullable(dueDate(values["decided-on"])),
|
|
2349
|
+
turnedDown: values["turned-down"],
|
|
2350
|
+
revisitOn:
|
|
2351
|
+
values["look-again"] === undefined ? undefined : nullable(dueDate(values["look-again"])),
|
|
2312
2352
|
update: values.update,
|
|
2313
2353
|
...Object.fromEntries(fields.map((f) => [f, values[f]])),
|
|
2314
2354
|
};
|
|
@@ -2370,11 +2410,15 @@ export async function run(argv, io = {}) {
|
|
|
2370
2410
|
spec(
|
|
2371
2411
|
[],
|
|
2372
2412
|
["project", "program", "owner", "due"].concat(
|
|
2373
|
-
command === "risk"
|
|
2413
|
+
command === "risk"
|
|
2414
|
+
? ["impact", "probability"]
|
|
2415
|
+
: command === "issue"
|
|
2416
|
+
? ["impact"]
|
|
2417
|
+
: ["supersedes", "turned-down", "look-again"],
|
|
2374
2418
|
),
|
|
2375
2419
|
),
|
|
2376
2420
|
);
|
|
2377
|
-
if (!values.project && !values.program) {
|
|
2421
|
+
if (!values.project && !values.program && !values.supersedes) {
|
|
2378
2422
|
throw new HarborError("Say where it belongs: --project NAME or --program NAME.");
|
|
2379
2423
|
}
|
|
2380
2424
|
return call(
|
|
@@ -2389,8 +2433,14 @@ export async function run(argv, io = {}) {
|
|
|
2389
2433
|
impact: level(values.impact, "impact"),
|
|
2390
2434
|
probability: level(values.probability, "probability"),
|
|
2391
2435
|
due: dueDate(values.due),
|
|
2436
|
+
supersedes: values.supersedes,
|
|
2437
|
+
turnedDown: values["turned-down"],
|
|
2438
|
+
revisitOn: values["look-again"] === undefined ? undefined : dueDate(values["look-again"]),
|
|
2392
2439
|
},
|
|
2393
|
-
(r) =>
|
|
2440
|
+
(r) =>
|
|
2441
|
+
r.replaces
|
|
2442
|
+
? `Logged the decision “${r.raised.title}”; it replaces “${r.replaces.title}”.`
|
|
2443
|
+
: `Logged the ${command} “${r.raised.title}”.`,
|
|
2394
2444
|
);
|
|
2395
2445
|
}
|
|
2396
2446
|
default:
|
package/lib/format.mjs
CHANGED
|
@@ -246,12 +246,19 @@ export function todayLocal(d = new Date()) {
|
|
|
246
246
|
}
|
|
247
247
|
|
|
248
248
|
/** The register, worst first: kind, title, then status, scores, owner and where it sits. */
|
|
249
|
-
export function raidList(r, words = "") {
|
|
249
|
+
export function raidList(r, words = "", kinds = []) {
|
|
250
250
|
const want = words.trim().toLowerCase();
|
|
251
251
|
const items = (r.items ?? []).filter(
|
|
252
252
|
(x) => !want || `${x.title} ${x.description ?? ""}`.toLowerCase().includes(want),
|
|
253
253
|
);
|
|
254
|
-
if (!items.length)
|
|
254
|
+
if (!items.length) {
|
|
255
|
+
if (want) return "Nothing in the register matches.";
|
|
256
|
+
const only =
|
|
257
|
+
kinds?.length === 1
|
|
258
|
+
? { risk: "risks", issue: "issues", decision: "decisions" }[kinds[0]]
|
|
259
|
+
: null;
|
|
260
|
+
return only ? `No ${only} in the register.` : "The register is clear.";
|
|
261
|
+
}
|
|
255
262
|
const lines = items.map((x) => {
|
|
256
263
|
const bits = [
|
|
257
264
|
x.status?.toLowerCase(),
|
|
@@ -480,6 +487,35 @@ export function voyageText(r) {
|
|
|
480
487
|
);
|
|
481
488
|
}
|
|
482
489
|
|
|
490
|
+
/** What's holding something up, as long_pole answers it: the headline, then the line of waits, first blocker at the top. */
|
|
491
|
+
export function whyText(r) {
|
|
492
|
+
const what = r.of.kind === "task" ? r.of.name : `${r.of.name}${r.of.kind === "milestone" ? " (milestone)" : ""}`;
|
|
493
|
+
if (r.none) return `What's holding up ${what}: ${r.none.replace(/^\w/, (c) => c.toLowerCase())}`;
|
|
494
|
+
const head = [`What's holding up ${what}`];
|
|
495
|
+
if (r.headline) head.push(r.headline);
|
|
496
|
+
const chain = r.chain ?? [];
|
|
497
|
+
const width = Math.min(12, Math.max(0, ...chain.map((l) => (l.key ?? "").length)));
|
|
498
|
+
const lines = chain.flatMap((l, i) => {
|
|
499
|
+
const due = l.due ? (l.late ? `was due ${l.due}` : `due ${l.due}`) : "no due date";
|
|
500
|
+
const finish = l.canFinish !== l.due ? ` · can finish ${l.canFinish}` : "";
|
|
501
|
+
const row = [
|
|
502
|
+
` ${(l.key ?? "").padEnd(width)}${width ? " " : ""}${l.title}`,
|
|
503
|
+
` ${" ".repeat(width)}${width ? " " : ""}${l.assignee ?? "nobody yet"} · ${due}${finish} · ${l.slackText}`,
|
|
504
|
+
];
|
|
505
|
+
return i < chain.length - 1 ? [...row, ` ${" ".repeat(width ? Math.floor(width / 2) : 0)}│`] : row;
|
|
506
|
+
});
|
|
507
|
+
const via = r.via ? [`It waits longest on ${r.via.project}; inside it:`] : [];
|
|
508
|
+
const others = (r.others ?? []).length
|
|
509
|
+
? [
|
|
510
|
+
"",
|
|
511
|
+
`Also waits on ${r.others
|
|
512
|
+
.map((o) => `${o.project} (finishes ${o.finish}, ${o.slack >= 0 ? `${o.slack} day${o.slack === 1 ? "" : "s"} spare` : `${-o.slack} day${o.slack === -1 ? "" : "s"} over`})`)
|
|
513
|
+
.join(", ")}.`,
|
|
514
|
+
]
|
|
515
|
+
: [];
|
|
516
|
+
return [...head, "", ...via, ...lines, ...others].join("\n");
|
|
517
|
+
}
|
|
518
|
+
|
|
483
519
|
/** A position on a 21-wide track, the top of the hill marked in the middle. */
|
|
484
520
|
function hillTrack(at) {
|
|
485
521
|
const cells = Array.from({ length: 21 }, (_, i) => (i === 10 ? "┼" : "─"));
|