@harborppm/cli 0.51.0 → 0.53.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 -0
- package/dist/app.mjs +871 -59
- package/lib/cli.mjs +44 -2
- package/lib/format.mjs +29 -0
- package/package.json +1 -1
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 = [];
|
|
@@ -3760,7 +3760,7 @@ function projectKeyFor(name, taken) {
|
|
|
3760
3760
|
for (let n = 2;; n++) if (!taken.has(`${base}${n}`)) return `${base}${n}`;
|
|
3761
3761
|
}
|
|
3762
3762
|
/** Whether `a` has the better claim to the number both hold. */
|
|
3763
|
-
function first(a, b, stored) {
|
|
3763
|
+
function first$1(a, b, stored) {
|
|
3764
3764
|
const ka = kept(a, stored);
|
|
3765
3765
|
if (ka !== kept(b, stored)) return ka;
|
|
3766
3766
|
return made(a, b) <= 0;
|
|
@@ -3808,7 +3808,7 @@ function settleKeys(data, stored) {
|
|
|
3808
3808
|
}
|
|
3809
3809
|
const other = holder.get(n);
|
|
3810
3810
|
if (other) {
|
|
3811
|
-
const [keep, move] = first(other, t, stored) ? [other, t] : [t, other];
|
|
3811
|
+
const [keep, move] = first$1(other, t, stored) ? [other, t] : [t, other];
|
|
3812
3812
|
holder.set(n, keep);
|
|
3813
3813
|
loose.push(move);
|
|
3814
3814
|
} else holder.set(n, t);
|
|
@@ -6485,6 +6485,493 @@ function BriefView({ active, width, height, go }) {
|
|
|
6485
6485
|
});
|
|
6486
6486
|
}
|
|
6487
6487
|
|
|
6488
|
+
//#endregion
|
|
6489
|
+
//#region src/lib/bridge.ts
|
|
6490
|
+
init_date_fns();
|
|
6491
|
+
init_indexes();
|
|
6492
|
+
init_raid();
|
|
6493
|
+
init_swatches();
|
|
6494
|
+
init_task_keys();
|
|
6495
|
+
init_format();
|
|
6496
|
+
/** How many items of one kind a slide lists before "and N more". */
|
|
6497
|
+
const LIST_CAP = 4;
|
|
6498
|
+
/** When a program's changes are counted from: its last meeting, else a week back. */
|
|
6499
|
+
function bridgeSince(program, now) {
|
|
6500
|
+
if (program.reviewedAt && !Number.isNaN(Date.parse(program.reviewedAt))) return program.reviewedAt;
|
|
6501
|
+
return addDays(now, -7).toISOString();
|
|
6502
|
+
}
|
|
6503
|
+
const after = (at, since) => !!at && !Number.isNaN(Date.parse(at)) && Date.parse(at) > Date.parse(since);
|
|
6504
|
+
const first = (name) => name?.split(" ")[0] ?? null;
|
|
6505
|
+
/** "RR-4 Draft the copy (Sam)", with the key when it has one. */
|
|
6506
|
+
function taskLine$1(t, project, doc) {
|
|
6507
|
+
const key = taskKey(t, project);
|
|
6508
|
+
const who = t.assigneeId ? first(byId(doc.people).get(t.assigneeId)?.name) : null;
|
|
6509
|
+
return `${key ? `${key} ` : ""}${t.title}${who ? ` (${who})` : ""}`;
|
|
6510
|
+
}
|
|
6511
|
+
function listed(prefix, items) {
|
|
6512
|
+
const shown = items.slice(0, 4);
|
|
6513
|
+
const rest = items.length - shown.length;
|
|
6514
|
+
return {
|
|
6515
|
+
text: `${prefix}${shown.join("; ")}${rest > 0 ? `; and ${rest} more` : ""}`,
|
|
6516
|
+
more: rest
|
|
6517
|
+
};
|
|
6518
|
+
}
|
|
6519
|
+
const highRated = (r) => r.kind !== "decision" && isActive(r) && (r.severity === "high" || (r.impact ?? 0) >= 4);
|
|
6520
|
+
/** What changed on one project since `since`: health, work finished, late or added, the register, the hill. */
|
|
6521
|
+
function projectChanges(doc, project, since, now) {
|
|
6522
|
+
const out = [];
|
|
6523
|
+
const today = todayISO(now);
|
|
6524
|
+
const sinceDay = since.slice(0, 10);
|
|
6525
|
+
const history = project.healthHistory ?? [];
|
|
6526
|
+
const moves = history.filter((h) => after(h.at, since));
|
|
6527
|
+
if (moves.length > 0) {
|
|
6528
|
+
const before = history[history.length - moves.length - 1]?.health ?? null;
|
|
6529
|
+
const last = moves[moves.length - 1];
|
|
6530
|
+
const note = last.note?.trim() ? ` “${last.note.trim()}”` : "";
|
|
6531
|
+
const word = (h) => HEALTH_LABEL$1[h] ?? h;
|
|
6532
|
+
out.push({
|
|
6533
|
+
kind: "health",
|
|
6534
|
+
text: before && before !== last.health ? `Went from ${word(before)} to ${word(last.health)}${note}` : `Now ${word(last.health)}${note}`,
|
|
6535
|
+
more: 0
|
|
6536
|
+
});
|
|
6537
|
+
}
|
|
6538
|
+
const tasks = tasksOf(doc.tasks, project.id);
|
|
6539
|
+
const finished = tasks.filter((t) => t.status === "done" && after(t.completedAt, since)).sort((a, b) => (b.completedAt ?? "").localeCompare(a.completedAt ?? ""));
|
|
6540
|
+
if (finished.length) out.push({
|
|
6541
|
+
kind: "finished",
|
|
6542
|
+
...listed("Finished: ", finished.map((t) => taskLine$1(t, project, doc)))
|
|
6543
|
+
});
|
|
6544
|
+
const late = tasks.filter((t) => t.status !== "done" && t.dueDate && t.dueDate >= sinceDay && t.dueDate < today).sort((a, b) => (a.dueDate ?? "").localeCompare(b.dueDate ?? ""));
|
|
6545
|
+
if (late.length) out.push({
|
|
6546
|
+
kind: "late",
|
|
6547
|
+
...listed("Now late: ", late.map((t) => taskLine$1(t, project, doc)))
|
|
6548
|
+
});
|
|
6549
|
+
const added = tasks.filter((t) => after(t.createdAt, since) && !finished.includes(t));
|
|
6550
|
+
if (added.length) out.push({
|
|
6551
|
+
kind: "added",
|
|
6552
|
+
...listed("Added: ", added.map((t) => taskLine$1(t, project, doc)))
|
|
6553
|
+
});
|
|
6554
|
+
const register = doc.raidItems.filter((r) => r.projectId === project.id);
|
|
6555
|
+
const decisions = register.filter((r) => r.kind === "decision" && after(r.updatedAt, since));
|
|
6556
|
+
const decided = decisions.filter((r) => r.status === "decided");
|
|
6557
|
+
if (decided.length) out.push({
|
|
6558
|
+
kind: "decided",
|
|
6559
|
+
...listed("Decided: ", decided.map((r) => r.title))
|
|
6560
|
+
});
|
|
6561
|
+
const proposed = decisions.filter((r) => r.status === "proposed");
|
|
6562
|
+
if (proposed.length) out.push({
|
|
6563
|
+
kind: "decided",
|
|
6564
|
+
...listed("To decide: ", proposed.map((r) => r.title))
|
|
6565
|
+
});
|
|
6566
|
+
const raised = register.filter((r) => r.kind !== "decision" && after(r.createdAt, since));
|
|
6567
|
+
if (raised.length) out.push({
|
|
6568
|
+
kind: "raised",
|
|
6569
|
+
...listed("Raised: ", raised.map((r) => `${r.kind === "issue" ? "issue" : "risk"} ${r.title}`))
|
|
6570
|
+
});
|
|
6571
|
+
const updated = register.filter((r) => r.kind !== "decision" && !raised.includes(r) && after(r.updatedAt, since));
|
|
6572
|
+
if (updated.length) out.push({
|
|
6573
|
+
kind: "updated",
|
|
6574
|
+
...listed("Updated: ", updated.map((r) => r.title))
|
|
6575
|
+
});
|
|
6576
|
+
if (after(project.hillMovedAt, since) && typeof project.hillAt === "number") out.push({
|
|
6577
|
+
kind: "hill",
|
|
6578
|
+
text: project.hillAt < 50 ? "Moved on the hill, still figuring it out" : "Moved on the hill, getting it done",
|
|
6579
|
+
more: 0
|
|
6580
|
+
});
|
|
6581
|
+
return out;
|
|
6582
|
+
}
|
|
6583
|
+
/**
|
|
6584
|
+
* The meeting's slides: the open projects of the given programs (in the
|
|
6585
|
+
* review's order), each with what changed. Projects where nothing moved
|
|
6586
|
+
* come back as `quiet`, named on the summary rather than given a screen.
|
|
6587
|
+
*/
|
|
6588
|
+
function bridgeSlides(doc, programs, now) {
|
|
6589
|
+
const today = todayISO(now);
|
|
6590
|
+
const slides = [];
|
|
6591
|
+
const quiet = [];
|
|
6592
|
+
const rank = {
|
|
6593
|
+
"off-track": 0,
|
|
6594
|
+
"at-risk": 1,
|
|
6595
|
+
"on-track": 2,
|
|
6596
|
+
paused: 3,
|
|
6597
|
+
done: 4
|
|
6598
|
+
};
|
|
6599
|
+
for (const program of programs) {
|
|
6600
|
+
if (program.health === "done") continue;
|
|
6601
|
+
const since = bridgeSince(program, now);
|
|
6602
|
+
const milestone = doc.milestones.filter((m) => m.programId === program.id && !m.done).sort((a, b) => a.dueDate.localeCompare(b.dueDate))[0] ?? null;
|
|
6603
|
+
const projects = doc.projects.filter((p) => p.programId === program.id && p.health !== "done").sort((a, b) => rank[a.health] - rank[b.health] || a.endDate.localeCompare(b.endDate));
|
|
6604
|
+
for (const project of projects) {
|
|
6605
|
+
const changes = projectChanges(doc, project, since, now);
|
|
6606
|
+
if (changes.length === 0) {
|
|
6607
|
+
quiet.push({
|
|
6608
|
+
program,
|
|
6609
|
+
project
|
|
6610
|
+
});
|
|
6611
|
+
continue;
|
|
6612
|
+
}
|
|
6613
|
+
const tasks = tasksOf(doc.tasks, project.id);
|
|
6614
|
+
slides.push({
|
|
6615
|
+
program,
|
|
6616
|
+
project,
|
|
6617
|
+
since,
|
|
6618
|
+
changes,
|
|
6619
|
+
late: tasks.filter((t) => t.status !== "done" && !!t.dueDate && t.dueDate < today).length,
|
|
6620
|
+
open: tasks.filter((t) => t.status !== "done").length,
|
|
6621
|
+
highRisks: doc.raidItems.filter((r) => r.projectId === project.id && highRated(r)).length,
|
|
6622
|
+
milestone
|
|
6623
|
+
});
|
|
6624
|
+
}
|
|
6625
|
+
}
|
|
6626
|
+
return {
|
|
6627
|
+
slides,
|
|
6628
|
+
quiet
|
|
6629
|
+
};
|
|
6630
|
+
}
|
|
6631
|
+
/**
|
|
6632
|
+
* The end summary, one part per program: projects covered and what was
|
|
6633
|
+
* logged on each, read back from the document (so an edit made since shows,
|
|
6634
|
+
* and an item since deleted drops out).
|
|
6635
|
+
*/
|
|
6636
|
+
function meetingSummary(doc, slides, logged) {
|
|
6637
|
+
const projects = byId(doc.projects);
|
|
6638
|
+
const people = byId(doc.people);
|
|
6639
|
+
const tasks = byId(doc.tasks);
|
|
6640
|
+
const raid = byId(doc.raidItems);
|
|
6641
|
+
const parts = /* @__PURE__ */ new Map();
|
|
6642
|
+
const partOf = (program) => {
|
|
6643
|
+
let part = parts.get(program.id);
|
|
6644
|
+
if (!part) {
|
|
6645
|
+
part = {
|
|
6646
|
+
program,
|
|
6647
|
+
covered: [],
|
|
6648
|
+
lines: []
|
|
6649
|
+
};
|
|
6650
|
+
parts.set(program.id, part);
|
|
6651
|
+
}
|
|
6652
|
+
return part;
|
|
6653
|
+
};
|
|
6654
|
+
for (const s of slides) partOf(s.program).covered.push(s.project);
|
|
6655
|
+
for (const item of logged) {
|
|
6656
|
+
if (item.kind === "action") {
|
|
6657
|
+
const t = tasks.get(item.id);
|
|
6658
|
+
const project = t ? projects.get(t.projectId) : void 0;
|
|
6659
|
+
const program = project ? doc.programs.find((p) => p.id === project.programId) : void 0;
|
|
6660
|
+
if (!t || !project || !program) continue;
|
|
6661
|
+
const who = t.assigneeId ? first(people.get(t.assigneeId)?.name) : null;
|
|
6662
|
+
const key = taskKey(t, project);
|
|
6663
|
+
partOf(program).lines.push({
|
|
6664
|
+
kind: "action",
|
|
6665
|
+
text: `${key ? `${key} ` : ""}${t.title}${who ? `, ${who}` : ""}${t.dueDate ? `, due ${t.dueDate}` : ""}`
|
|
6666
|
+
});
|
|
6667
|
+
continue;
|
|
6668
|
+
}
|
|
6669
|
+
const r = raid.get(item.id);
|
|
6670
|
+
const project = r?.projectId ? projects.get(r.projectId) : void 0;
|
|
6671
|
+
const program = project ? doc.programs.find((p) => p.id === project.programId) : void 0;
|
|
6672
|
+
if (!r || !program) continue;
|
|
6673
|
+
const said = r.kind === "decision" && r.resolution?.trim() ? `: ${r.resolution.trim()}` : "";
|
|
6674
|
+
partOf(program).lines.push({
|
|
6675
|
+
kind: item.kind,
|
|
6676
|
+
text: `${r.title}${said}`
|
|
6677
|
+
});
|
|
6678
|
+
}
|
|
6679
|
+
return [...parts.values()];
|
|
6680
|
+
}
|
|
6681
|
+
const LABEL = {
|
|
6682
|
+
decision: "Decided",
|
|
6683
|
+
action: "Action",
|
|
6684
|
+
risk: "Risk"
|
|
6685
|
+
};
|
|
6686
|
+
/** One program's part as the post for its chat channel: plain lines, capped well under every service's limit. */
|
|
6687
|
+
function summaryText(part, minutes) {
|
|
6688
|
+
const names = part.covered.map((p) => p.name);
|
|
6689
|
+
const head = `Review of ${part.program.name}: ${names.length} project${names.length === 1 ? "" : "s"} covered${minutes > 0 ? ` in ${minutes} min` : ""}${names.length ? ` (${names.join(", ")})` : ""}.`;
|
|
6690
|
+
const lines = part.lines.map((l) => `${LABEL[l.kind]}: ${l.text}`);
|
|
6691
|
+
return `${head}\n${lines.length ? lines.join("\n") : "Nothing new logged."}`.slice(0, 2e3);
|
|
6692
|
+
}
|
|
6693
|
+
/** The ids of programs whose projects the meeting covered, to stamp `reviewedAt` on. */
|
|
6694
|
+
function reviewedPrograms(slides, quiet) {
|
|
6695
|
+
return [.../* @__PURE__ */ new Set([...slides.map((s) => s.program.id), ...quiet.map((q) => q.program.id)])];
|
|
6696
|
+
}
|
|
6697
|
+
|
|
6698
|
+
//#endregion
|
|
6699
|
+
//#region src/tui/views/bridge.tsx
|
|
6700
|
+
init_date_fns();
|
|
6701
|
+
init_date_text();
|
|
6702
|
+
const WORD = {
|
|
6703
|
+
decision: "Decided",
|
|
6704
|
+
action: "Action",
|
|
6705
|
+
risk: "Risk"
|
|
6706
|
+
};
|
|
6707
|
+
/**
|
|
6708
|
+
* Bridge mode, as on the website's review: the meeting one project at a
|
|
6709
|
+
* time with only what changed since the program was last reviewed (←→ or
|
|
6710
|
+
* j k), d a decision, a an action, r a risk, m the program's next move, e
|
|
6711
|
+
* ends it. The summary lists what was logged; p posts it to each program's
|
|
6712
|
+
* chat channel where one is set, y copies it.
|
|
6713
|
+
*/
|
|
6714
|
+
function BridgeView({ active, width, height, go, programIds, onBack }) {
|
|
6715
|
+
const { store, transport, today } = useDeps();
|
|
6716
|
+
const workspaceId = useHarbor((s) => s.workspaceId);
|
|
6717
|
+
const viewOnly = useHarbor((s) => s.myRole === "viewer" || s.keepPaused);
|
|
6718
|
+
const programs = useHarbor((s) => s.programs);
|
|
6719
|
+
const projects = useHarbor((s) => s.projects);
|
|
6720
|
+
const tasks = useHarbor((s) => s.tasks);
|
|
6721
|
+
const raidItems = useHarbor((s) => s.raidItems);
|
|
6722
|
+
const people = useHarbor((s) => s.people);
|
|
6723
|
+
const milestones = useHarbor((s) => s.milestones);
|
|
6724
|
+
const me = useHarbor((s) => s.currentUserId);
|
|
6725
|
+
const { stdout } = useStdout();
|
|
6726
|
+
const data = useMemo(() => ({
|
|
6727
|
+
programs,
|
|
6728
|
+
projects,
|
|
6729
|
+
tasks,
|
|
6730
|
+
raidItems,
|
|
6731
|
+
people,
|
|
6732
|
+
milestones
|
|
6733
|
+
}), [
|
|
6734
|
+
programs,
|
|
6735
|
+
projects,
|
|
6736
|
+
tasks,
|
|
6737
|
+
raidItems,
|
|
6738
|
+
people,
|
|
6739
|
+
milestones
|
|
6740
|
+
]);
|
|
6741
|
+
const [started] = useState(() => /* @__PURE__ */ new Date());
|
|
6742
|
+
const [{ slides, quiet }] = useState(() => {
|
|
6743
|
+
const picked = programIds.map((id) => store.getState().programs.find((p) => p.id === id)).filter((p) => p !== void 0);
|
|
6744
|
+
return bridgeSlides(store.getState(), picked, started);
|
|
6745
|
+
});
|
|
6746
|
+
const [index, setIndex] = useState(0);
|
|
6747
|
+
const [logged, setLogged] = useState([]);
|
|
6748
|
+
const [ended, setEnded] = useState(slides.length === 0 ? started : null);
|
|
6749
|
+
const [channels, setChannels] = useState({});
|
|
6750
|
+
const parts = useMemo(() => meetingSummary(data, slides, logged), [
|
|
6751
|
+
data,
|
|
6752
|
+
slides,
|
|
6753
|
+
logged
|
|
6754
|
+
]);
|
|
6755
|
+
const minutes = ended ? Math.round((ended.getTime() - started.getTime()) / 6e4) : 0;
|
|
6756
|
+
useEffect(() => {
|
|
6757
|
+
if (!ended || !transport.meetingPost || viewOnly || !workspaceId) return;
|
|
6758
|
+
let live = true;
|
|
6759
|
+
for (const part of parts) transport.meetingPost(workspaceId, part.program.id).then((a) => {
|
|
6760
|
+
if (live && a.set && a.service) setChannels((c) => ({
|
|
6761
|
+
...c,
|
|
6762
|
+
[part.program.id]: a.service
|
|
6763
|
+
}));
|
|
6764
|
+
}).catch(() => {});
|
|
6765
|
+
return () => {
|
|
6766
|
+
live = false;
|
|
6767
|
+
};
|
|
6768
|
+
}, [ended]);
|
|
6769
|
+
const end = () => {
|
|
6770
|
+
const at = /* @__PURE__ */ new Date();
|
|
6771
|
+
setEnded(at);
|
|
6772
|
+
if (!viewOnly) store.getState().markReviewed(reviewedPrograms(slides, quiet), at.toISOString());
|
|
6773
|
+
};
|
|
6774
|
+
const slide = slides[index];
|
|
6775
|
+
const program = slide ? programs.find((p) => p.id === slide.program.id) ?? slide.program : null;
|
|
6776
|
+
const add = (kind, id) => setLogged((l) => [...l, {
|
|
6777
|
+
kind,
|
|
6778
|
+
id
|
|
6779
|
+
}]);
|
|
6780
|
+
const logDecision = () => {
|
|
6781
|
+
if (!slide) return;
|
|
6782
|
+
go.ask(`Decision on ${slide.project.name}: what was it about?`, (title) => {
|
|
6783
|
+
if (!title.trim()) return;
|
|
6784
|
+
go.ask("What was decided? (Enter to skip)", (said) => {
|
|
6785
|
+
add("decision", store.getState().addRaidItem({
|
|
6786
|
+
kind: "decision",
|
|
6787
|
+
title: title.trim(),
|
|
6788
|
+
projectId: slide.project.id,
|
|
6789
|
+
ownerId: me,
|
|
6790
|
+
status: "decided",
|
|
6791
|
+
resolution: said.trim(),
|
|
6792
|
+
decidedById: me,
|
|
6793
|
+
decidedAt: today()
|
|
6794
|
+
}));
|
|
6795
|
+
go.notify("Decision logged.");
|
|
6796
|
+
});
|
|
6797
|
+
});
|
|
6798
|
+
};
|
|
6799
|
+
const logAction = () => {
|
|
6800
|
+
if (!slide) return;
|
|
6801
|
+
go.ask(`Action on ${slide.project.name}: what needs doing?`, (title) => {
|
|
6802
|
+
if (!title.trim()) return;
|
|
6803
|
+
const lead = people.find((p) => p.id === slide.project.ownerId);
|
|
6804
|
+
go.pick("Who takes it?", [
|
|
6805
|
+
...lead ? [{
|
|
6806
|
+
label: lead.name,
|
|
6807
|
+
value: lead.id,
|
|
6808
|
+
hint: "leads it"
|
|
6809
|
+
}] : [],
|
|
6810
|
+
...people.filter((p) => p.id !== lead?.id && p.kind !== "agent").map((p) => ({
|
|
6811
|
+
label: p.name,
|
|
6812
|
+
value: p.id,
|
|
6813
|
+
hint: p.role
|
|
6814
|
+
})),
|
|
6815
|
+
{
|
|
6816
|
+
label: "Nobody yet",
|
|
6817
|
+
value: null
|
|
6818
|
+
}
|
|
6819
|
+
], (who) => {
|
|
6820
|
+
const due = addDays(/* @__PURE__ */ new Date(`${today()}T12:00:00`), 7).toISOString().slice(0, 10);
|
|
6821
|
+
add("action", store.getState().addTask({
|
|
6822
|
+
projectId: slide.project.id,
|
|
6823
|
+
title: title.trim(),
|
|
6824
|
+
description: "",
|
|
6825
|
+
status: "todo",
|
|
6826
|
+
priority: "medium",
|
|
6827
|
+
assigneeId: who ?? null,
|
|
6828
|
+
dueDate: due
|
|
6829
|
+
}, { allowOverCap: true }));
|
|
6830
|
+
go.notify(`Action added, due ${shortDay(due, today())}.`);
|
|
6831
|
+
});
|
|
6832
|
+
});
|
|
6833
|
+
};
|
|
6834
|
+
const logRisk = () => {
|
|
6835
|
+
if (!slide) return;
|
|
6836
|
+
go.ask(`Risk on ${slide.project.name}: what could go wrong?`, (title) => {
|
|
6837
|
+
if (!title.trim()) return;
|
|
6838
|
+
add("risk", store.getState().addRaidItem({
|
|
6839
|
+
kind: "risk",
|
|
6840
|
+
title: title.trim(),
|
|
6841
|
+
projectId: slide.project.id,
|
|
6842
|
+
ownerId: slide.project.ownerId || null,
|
|
6843
|
+
status: "open"
|
|
6844
|
+
}));
|
|
6845
|
+
go.notify("Risk raised.");
|
|
6846
|
+
});
|
|
6847
|
+
};
|
|
6848
|
+
const post = async () => {
|
|
6849
|
+
const targets = parts.filter((p) => channels[p.program.id]);
|
|
6850
|
+
if (!transport.meetingPost || !workspaceId || targets.length === 0) return go.notify("None of these programs has a chat channel. Set one on the website's program page.");
|
|
6851
|
+
let ok = 0;
|
|
6852
|
+
for (const part of targets) if ((await transport.meetingPost(workspaceId, part.program.id, summaryText(part, minutes)).catch((e) => {
|
|
6853
|
+
go.notify(`Didn't post: ${e.message}`);
|
|
6854
|
+
return null;
|
|
6855
|
+
}))?.posted) ok++;
|
|
6856
|
+
if (ok) go.notify(`Posted to ${ok} channel${ok === 1 ? "" : "s"}.`);
|
|
6857
|
+
};
|
|
6858
|
+
useInput((input, key) => {
|
|
6859
|
+
if (ended) {
|
|
6860
|
+
if (key.escape) return onBack();
|
|
6861
|
+
if (input === "y") {
|
|
6862
|
+
stdout.write(copySequence(parts.map((p) => summaryText(p, minutes)).join("\n\n")));
|
|
6863
|
+
return go.notify("Copied the summary, if this terminal lets apps copy.");
|
|
6864
|
+
}
|
|
6865
|
+
if (input === "p" && !viewOnly) return void post();
|
|
6866
|
+
return;
|
|
6867
|
+
}
|
|
6868
|
+
if (key.escape || input === "e") return end();
|
|
6869
|
+
if (key.rightArrow || input === "j" || input === " ") return setIndex((i) => Math.min(slides.length - 1, i + 1));
|
|
6870
|
+
if (key.leftArrow || input === "k") return setIndex((i) => Math.max(0, i - 1));
|
|
6871
|
+
if (viewOnly) return;
|
|
6872
|
+
if (input === "d") return logDecision();
|
|
6873
|
+
if (input === "a") return logAction();
|
|
6874
|
+
if (input === "r") return logRisk();
|
|
6875
|
+
if (input === "m" && program) return go.ask(`Next move for ${program.name}`, (text) => store.getState().setNextMove(program.id, text.trim()), program.nextMove ?? "");
|
|
6876
|
+
}, { isActive: active });
|
|
6877
|
+
const w = width - 2;
|
|
6878
|
+
if (ended) {
|
|
6879
|
+
const text = [
|
|
6880
|
+
parts.length ? `${parts.reduce((n, p) => n + p.covered.length, 0)} projects covered${minutes ? ` in ${minutes} min` : ""}. The next meeting shows what changes from here.` : "Nothing changed since the last review.",
|
|
6881
|
+
...parts.flatMap((part) => [
|
|
6882
|
+
"",
|
|
6883
|
+
`${part.program.name}${channels[part.program.id] ? ` · ${channels[part.program.id]} channel` : ""}`,
|
|
6884
|
+
`Covered: ${part.covered.map((p) => p.name).join(", ") || "none"}`,
|
|
6885
|
+
...part.lines.length ? part.lines.map((l) => `• ${WORD[l.kind]}: ${l.text}`) : ["Nothing new logged."]
|
|
6886
|
+
]),
|
|
6887
|
+
...quiet.length ? ["", `No change on ${quiet.map((q) => q.project.name).join(", ")}.`] : []
|
|
6888
|
+
].join("\n");
|
|
6889
|
+
const lines = wrapLines(text, w).slice(0, Math.max(1, height - 2));
|
|
6890
|
+
return /* @__PURE__ */ jsxs(Box, {
|
|
6891
|
+
flexDirection: "column",
|
|
6892
|
+
paddingX: 1,
|
|
6893
|
+
width,
|
|
6894
|
+
children: [/* @__PURE__ */ jsx(Heading, {
|
|
6895
|
+
right: `${Object.keys(channels).length && !viewOnly ? "p post · " : ""}y copy · Esc back`,
|
|
6896
|
+
children: "Meeting summary"
|
|
6897
|
+
}), lines.map((line, i) => /* @__PURE__ */ jsx(Text, {
|
|
6898
|
+
wrap: "truncate-end",
|
|
6899
|
+
children: line || " "
|
|
6900
|
+
}, i))]
|
|
6901
|
+
});
|
|
6902
|
+
}
|
|
6903
|
+
if (!slide || !program) return null;
|
|
6904
|
+
const owner = people.find((p) => p.id === slide.project.ownerId);
|
|
6905
|
+
const here = meetingSummary(data, [], logged.filter((l) => l.kind === "action" ? tasks.some((t) => t.id === l.id && t.projectId === slide.project.id) : raidItems.some((r) => r.id === l.id && r.projectId === slide.project.id))).flatMap((p) => p.lines);
|
|
6906
|
+
const text = [
|
|
6907
|
+
[
|
|
6908
|
+
HEALTH_LABEL[slide.project.health],
|
|
6909
|
+
owner?.name,
|
|
6910
|
+
`ends ${shortDay(slide.project.endDate, today())}`,
|
|
6911
|
+
`${slide.late} late of ${slide.open} open`,
|
|
6912
|
+
slide.highRisks ? `${slide.highRisks} high risk${slide.highRisks === 1 ? "" : "s"}` : null
|
|
6913
|
+
].filter(Boolean).join(" · "),
|
|
6914
|
+
slide.milestone ? `Next milestone: ${slide.milestone.name}, ${shortDay(slide.milestone.dueDate, today())}` : "",
|
|
6915
|
+
"",
|
|
6916
|
+
`Since ${program.reviewedAt ? "the last review" : "last week"}, ${timeAgo(new Date(slide.since), started)}:`,
|
|
6917
|
+
...slide.changes.map((c) => `• ${c.text}`),
|
|
6918
|
+
...here.length ? [
|
|
6919
|
+
"",
|
|
6920
|
+
"Logged in this meeting:",
|
|
6921
|
+
...here.map((l) => `• ${WORD[l.kind]}: ${l.text}`)
|
|
6922
|
+
] : [],
|
|
6923
|
+
"",
|
|
6924
|
+
`Next move: ${program.nextMove?.trim() || "none yet"}`
|
|
6925
|
+
].filter((l, i, all) => l !== "" || all[i - 1] !== "").join("\n");
|
|
6926
|
+
const lines = wrapLines(text, w).slice(0, Math.max(1, height - 3));
|
|
6927
|
+
return /* @__PURE__ */ jsxs(Box, {
|
|
6928
|
+
flexDirection: "column",
|
|
6929
|
+
paddingX: 1,
|
|
6930
|
+
width,
|
|
6931
|
+
children: [
|
|
6932
|
+
/* @__PURE__ */ jsxs(Heading, {
|
|
6933
|
+
right: `${index + 1} of ${slides.length} · ${program.name}`,
|
|
6934
|
+
children: [/* @__PURE__ */ jsx(Text, {
|
|
6935
|
+
color: theme.accent,
|
|
6936
|
+
children: "Meeting ▸ "
|
|
6937
|
+
}), slide.project.name]
|
|
6938
|
+
}),
|
|
6939
|
+
lines.map((line, i) => /* @__PURE__ */ jsx(Text, {
|
|
6940
|
+
wrap: "truncate-end",
|
|
6941
|
+
children: line || " "
|
|
6942
|
+
}, i)),
|
|
6943
|
+
/* @__PURE__ */ jsxs(Text, {
|
|
6944
|
+
color: theme.muted,
|
|
6945
|
+
wrap: "truncate-end",
|
|
6946
|
+
children: [
|
|
6947
|
+
"←→ projects",
|
|
6948
|
+
viewOnly ? "" : " · d decision · a action · r risk · m next move",
|
|
6949
|
+
" · e end"
|
|
6950
|
+
]
|
|
6951
|
+
})
|
|
6952
|
+
]
|
|
6953
|
+
});
|
|
6954
|
+
}
|
|
6955
|
+
|
|
6956
|
+
//#endregion
|
|
6957
|
+
//#region src/lib/review.ts
|
|
6958
|
+
init_indexes();
|
|
6959
|
+
init_derived_health();
|
|
6960
|
+
/** A program is someone's when they lead it, lead one of its projects or hold a task in one. */
|
|
6961
|
+
function programTouches(program, data, personId) {
|
|
6962
|
+
if (program.ownerId === personId) return true;
|
|
6963
|
+
return data.projects.some((p) => p.programId === program.id && (p.ownerId === personId || tasksOf(data.tasks, p.id).some((t) => t.assigneeId === personId)));
|
|
6964
|
+
}
|
|
6965
|
+
/**
|
|
6966
|
+
* The review's programs: worst health first, then soonest end, narrowed to
|
|
6967
|
+
* one person's and one portfolio's when asked. Done ones are left in; the
|
|
6968
|
+
* website, the terminal and the agents' tool each tuck them away themselves.
|
|
6969
|
+
*/
|
|
6970
|
+
function reviewPrograms(data, opts = {}) {
|
|
6971
|
+
const { ownerId, portfolioId } = opts;
|
|
6972
|
+
return data.programs.filter((p) => (!portfolioId || p.portfolioId === portfolioId) && (!ownerId || programTouches(p, data, ownerId))).sort((a, b) => rankHealth(a.health) - rankHealth(b.health) || a.endDate.localeCompare(b.endDate));
|
|
6973
|
+
}
|
|
6974
|
+
|
|
6488
6975
|
//#endregion
|
|
6489
6976
|
//#region src/tui/moves.ts
|
|
6490
6977
|
init_board();
|
|
@@ -7747,26 +8234,6 @@ var init_load = __esmMin((() => {
|
|
|
7747
8234
|
};
|
|
7748
8235
|
}));
|
|
7749
8236
|
|
|
7750
|
-
//#endregion
|
|
7751
|
-
//#region src/lib/review.ts
|
|
7752
|
-
init_load();
|
|
7753
|
-
init_indexes();
|
|
7754
|
-
init_derived_health();
|
|
7755
|
-
/** A program is someone's when they lead it, lead one of its projects or hold a task in one. */
|
|
7756
|
-
function programTouches(program, data, personId) {
|
|
7757
|
-
if (program.ownerId === personId) return true;
|
|
7758
|
-
return data.projects.some((p) => p.programId === program.id && (p.ownerId === personId || tasksOf(data.tasks, p.id).some((t) => t.assigneeId === personId)));
|
|
7759
|
-
}
|
|
7760
|
-
/**
|
|
7761
|
-
* The review's programs: worst health first, then soonest end, narrowed to
|
|
7762
|
-
* one person's and one portfolio's when asked. Done ones are left in; the
|
|
7763
|
-
* website, the terminal and the agents' tool each tuck them away themselves.
|
|
7764
|
-
*/
|
|
7765
|
-
function reviewPrograms(data, opts = {}) {
|
|
7766
|
-
const { ownerId, portfolioId } = opts;
|
|
7767
|
-
return data.programs.filter((p) => (!portfolioId || p.portfolioId === portfolioId) && (!ownerId || programTouches(p, data, ownerId))).sort((a, b) => rankHealth(a.health) - rankHealth(b.health) || a.endDate.localeCompare(b.endDate));
|
|
7768
|
-
}
|
|
7769
|
-
|
|
7770
8237
|
//#endregion
|
|
7771
8238
|
//#region src/lib/priority.ts
|
|
7772
8239
|
/** Rules with anything missing or out of range brought back to the defaults. */
|
|
@@ -7804,6 +8271,7 @@ var init_priority = __esmMin((() => {
|
|
|
7804
8271
|
|
|
7805
8272
|
//#endregion
|
|
7806
8273
|
//#region src/tui/views/heat.tsx
|
|
8274
|
+
init_load();
|
|
7807
8275
|
init_priority();
|
|
7808
8276
|
init_raid();
|
|
7809
8277
|
const CELL = 7;
|
|
@@ -9242,7 +9710,7 @@ function ProgramsView({ active, width, height, go }) {
|
|
|
9242
9710
|
const where = portfolio ? ` in ${portfolios.find((p) => p.id === portfolio)?.name ?? "a portfolio"}` : "";
|
|
9243
9711
|
return /* @__PURE__ */ jsx(RowList, {
|
|
9244
9712
|
title: `${whose}${where}`,
|
|
9245
|
-
right: `${count(listed.length, "program")}${done ? ` · ${done} done ${showDone ? "shown" : "hidden"} (D)` : ""} · m next move · h health · o p narrow`,
|
|
9713
|
+
right: `${count(listed.length, "program")}${done ? ` · ${done} done ${showDone ? "shown" : "hidden"} (D)` : ""} · b run the meeting · m next move · h health · o p narrow`,
|
|
9246
9714
|
rows,
|
|
9247
9715
|
empty: done ? "Every program here is done. D shows them." : owner || portfolio ? "Nothing here. o and p change what's shown." : "No programs yet. n adds one.",
|
|
9248
9716
|
active,
|
|
@@ -9255,6 +9723,7 @@ function ProgramsView({ active, width, height, go }) {
|
|
|
9255
9723
|
go.create("program", portfolioId ? { portfolioId } : {});
|
|
9256
9724
|
},
|
|
9257
9725
|
e: (r) => r && go.openPanel("program", r.id),
|
|
9726
|
+
b: () => split.open.length ? go.openBridge(split.open.map((p) => p.id)) : go.notify("No open programs here to review."),
|
|
9258
9727
|
m: (r) => {
|
|
9259
9728
|
const program = r && programs.find((p) => p.id === r.id);
|
|
9260
9729
|
if (!program) return;
|
|
@@ -12858,6 +13327,8 @@ const NAV = [
|
|
|
12858
13327
|
const navKey = (i) => i < 10 ? String((i + 1) % 10) : "C";
|
|
12859
13328
|
/** The sidebar's width: its longest line ("9 Calendar", ": Anything") with room either side. */
|
|
12860
13329
|
const NAV_WIDTH = 15;
|
|
13330
|
+
/** Every open program, in the review's order: what a meeting started from the palette covers. */
|
|
13331
|
+
const openReviewIds = (data) => reviewPrograms(data).filter((p) => p.health !== "done").map((p) => p.id);
|
|
12861
13332
|
/** What the corner says about saving, and in which of the theme's colours. */
|
|
12862
13333
|
const SAVE_LABEL = {
|
|
12863
13334
|
saved: ["● saved", "good"],
|
|
@@ -12866,7 +13337,7 @@ const SAVE_LABEL = {
|
|
|
12866
13337
|
retrying: ["● not saved, retrying", "bad"],
|
|
12867
13338
|
offline: ["● offline, will save", "bad"]
|
|
12868
13339
|
};
|
|
12869
|
-
function App({ ui }) {
|
|
13340
|
+
function App({ ui, startIn }) {
|
|
12870
13341
|
const { store, sync, account, harbors, today, remember } = useDeps();
|
|
12871
13342
|
const { exit } = useApp();
|
|
12872
13343
|
const { columns, rows } = useWindowSize();
|
|
@@ -12879,7 +13350,11 @@ function App({ ui }) {
|
|
|
12879
13350
|
const save = useStore(ui, (s) => s.save);
|
|
12880
13351
|
const notice = useStore(ui, (s) => s.notice);
|
|
12881
13352
|
const [screen, setScreen] = useState("today");
|
|
12882
|
-
const [stack, setStack] = useState([
|
|
13353
|
+
const [stack, setStack] = useState(() => startIn === "bridge" ? [{
|
|
13354
|
+
kind: "bridge",
|
|
13355
|
+
id: "bridge-start",
|
|
13356
|
+
programIds: openReviewIds(store.getState())
|
|
13357
|
+
}] : []);
|
|
12883
13358
|
const [panel, setPanel] = useState(null);
|
|
12884
13359
|
const [overlay, setOverlay] = useState(null);
|
|
12885
13360
|
const [focus, setFocus] = useState("main");
|
|
@@ -12916,6 +13391,11 @@ function App({ ui }) {
|
|
|
12916
13391
|
kind: "portfolio",
|
|
12917
13392
|
id
|
|
12918
13393
|
}),
|
|
13394
|
+
openBridge: (programIds) => push({
|
|
13395
|
+
kind: "bridge",
|
|
13396
|
+
id: `bridge-${Date.now()}`,
|
|
13397
|
+
programIds
|
|
13398
|
+
}),
|
|
12919
13399
|
openPanel: (kind, id) => {
|
|
12920
13400
|
setPanel({
|
|
12921
13401
|
kind,
|
|
@@ -13009,6 +13489,11 @@ function App({ ui }) {
|
|
|
13009
13489
|
hint: "make",
|
|
13010
13490
|
run: () => go.create("raid")
|
|
13011
13491
|
},
|
|
13492
|
+
{
|
|
13493
|
+
label: "Run the review meeting",
|
|
13494
|
+
hint: "bridge",
|
|
13495
|
+
run: () => go.openBridge(openReviewIds(store.getState()))
|
|
13496
|
+
},
|
|
13012
13497
|
{
|
|
13013
13498
|
label: "Find a task",
|
|
13014
13499
|
hint: "/",
|
|
@@ -13045,7 +13530,7 @@ function App({ ui }) {
|
|
|
13045
13530
|
if (input === "?") return setOverlay({ kind: "help" });
|
|
13046
13531
|
if (input === "H") return setOverlay({ kind: "harbors" });
|
|
13047
13532
|
if (input === "C") return goScreen("crew");
|
|
13048
|
-
if (input === "r") {
|
|
13533
|
+
if (input === "r" && page?.kind !== "bridge") {
|
|
13049
13534
|
sync.retryNow();
|
|
13050
13535
|
return notify("Saving now.");
|
|
13051
13536
|
}
|
|
@@ -13178,7 +13663,7 @@ function App({ ui }) {
|
|
|
13178
13663
|
close();
|
|
13179
13664
|
then(text);
|
|
13180
13665
|
}
|
|
13181
|
-
})
|
|
13666
|
+
}, `${label}\u0000${initial ?? ""}`)
|
|
13182
13667
|
});
|
|
13183
13668
|
} else if (overlay?.kind === "read") overlayNode = /* @__PURE__ */ jsx(Letter, {
|
|
13184
13669
|
title: overlay.title,
|
|
@@ -13248,7 +13733,12 @@ function App({ ui }) {
|
|
|
13248
13733
|
id: panel.id,
|
|
13249
13734
|
...panelProps
|
|
13250
13735
|
});
|
|
13251
|
-
const hints = panel ? [["Esc", "back"], ["u", "undo"]] : page?.kind === "
|
|
13736
|
+
const hints = panel ? [["Esc", "back"], ["u", "undo"]] : page?.kind === "bridge" ? [
|
|
13737
|
+
["←→", "projects"],
|
|
13738
|
+
["d a r", "log"],
|
|
13739
|
+
["e", "end"],
|
|
13740
|
+
["Esc", "end/back"]
|
|
13741
|
+
] : page?.kind === "project" ? [
|
|
13252
13742
|
["←→↑↓", "move"],
|
|
13253
13743
|
["Enter", "open"],
|
|
13254
13744
|
["n", "new"],
|
|
@@ -13279,7 +13769,12 @@ function App({ ui }) {
|
|
|
13279
13769
|
["q", "quit"]
|
|
13280
13770
|
];
|
|
13281
13771
|
let screenNode;
|
|
13282
|
-
if (page?.kind === "
|
|
13772
|
+
if (page?.kind === "bridge") screenNode = /* @__PURE__ */ jsx(BridgeView, {
|
|
13773
|
+
...viewProps,
|
|
13774
|
+
programIds: page.programIds,
|
|
13775
|
+
onBack: back
|
|
13776
|
+
}, page.id);
|
|
13777
|
+
else if (page?.kind === "project") screenNode = /* @__PURE__ */ jsx(ProjectView, {
|
|
13283
13778
|
...viewProps,
|
|
13284
13779
|
projectId: page.id,
|
|
13285
13780
|
onBack: back
|
|
@@ -14405,6 +14900,15 @@ function hierarchySlice({ get, write }) {
|
|
|
14405
14900
|
nextMove: text
|
|
14406
14901
|
} : p) });
|
|
14407
14902
|
},
|
|
14903
|
+
markReviewed: (programIds, at) => {
|
|
14904
|
+
const ids = new Set(programIds);
|
|
14905
|
+
if (ids.size === 0) return;
|
|
14906
|
+
const when = at ?? stamp();
|
|
14907
|
+
write({ programs: get().programs.map((p) => ids.has(p.id) ? {
|
|
14908
|
+
...p,
|
|
14909
|
+
reviewedAt: when
|
|
14910
|
+
} : p) });
|
|
14911
|
+
},
|
|
14408
14912
|
setHealthOverride: (kind, id, health, note) => {
|
|
14409
14913
|
const at = stamp();
|
|
14410
14914
|
const body = {
|
|
@@ -16486,7 +16990,8 @@ function plainTransport(t) {
|
|
|
16486
16990
|
history: async (query) => plainDeep(await t.history(query)),
|
|
16487
16991
|
create: async (name) => plainDeep(await t.create(name)),
|
|
16488
16992
|
crew: async (workspaceId, act) => plainDeep(await t.crew(workspaceId, act)),
|
|
16489
|
-
invite: async (workspaceId, email, role) => plainDeep(await t.invite(workspaceId, email, role))
|
|
16993
|
+
invite: async (workspaceId, email, role) => plainDeep(await t.invite(workspaceId, email, role)),
|
|
16994
|
+
...t.meetingPost ? { meetingPost: async (workspaceId, programId, text) => plainDeep(await t.meetingPost(workspaceId, programId, text)) } : {}
|
|
16490
16995
|
};
|
|
16491
16996
|
}
|
|
16492
16997
|
function httpTransport(source, fetcher = fetch) {
|
|
@@ -16550,6 +17055,12 @@ function httpTransport(source, fetcher = fetch) {
|
|
|
16550
17055
|
harbor: workspaceId,
|
|
16551
17056
|
email,
|
|
16552
17057
|
role
|
|
17058
|
+
}),
|
|
17059
|
+
meetingPost: (workspaceId, programId, text) => post({
|
|
17060
|
+
op: "meeting-post",
|
|
17061
|
+
workspaceId,
|
|
17062
|
+
programId,
|
|
17063
|
+
...text ? { text } : {}
|
|
16553
17064
|
})
|
|
16554
17065
|
};
|
|
16555
17066
|
}
|
|
@@ -16966,7 +17477,7 @@ function dueFrom(value, now) {
|
|
|
16966
17477
|
* The CLI saves through the sync loop afterwards.
|
|
16967
17478
|
*/
|
|
16968
17479
|
init_fog();
|
|
16969
|
-
const plural$
|
|
17480
|
+
const plural$3 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
16970
17481
|
async function sweepFog(opts) {
|
|
16971
17482
|
const { store, ask, say, today } = opts;
|
|
16972
17483
|
const start = store.getState();
|
|
@@ -16982,7 +17493,7 @@ async function sweepFog(opts) {
|
|
|
16982
17493
|
say("No fog: every open task was touched in the last three weeks.");
|
|
16983
17494
|
return tally;
|
|
16984
17495
|
}
|
|
16985
|
-
say(`Clear the fog · ${plural$
|
|
17496
|
+
say(`Clear the fog · ${plural$3(queue.length, "task")} nobody has opened, moved or commented on for three weeks.`);
|
|
16986
17497
|
say("k still needed d new date x drop it s skip q stop\n");
|
|
16987
17498
|
let stopped = false;
|
|
16988
17499
|
for (let i = 0; i < queue.length && !stopped; i++) {
|
|
@@ -17274,6 +17785,7 @@ function memoryHarbor(start, opts) {
|
|
|
17274
17785
|
id,
|
|
17275
17786
|
name
|
|
17276
17787
|
});
|
|
17788
|
+
const posts = [];
|
|
17277
17789
|
return {
|
|
17278
17790
|
transport: {
|
|
17279
17791
|
crew: opts.noCrew ? async () => Promise.reject(new TransportError(opts.noCrew, 404)) : crew.crew,
|
|
@@ -17317,6 +17829,23 @@ function memoryHarbor(start, opts) {
|
|
|
17317
17829
|
};
|
|
17318
17830
|
return save(sent);
|
|
17319
17831
|
},
|
|
17832
|
+
async meetingPost(_id, programId, text) {
|
|
17833
|
+
unreachable();
|
|
17834
|
+
const service = opts.channels?.[programId] ?? null;
|
|
17835
|
+
if (!text) return {
|
|
17836
|
+
set: Boolean(service),
|
|
17837
|
+
service
|
|
17838
|
+
};
|
|
17839
|
+
if (service) posts.push({
|
|
17840
|
+
programId,
|
|
17841
|
+
text
|
|
17842
|
+
});
|
|
17843
|
+
return {
|
|
17844
|
+
set: Boolean(service),
|
|
17845
|
+
service,
|
|
17846
|
+
posted: Boolean(service)
|
|
17847
|
+
};
|
|
17848
|
+
},
|
|
17320
17849
|
async create(made) {
|
|
17321
17850
|
return { harbor: {
|
|
17322
17851
|
id,
|
|
@@ -17360,6 +17889,8 @@ function memoryHarbor(start, opts) {
|
|
|
17360
17889
|
}
|
|
17361
17890
|
},
|
|
17362
17891
|
now,
|
|
17892
|
+
/** What was posted to the pretend chat channels, oldest first. */
|
|
17893
|
+
posts,
|
|
17363
17894
|
/** How many versions this harbor has been through. */
|
|
17364
17895
|
get versions() {
|
|
17365
17896
|
return versions.length;
|
|
@@ -18281,9 +18812,9 @@ var commands_exports = /* @__PURE__ */ __exportAll({
|
|
|
18281
18812
|
/** The one line a sync ends with; a dry run's says what it would have done. */
|
|
18282
18813
|
function syncLine(report, dryRun = false) {
|
|
18283
18814
|
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$
|
|
18815
|
+
report.sent.length ? `${dryRun ? "would send" : "sent"} ${plural$2(report.sent.length, "change")} to Harbor` : "",
|
|
18816
|
+
report.added.length ? `${dryRun ? "would add" : "added"} ${plural$2(report.added.length, "task")}` : "",
|
|
18817
|
+
report.took.length ? `${dryRun ? "would bring in" : "brought in"} ${plural$2(report.took.length, "change")} from Harbor` : ""
|
|
18287
18818
|
].filter(Boolean);
|
|
18288
18819
|
if (dryRun) return `⚓ Dry run: ${parts.length ? parts.join(", ") : "already in step with Harbor"}. Nothing was written or sent.`;
|
|
18289
18820
|
return parts.length ? `⚓ ${parts.join(", ")}.` : "⚓ Already in step with Harbor.";
|
|
@@ -18338,13 +18869,13 @@ async function initHere(opts) {
|
|
|
18338
18869
|
dryRun: opts.dryRun
|
|
18339
18870
|
});
|
|
18340
18871
|
const { project, harbor } = made.config;
|
|
18341
|
-
const counted = made.open === made.tasks ? plural$
|
|
18872
|
+
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
18873
|
if (opts.dryRun) {
|
|
18343
|
-
opts.say(made.moved ? `Would move this folder's ${plural$
|
|
18874
|
+
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
18875
|
opts.say("Dry run: nothing was written or sent. Run it again without --dry-run to do it.");
|
|
18345
18876
|
return made.config;
|
|
18346
18877
|
}
|
|
18347
|
-
opts.say(made.moved ? `Moved this folder's ${plural$
|
|
18878
|
+
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
18879
|
opts.say("Edit a file, or add one, then run harbor sync. Changes on the website come back the same way.");
|
|
18349
18880
|
if (opts.hook) opts.say(await addHook(home));
|
|
18350
18881
|
return made.config;
|
|
@@ -18405,12 +18936,12 @@ async function openLocalPlan(dir, say) {
|
|
|
18405
18936
|
}]
|
|
18406
18937
|
};
|
|
18407
18938
|
}
|
|
18408
|
-
var plural$
|
|
18939
|
+
var plural$2, shownDir, HOOK;
|
|
18409
18940
|
var init_commands = __esmMin((() => {
|
|
18410
18941
|
init_errors();
|
|
18411
18942
|
init_folder();
|
|
18412
18943
|
init_repo_sync();
|
|
18413
|
-
plural$
|
|
18944
|
+
plural$2 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
18414
18945
|
shownDir = (cwd, dir) => `${relative(cwd, dir) || ".harbor"}/`;
|
|
18415
18946
|
HOOK = "harbor sync --quiet || true";
|
|
18416
18947
|
}));
|
|
@@ -21186,7 +21717,7 @@ function bucketFor(state, inst) {
|
|
|
21186
21717
|
return bucket;
|
|
21187
21718
|
}
|
|
21188
21719
|
let handoff;
|
|
21189
|
-
const open = [];
|
|
21720
|
+
const open$1 = [];
|
|
21190
21721
|
const memo = {
|
|
21191
21722
|
alloc(_inst, payload, empty) {
|
|
21192
21723
|
const bucket = handoff;
|
|
@@ -21197,7 +21728,7 @@ const memo = {
|
|
|
21197
21728
|
issues: null
|
|
21198
21729
|
};
|
|
21199
21730
|
bucket.set(payload.value, entry);
|
|
21200
|
-
open.push(entry);
|
|
21731
|
+
open$1.push(entry);
|
|
21201
21732
|
return empty;
|
|
21202
21733
|
},
|
|
21203
21734
|
guard(inst) {
|
|
@@ -21263,10 +21794,10 @@ const memo = {
|
|
|
21263
21794
|
return payload;
|
|
21264
21795
|
}
|
|
21265
21796
|
handoff = bucket;
|
|
21266
|
-
const depth = open.length;
|
|
21797
|
+
const depth = open$1.length;
|
|
21267
21798
|
const result = base(payload, ctx);
|
|
21268
21799
|
handoff = void 0;
|
|
21269
|
-
const entry = open.length > depth ? open.pop() : void 0;
|
|
21800
|
+
const entry = open$1.length > depth ? open$1.pop() : void 0;
|
|
21270
21801
|
if (result instanceof Promise) return result.then((r) => {
|
|
21271
21802
|
if (entry) entry.issues = r.issues.length ? cloneIssues(r.issues) : NO_ISSUES;
|
|
21272
21803
|
return r;
|
|
@@ -23638,18 +24169,18 @@ init_format();
|
|
|
23638
24169
|
const BURN_DAYS = 56;
|
|
23639
24170
|
/** A window shorter than a week says too little to read a rate from. */
|
|
23640
24171
|
const MIN_DAYS = 7;
|
|
23641
|
-
const noon = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
|
|
24172
|
+
const noon$1 = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
|
|
23642
24173
|
/**
|
|
23643
24174
|
* Spend a day over the last BURN_DAYS (or the project's life, when it's
|
|
23644
24175
|
* younger), from the ledger; null when nothing was spent in that time.
|
|
23645
24176
|
*/
|
|
23646
24177
|
function burnPerDay(entries, startDate, now) {
|
|
23647
|
-
const today = noon(todayISO(now));
|
|
23648
|
-
const age = differenceInCalendarDays(today, noon(startDate)) + 1;
|
|
24178
|
+
const today = noon$1(todayISO(now));
|
|
24179
|
+
const age = differenceInCalendarDays(today, noon$1(startDate)) + 1;
|
|
23649
24180
|
const span = Math.max(MIN_DAYS, Math.min(56, age));
|
|
23650
24181
|
let sum = 0;
|
|
23651
24182
|
for (const e of entries) {
|
|
23652
|
-
const back = differenceInCalendarDays(today, noon(e.date));
|
|
24183
|
+
const back = differenceInCalendarDays(today, noon$1(e.date));
|
|
23653
24184
|
if (back >= 0 && back < span) sum += e.amount;
|
|
23654
24185
|
}
|
|
23655
24186
|
return sum > 0 ? sum / span : null;
|
|
@@ -23666,8 +24197,8 @@ function slipCost(project, tide, entries, now) {
|
|
|
23666
24197
|
const from = project.endDate > today ? project.endDate : today;
|
|
23667
24198
|
const point = (date) => {
|
|
23668
24199
|
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)));
|
|
24200
|
+
const daysLate = Math.max(0, differenceInCalendarDays(noon$1(date), noon$1(from)));
|
|
24201
|
+
const ahead = Math.max(0, differenceInCalendarDays(noon$1(date), noon$1(today)));
|
|
23671
24202
|
return {
|
|
23672
24203
|
date,
|
|
23673
24204
|
daysLate,
|
|
@@ -23682,6 +24213,209 @@ function slipCost(project, tide, entries, now) {
|
|
|
23682
24213
|
};
|
|
23683
24214
|
}
|
|
23684
24215
|
|
|
24216
|
+
//#endregion
|
|
24217
|
+
//#region src/lib/long-pole.ts
|
|
24218
|
+
init_date_fns();
|
|
24219
|
+
init_blockers();
|
|
24220
|
+
init_format();
|
|
24221
|
+
init_indexes();
|
|
24222
|
+
const noon = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
|
|
24223
|
+
const days = (from, to) => differenceInCalendarDays(noon(to), noon(from));
|
|
24224
|
+
const later = (a, b) => !a ? b : !b ? a : a >= b ? a : b;
|
|
24225
|
+
const open = (t) => t.status !== "done";
|
|
24226
|
+
/** True when `a` finishes later than `b`, or as late behind a longer line. */
|
|
24227
|
+
const beats = (a, b) => !b || a.finish > b.finish || a.finish === b.finish && a.depth > b.depth;
|
|
24228
|
+
/**
|
|
24229
|
+
* Soonest finish for open tasks, and how many open links lie behind each
|
|
24230
|
+
* (itself included), worked out once per task however many wait on it. A
|
|
24231
|
+
* loop is cut where it closes. Undated tasks with nothing dated behind them
|
|
24232
|
+
* have no finish.
|
|
24233
|
+
*/
|
|
24234
|
+
function finishes(all, today) {
|
|
24235
|
+
const memo = /* @__PURE__ */ new Map();
|
|
24236
|
+
const onPath = /* @__PURE__ */ new Set();
|
|
24237
|
+
const walk = (t) => {
|
|
24238
|
+
const known = memo.get(t.id);
|
|
24239
|
+
if (known) return known;
|
|
24240
|
+
onPath.add(t.id);
|
|
24241
|
+
let behind = null;
|
|
24242
|
+
for (const b of blockersIn(t, all)) {
|
|
24243
|
+
if (!open(b) || onPath.has(b.id)) continue;
|
|
24244
|
+
const w = walk(b);
|
|
24245
|
+
if (w.finish && beats(w, behind)) behind = w;
|
|
24246
|
+
}
|
|
24247
|
+
const own = t.dueDate ? later(t.dueDate.slice(0, 10), today) : null;
|
|
24248
|
+
onPath.delete(t.id);
|
|
24249
|
+
const out = {
|
|
24250
|
+
finish: later(own, behind?.finish ?? null),
|
|
24251
|
+
depth: (behind?.depth ?? 0) + 1
|
|
24252
|
+
};
|
|
24253
|
+
memo.set(t.id, out);
|
|
24254
|
+
return out;
|
|
24255
|
+
};
|
|
24256
|
+
return { walk };
|
|
24257
|
+
}
|
|
24258
|
+
/** The line of open tasks ending at `last`, through the blocker that finishes last at each step. */
|
|
24259
|
+
function lineTo(last, all, walk) {
|
|
24260
|
+
const line = [last];
|
|
24261
|
+
const seen = /* @__PURE__ */ new Set([last.id]);
|
|
24262
|
+
let cursor = last;
|
|
24263
|
+
for (;;) {
|
|
24264
|
+
let next = null;
|
|
24265
|
+
let best = null;
|
|
24266
|
+
for (const b of blockersIn(cursor, all)) {
|
|
24267
|
+
if (!open(b) || seen.has(b.id)) continue;
|
|
24268
|
+
const w = walk(b);
|
|
24269
|
+
if (w.finish && beats(w, best)) {
|
|
24270
|
+
best = w;
|
|
24271
|
+
next = b;
|
|
24272
|
+
}
|
|
24273
|
+
}
|
|
24274
|
+
if (!next) break;
|
|
24275
|
+
seen.add(next.id);
|
|
24276
|
+
line.unshift(next);
|
|
24277
|
+
cursor = next;
|
|
24278
|
+
}
|
|
24279
|
+
return line;
|
|
24280
|
+
}
|
|
24281
|
+
function linksOf(line, walk, target, today) {
|
|
24282
|
+
return line.map((task, i) => {
|
|
24283
|
+
const finish = walk(task).finish;
|
|
24284
|
+
const next = line[i + 1];
|
|
24285
|
+
const against = next ? next.dueDate?.slice(0, 10) ?? null : target;
|
|
24286
|
+
return {
|
|
24287
|
+
task,
|
|
24288
|
+
finish,
|
|
24289
|
+
late: Boolean(task.dueDate && task.dueDate.slice(0, 10) < today),
|
|
24290
|
+
slack: against ? days(finish, against) : null
|
|
24291
|
+
};
|
|
24292
|
+
});
|
|
24293
|
+
}
|
|
24294
|
+
function chainOf(line, walk, target, today) {
|
|
24295
|
+
const links = linksOf(line, walk, target, today);
|
|
24296
|
+
const finish = links[links.length - 1].finish;
|
|
24297
|
+
return {
|
|
24298
|
+
kind: "chain",
|
|
24299
|
+
links,
|
|
24300
|
+
finish,
|
|
24301
|
+
target,
|
|
24302
|
+
over: target ? days(target, finish) : null
|
|
24303
|
+
};
|
|
24304
|
+
}
|
|
24305
|
+
/** What sets a project's date: the line behind its open task that finishes last. */
|
|
24306
|
+
function projectPole(project, all, now) {
|
|
24307
|
+
const today = todayISO(now);
|
|
24308
|
+
const mine = tasksOf(all, project.id).filter(open);
|
|
24309
|
+
if (mine.length === 0) return {
|
|
24310
|
+
kind: "none",
|
|
24311
|
+
reason: "Nothing is open, so nothing holds it up."
|
|
24312
|
+
};
|
|
24313
|
+
const { walk } = finishes(all, today);
|
|
24314
|
+
let last = null;
|
|
24315
|
+
let best = null;
|
|
24316
|
+
for (const t of mine) {
|
|
24317
|
+
const w = walk(t);
|
|
24318
|
+
if (w.finish && beats(w, best)) {
|
|
24319
|
+
best = w;
|
|
24320
|
+
last = t;
|
|
24321
|
+
}
|
|
24322
|
+
}
|
|
24323
|
+
if (!last) return {
|
|
24324
|
+
kind: "none",
|
|
24325
|
+
reason: "None of its open tasks has a due date, so there's no date to trace."
|
|
24326
|
+
};
|
|
24327
|
+
return chainOf(lineTo(last, all, walk), walk, project.endDate.slice(0, 10), today);
|
|
24328
|
+
}
|
|
24329
|
+
/** What holds one task up: the line of open tasks it waits on, ending at it. */
|
|
24330
|
+
function taskPole(task, all, now) {
|
|
24331
|
+
const today = todayISO(now);
|
|
24332
|
+
if (!open(task)) return {
|
|
24333
|
+
kind: "none",
|
|
24334
|
+
reason: "It's done, so nothing holds it up."
|
|
24335
|
+
};
|
|
24336
|
+
const { walk } = finishes(all, today);
|
|
24337
|
+
if (!walk(task).finish) return {
|
|
24338
|
+
kind: "none",
|
|
24339
|
+
reason: "Neither it nor anything it waits on has a due date, so there's no date to trace."
|
|
24340
|
+
};
|
|
24341
|
+
return chainOf(lineTo(task, all, walk), walk, task.dueDate?.slice(0, 10) ?? null, today);
|
|
24342
|
+
}
|
|
24343
|
+
/**
|
|
24344
|
+
* What holds a milestone up: of the projects it waits on that still have
|
|
24345
|
+
* open work, the one finishing last (its end date, or later when its tasks
|
|
24346
|
+
* run past it), and that project's own long pole.
|
|
24347
|
+
*/
|
|
24348
|
+
function milestonePole(milestone, data, now) {
|
|
24349
|
+
if (milestone.done) return {
|
|
24350
|
+
kind: "none",
|
|
24351
|
+
reason: "It's done, so nothing holds it up."
|
|
24352
|
+
};
|
|
24353
|
+
const waited = blockersIn(milestone, data.projects);
|
|
24354
|
+
if (waited.length === 0) return {
|
|
24355
|
+
kind: "none",
|
|
24356
|
+
reason: "It doesn't wait on any project. Link the projects it needs to trace what sets it."
|
|
24357
|
+
};
|
|
24358
|
+
const due = milestone.dueDate.slice(0, 10);
|
|
24359
|
+
const runs = waited.flatMap((project) => {
|
|
24360
|
+
if (project.health === "done" || !tasksOf(data.tasks, project.id).some(open)) return [];
|
|
24361
|
+
const pole = projectPole(project, data.tasks, now);
|
|
24362
|
+
const end = project.endDate.slice(0, 10);
|
|
24363
|
+
return [{
|
|
24364
|
+
project,
|
|
24365
|
+
pole,
|
|
24366
|
+
finish: pole.kind === "chain" ? later(pole.finish, end) ?? end : end
|
|
24367
|
+
}];
|
|
24368
|
+
});
|
|
24369
|
+
if (runs.length === 0) return {
|
|
24370
|
+
kind: "none",
|
|
24371
|
+
reason: "Every project it waits on is finished."
|
|
24372
|
+
};
|
|
24373
|
+
runs.sort((a, b) => b.finish.localeCompare(a.finish));
|
|
24374
|
+
const first = runs[0];
|
|
24375
|
+
const others = runs.slice(1).map((r) => ({
|
|
24376
|
+
project: r.project,
|
|
24377
|
+
finish: r.finish,
|
|
24378
|
+
slack: days(r.finish, due)
|
|
24379
|
+
}));
|
|
24380
|
+
return {
|
|
24381
|
+
kind: "chain",
|
|
24382
|
+
links: first.pole.kind === "chain" ? first.pole.links.map((l, i, all) => i === all.length - 1 ? {
|
|
24383
|
+
...l,
|
|
24384
|
+
slack: days(l.finish, due)
|
|
24385
|
+
} : l) : [],
|
|
24386
|
+
finish: first.finish,
|
|
24387
|
+
target: due,
|
|
24388
|
+
over: days(due, first.finish),
|
|
24389
|
+
via: {
|
|
24390
|
+
project: first.project,
|
|
24391
|
+
finish: first.finish
|
|
24392
|
+
},
|
|
24393
|
+
others
|
|
24394
|
+
};
|
|
24395
|
+
}
|
|
24396
|
+
const plural$1 = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
24397
|
+
/** A link's slack in a few words: "4 days spare", "no slack", "3 days late for what's next". */
|
|
24398
|
+
function slackText(slack, last = false) {
|
|
24399
|
+
if (slack == null) return last ? "no date to hold" : "next has no due date";
|
|
24400
|
+
if (slack > 0) return `${plural$1(slack, "day")} spare`;
|
|
24401
|
+
if (slack === 0) return "no slack";
|
|
24402
|
+
return last ? `runs ${plural$1(-slack, "day")} over` : `pushes the next ${plural$1(-slack, "day")}`;
|
|
24403
|
+
}
|
|
24404
|
+
/**
|
|
24405
|
+
* The sentence above the line: what sets the date and how it compares with
|
|
24406
|
+
* the date being held. `name` turns a task into what to call it (its key).
|
|
24407
|
+
*/
|
|
24408
|
+
function poleHeadline(pole, name, held) {
|
|
24409
|
+
if (pole.kind !== "chain") return null;
|
|
24410
|
+
const last = pole.links[pole.links.length - 1];
|
|
24411
|
+
const lead = pole.via ? `${pole.via.project.name} finishes ${shortDate(pole.finish)}` : `${last ? name(last.task) : "It"} can finish ${shortDate(pole.finish)}`;
|
|
24412
|
+
const tail = pole.via && last && last.finish < pole.finish ? ` Its tasks can all be done by ${shortDate(last.finish)}; its end date sets it.` : "";
|
|
24413
|
+
if (pole.over == null || !pole.target) return `${lead}.${tail}`;
|
|
24414
|
+
if (pole.over > 0) return `${lead}, ${plural$1(pole.over, "day")} after ${held} (${shortDate(pole.target)}).${tail}`;
|
|
24415
|
+
if (pole.over === 0) return `${lead}, right on ${held}.${tail}`;
|
|
24416
|
+
return `${lead}, ${plural$1(-pole.over, "day")} before ${held}.${tail}`;
|
|
24417
|
+
}
|
|
24418
|
+
|
|
23685
24419
|
//#endregion
|
|
23686
24420
|
//#region src/lib/csv.ts
|
|
23687
24421
|
/**
|
|
@@ -24959,6 +25693,80 @@ const HARBOR_TOOLS = [
|
|
|
24959
25693
|
};
|
|
24960
25694
|
}
|
|
24961
25695
|
}),
|
|
25696
|
+
tool({
|
|
25697
|
+
name: "long_pole",
|
|
25698
|
+
title: "What's holding this up",
|
|
25699
|
+
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,
|
|
25700
|
+
input: object({
|
|
25701
|
+
harbor: harborArg,
|
|
25702
|
+
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).")
|
|
25703
|
+
}),
|
|
25704
|
+
run: (ctx, input) => {
|
|
25705
|
+
const { data } = ctx;
|
|
25706
|
+
const people = byId(data.people);
|
|
25707
|
+
const projects = byId(data.projects);
|
|
25708
|
+
const keyOf = (t) => taskKey(t, projects.get(t.projectId));
|
|
25709
|
+
const name = (t) => keyOf(t) ?? `“${t.title}”`;
|
|
25710
|
+
const answer = (kind, what, pole, held) => {
|
|
25711
|
+
if (pole.kind !== "chain") return {
|
|
25712
|
+
of: {
|
|
25713
|
+
kind,
|
|
25714
|
+
name: what
|
|
25715
|
+
},
|
|
25716
|
+
none: pole.reason
|
|
25717
|
+
};
|
|
25718
|
+
return {
|
|
25719
|
+
of: {
|
|
25720
|
+
kind,
|
|
25721
|
+
name: what
|
|
25722
|
+
},
|
|
25723
|
+
headline: poleHeadline(pole, name, held),
|
|
25724
|
+
finish: pole.finish,
|
|
25725
|
+
held: pole.target,
|
|
25726
|
+
over: pole.over,
|
|
25727
|
+
...pole.via ? { via: {
|
|
25728
|
+
project: pole.via.project.name,
|
|
25729
|
+
finish: pole.via.finish
|
|
25730
|
+
} } : {},
|
|
25731
|
+
chain: pole.links.map((l, i) => ({
|
|
25732
|
+
id: l.task.id,
|
|
25733
|
+
...keyOf(l.task) ? { key: keyOf(l.task) } : {},
|
|
25734
|
+
title: l.task.title,
|
|
25735
|
+
status: l.task.status,
|
|
25736
|
+
assignee: nameOf(people, l.task.assigneeId),
|
|
25737
|
+
due: dayOf(l.task.dueDate),
|
|
25738
|
+
canFinish: l.finish,
|
|
25739
|
+
late: l.late,
|
|
25740
|
+
slack: l.slack,
|
|
25741
|
+
slackText: slackText(l.slack, i === pole.links.length - 1)
|
|
25742
|
+
})),
|
|
25743
|
+
...pole.others ? { others: pole.others.map((o) => ({
|
|
25744
|
+
project: o.project.name,
|
|
25745
|
+
finish: o.finish,
|
|
25746
|
+
slack: o.slack
|
|
25747
|
+
})) } : {}
|
|
25748
|
+
};
|
|
25749
|
+
};
|
|
25750
|
+
const task = data.tasks.find((x) => x.id === input.of.trim()) ?? findByKey(data, input.of);
|
|
25751
|
+
if (task) return answer("task", name(task), taskPole(task, data.tasks, ctx.now), "its due date");
|
|
25752
|
+
let project = null;
|
|
25753
|
+
try {
|
|
25754
|
+
project = findOne(data.projects, input.of, "project");
|
|
25755
|
+
} catch (err) {
|
|
25756
|
+
if (!(err instanceof ToolError) || !/^No project matches/.test(err.message)) throw err;
|
|
25757
|
+
const milestones = data.milestones.filter((m) => !m.done);
|
|
25758
|
+
let milestone;
|
|
25759
|
+
try {
|
|
25760
|
+
milestone = findOne(milestones.length ? milestones : data.milestones, input.of, "milestone");
|
|
25761
|
+
} catch (inner) {
|
|
25762
|
+
if (inner instanceof ToolError && /^No milestone matches/.test(inner.message)) throw new ToolError(`No project, task key or open milestone matches "${input.of.trim()}".`);
|
|
25763
|
+
throw inner;
|
|
25764
|
+
}
|
|
25765
|
+
return answer("milestone", milestone.name, milestonePole(milestone, data, ctx.now), "the milestone");
|
|
25766
|
+
}
|
|
25767
|
+
return answer("project", project.name, projectPole(project, data.tasks, ctx.now), "the end date");
|
|
25768
|
+
}
|
|
25769
|
+
}),
|
|
24962
25770
|
tool({
|
|
24963
25771
|
name: "shipping_forecast",
|
|
24964
25772
|
title: "The harbor forecast",
|
|
@@ -25770,7 +26578,10 @@ async function startApp(deps, options = {}) {
|
|
|
25770
26578
|
remember: deps.remember,
|
|
25771
26579
|
browse: deps.browse
|
|
25772
26580
|
},
|
|
25773
|
-
children: /* @__PURE__ */ jsx(App, {
|
|
26581
|
+
children: /* @__PURE__ */ jsx(App, {
|
|
26582
|
+
ui,
|
|
26583
|
+
startIn: deps.startIn
|
|
26584
|
+
})
|
|
25774
26585
|
}));
|
|
25775
26586
|
return {
|
|
25776
26587
|
instance,
|
|
@@ -25848,7 +26659,8 @@ async function runApp(opts) {
|
|
|
25848
26659
|
theme,
|
|
25849
26660
|
firstRun,
|
|
25850
26661
|
remember: opts.remember,
|
|
25851
|
-
browse: opts.browse
|
|
26662
|
+
browse: opts.browse,
|
|
26663
|
+
startIn: opts.startIn
|
|
25852
26664
|
}, { alternateScreen: true });
|
|
25853
26665
|
await instance.waitUntilExit();
|
|
25854
26666
|
}
|