@harborppm/cli 0.51.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 +2 -0
- package/dist/app.mjs +304 -27
- package/lib/cli.mjs +23 -0
- package/lib/format.mjs +29 -0
- 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
|
package/dist/app.mjs
CHANGED
|
@@ -3158,16 +3158,16 @@ const TRIALS = 1e3;
|
|
|
3158
3158
|
const MAX_WEEKS = 104;
|
|
3159
3159
|
/** Fewer finished tasks than this and the forecast says it's rough. */
|
|
3160
3160
|
const ROUGH_BELOW = 5;
|
|
3161
|
-
const noon$
|
|
3161
|
+
const noon$2 = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
|
|
3162
3162
|
/** Tasks finished in each of the last weeks, oldest first. Done tasks without a date, or dated after now, count as finished now. */
|
|
3163
3163
|
function weeklyFinished(tasks, startDate, now, weeks = 6) {
|
|
3164
|
-
const today = noon$
|
|
3165
|
-
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;
|
|
3166
3166
|
const span = Math.max(1, Math.min(weeks, age));
|
|
3167
3167
|
const counts = new Array(span).fill(0);
|
|
3168
3168
|
for (const t of tasks) {
|
|
3169
3169
|
if (t.status !== "done") continue;
|
|
3170
|
-
const at = t.completedAt ? noon$
|
|
3170
|
+
const at = t.completedAt ? noon$2(t.completedAt) : today;
|
|
3171
3171
|
const back = Math.max(0, differenceInCalendarDays(today, at));
|
|
3172
3172
|
const week = Math.floor(back / 7);
|
|
3173
3173
|
if (week < span) counts[span - 1 - week] += 1;
|
|
@@ -3273,7 +3273,7 @@ function tideForecast(project, tasks, now, opts = {}) {
|
|
|
3273
3273
|
remaining
|
|
3274
3274
|
};
|
|
3275
3275
|
const today = todayISO(now);
|
|
3276
|
-
const base = noon$
|
|
3276
|
+
const base = noon$2(today);
|
|
3277
3277
|
const paced = simulate(remaining, weekly, hash(`${project.id}|${remaining}|${today}`), Math.max(0, opts.pace ?? 1));
|
|
3278
3278
|
const at = (share, list = runs) => list[Math.min(list.length - 1, Math.ceil(share * list.length) - 1)];
|
|
3279
3279
|
let lastDue = null;
|
|
@@ -3281,12 +3281,12 @@ function tideForecast(project, tasks, now, opts = {}) {
|
|
|
3281
3281
|
if (t.status === "done" || !t.dueDate) continue;
|
|
3282
3282
|
if (!lastDue || t.dueDate > lastDue.dueDate) lastDue = t;
|
|
3283
3283
|
}
|
|
3284
|
-
const floor = lastDue ? differenceInCalendarDays(noon$
|
|
3284
|
+
const floor = lastDue ? differenceInCalendarDays(noon$2(lastDue.dueDate), base) : 0;
|
|
3285
3285
|
const sounded = soundingFloors(tasks, opts.reflections, base, hash(`${project.id}|sound|${today}`));
|
|
3286
3286
|
const runs = paced.map((d, i) => Math.max(d, floor, sounded.floors?.[i] ?? 0)).sort((a, b) => a - b);
|
|
3287
3287
|
const heldBy = lastDue && floor > at(.5, paced) ? lastDue : null;
|
|
3288
3288
|
const dayOf = (days) => Number.isFinite(days) ? todayISO(addDays(base, days)) : null;
|
|
3289
|
-
const toEnd = differenceInCalendarDays(noon$
|
|
3289
|
+
const toEnd = differenceInCalendarDays(noon$2(project.endDate), base);
|
|
3290
3290
|
const byEnd = runs.filter((d) => d <= toEnd).length / runs.length;
|
|
3291
3291
|
const last = Math.max(Number.isFinite(at(.99)) ? at(.99) : 728, toEnd + 14, 7);
|
|
3292
3292
|
const curve = [];
|
|
@@ -16966,7 +16966,7 @@ function dueFrom(value, now) {
|
|
|
16966
16966
|
* The CLI saves through the sync loop afterwards.
|
|
16967
16967
|
*/
|
|
16968
16968
|
init_fog();
|
|
16969
|
-
const plural$
|
|
16969
|
+
const plural$3 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
16970
16970
|
async function sweepFog(opts) {
|
|
16971
16971
|
const { store, ask, say, today } = opts;
|
|
16972
16972
|
const start = store.getState();
|
|
@@ -16982,7 +16982,7 @@ async function sweepFog(opts) {
|
|
|
16982
16982
|
say("No fog: every open task was touched in the last three weeks.");
|
|
16983
16983
|
return tally;
|
|
16984
16984
|
}
|
|
16985
|
-
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.`);
|
|
16986
16986
|
say("k still needed d new date x drop it s skip q stop\n");
|
|
16987
16987
|
let stopped = false;
|
|
16988
16988
|
for (let i = 0; i < queue.length && !stopped; i++) {
|
|
@@ -18281,9 +18281,9 @@ var commands_exports = /* @__PURE__ */ __exportAll({
|
|
|
18281
18281
|
/** The one line a sync ends with; a dry run's says what it would have done. */
|
|
18282
18282
|
function syncLine(report, dryRun = false) {
|
|
18283
18283
|
const parts = [
|
|
18284
|
-
report.sent.length ? `${dryRun ? "would send" : "sent"} ${plural$
|
|
18285
|
-
report.added.length ? `${dryRun ? "would add" : "added"} ${plural$
|
|
18286
|
-
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` : ""
|
|
18287
18287
|
].filter(Boolean);
|
|
18288
18288
|
if (dryRun) return `⚓ Dry run: ${parts.length ? parts.join(", ") : "already in step with Harbor"}. Nothing was written or sent.`;
|
|
18289
18289
|
return parts.length ? `⚓ ${parts.join(", ")}.` : "⚓ Already in step with Harbor.";
|
|
@@ -18338,13 +18338,13 @@ async function initHere(opts) {
|
|
|
18338
18338
|
dryRun: opts.dryRun
|
|
18339
18339
|
});
|
|
18340
18340
|
const { project, harbor } = made.config;
|
|
18341
|
-
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"}`;
|
|
18342
18342
|
if (opts.dryRun) {
|
|
18343
|
-
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}.`);
|
|
18344
18344
|
opts.say("Dry run: nothing was written or sent. Run it again without --dry-run to do it.");
|
|
18345
18345
|
return made.config;
|
|
18346
18346
|
}
|
|
18347
|
-
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.`);
|
|
18348
18348
|
opts.say("Edit a file, or add one, then run harbor sync. Changes on the website come back the same way.");
|
|
18349
18349
|
if (opts.hook) opts.say(await addHook(home));
|
|
18350
18350
|
return made.config;
|
|
@@ -18405,12 +18405,12 @@ async function openLocalPlan(dir, say) {
|
|
|
18405
18405
|
}]
|
|
18406
18406
|
};
|
|
18407
18407
|
}
|
|
18408
|
-
var plural$
|
|
18408
|
+
var plural$2, shownDir, HOOK;
|
|
18409
18409
|
var init_commands = __esmMin((() => {
|
|
18410
18410
|
init_errors();
|
|
18411
18411
|
init_folder();
|
|
18412
18412
|
init_repo_sync();
|
|
18413
|
-
plural$
|
|
18413
|
+
plural$2 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
18414
18414
|
shownDir = (cwd, dir) => `${relative(cwd, dir) || ".harbor"}/`;
|
|
18415
18415
|
HOOK = "harbor sync --quiet || true";
|
|
18416
18416
|
}));
|
|
@@ -21186,7 +21186,7 @@ function bucketFor(state, inst) {
|
|
|
21186
21186
|
return bucket;
|
|
21187
21187
|
}
|
|
21188
21188
|
let handoff;
|
|
21189
|
-
const open = [];
|
|
21189
|
+
const open$1 = [];
|
|
21190
21190
|
const memo = {
|
|
21191
21191
|
alloc(_inst, payload, empty) {
|
|
21192
21192
|
const bucket = handoff;
|
|
@@ -21197,7 +21197,7 @@ const memo = {
|
|
|
21197
21197
|
issues: null
|
|
21198
21198
|
};
|
|
21199
21199
|
bucket.set(payload.value, entry);
|
|
21200
|
-
open.push(entry);
|
|
21200
|
+
open$1.push(entry);
|
|
21201
21201
|
return empty;
|
|
21202
21202
|
},
|
|
21203
21203
|
guard(inst) {
|
|
@@ -21263,10 +21263,10 @@ const memo = {
|
|
|
21263
21263
|
return payload;
|
|
21264
21264
|
}
|
|
21265
21265
|
handoff = bucket;
|
|
21266
|
-
const depth = open.length;
|
|
21266
|
+
const depth = open$1.length;
|
|
21267
21267
|
const result = base(payload, ctx);
|
|
21268
21268
|
handoff = void 0;
|
|
21269
|
-
const entry = open.length > depth ? open.pop() : void 0;
|
|
21269
|
+
const entry = open$1.length > depth ? open$1.pop() : void 0;
|
|
21270
21270
|
if (result instanceof Promise) return result.then((r) => {
|
|
21271
21271
|
if (entry) entry.issues = r.issues.length ? cloneIssues(r.issues) : NO_ISSUES;
|
|
21272
21272
|
return r;
|
|
@@ -23638,18 +23638,18 @@ init_format();
|
|
|
23638
23638
|
const BURN_DAYS = 56;
|
|
23639
23639
|
/** A window shorter than a week says too little to read a rate from. */
|
|
23640
23640
|
const MIN_DAYS = 7;
|
|
23641
|
-
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`);
|
|
23642
23642
|
/**
|
|
23643
23643
|
* Spend a day over the last BURN_DAYS (or the project's life, when it's
|
|
23644
23644
|
* younger), from the ledger; null when nothing was spent in that time.
|
|
23645
23645
|
*/
|
|
23646
23646
|
function burnPerDay(entries, startDate, now) {
|
|
23647
|
-
const today = noon(todayISO(now));
|
|
23648
|
-
const age = differenceInCalendarDays(today, noon(startDate)) + 1;
|
|
23647
|
+
const today = noon$1(todayISO(now));
|
|
23648
|
+
const age = differenceInCalendarDays(today, noon$1(startDate)) + 1;
|
|
23649
23649
|
const span = Math.max(MIN_DAYS, Math.min(56, age));
|
|
23650
23650
|
let sum = 0;
|
|
23651
23651
|
for (const e of entries) {
|
|
23652
|
-
const back = differenceInCalendarDays(today, noon(e.date));
|
|
23652
|
+
const back = differenceInCalendarDays(today, noon$1(e.date));
|
|
23653
23653
|
if (back >= 0 && back < span) sum += e.amount;
|
|
23654
23654
|
}
|
|
23655
23655
|
return sum > 0 ? sum / span : null;
|
|
@@ -23666,8 +23666,8 @@ function slipCost(project, tide, entries, now) {
|
|
|
23666
23666
|
const from = project.endDate > today ? project.endDate : today;
|
|
23667
23667
|
const point = (date) => {
|
|
23668
23668
|
if (!date) return null;
|
|
23669
|
-
const daysLate = Math.max(0, differenceInCalendarDays(noon(date), noon(from)));
|
|
23670
|
-
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)));
|
|
23671
23671
|
return {
|
|
23672
23672
|
date,
|
|
23673
23673
|
daysLate,
|
|
@@ -23682,6 +23682,209 @@ function slipCost(project, tide, entries, now) {
|
|
|
23682
23682
|
};
|
|
23683
23683
|
}
|
|
23684
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
|
+
|
|
23685
23888
|
//#endregion
|
|
23686
23889
|
//#region src/lib/csv.ts
|
|
23687
23890
|
/**
|
|
@@ -24959,6 +25162,80 @@ const HARBOR_TOOLS = [
|
|
|
24959
25162
|
};
|
|
24960
25163
|
}
|
|
24961
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
|
+
}),
|
|
24962
25239
|
tool({
|
|
24963
25240
|
name: "shipping_forecast",
|
|
24964
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
|
|
@@ -721,6 +724,20 @@ const DETAIL = {
|
|
|
721
724
|
" never marks its own work Done; a person does, on the website or in the",
|
|
722
725
|
" terminal app.",
|
|
723
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
|
+
],
|
|
724
741
|
voyage: [
|
|
725
742
|
" harbor voyage PROJECT reads a Done project's voyage log",
|
|
726
743
|
' harbor voyage PROJECT "YOUR LINE" writes: adds or changes your line in it',
|
|
@@ -2173,6 +2190,12 @@ export async function run(argv, io = {}) {
|
|
|
2173
2190
|
soundingsText(r),
|
|
2174
2191
|
);
|
|
2175
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
|
+
}
|
|
2176
2199
|
case "voyage": {
|
|
2177
2200
|
const { positionals, harbor } = parse(spec());
|
|
2178
2201
|
const [project, ...words] = positionals;
|
package/lib/format.mjs
CHANGED
|
@@ -487,6 +487,35 @@ export function voyageText(r) {
|
|
|
487
487
|
);
|
|
488
488
|
}
|
|
489
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
|
+
|
|
490
519
|
/** A position on a 21-wide track, the top of the hill marked in the middle. */
|
|
491
520
|
function hillTrack(at) {
|
|
492
521
|
const cells = Array.from({ length: 21 }, (_, i) => (i === 10 ? "┼" : "─"));
|