@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/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$1 = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
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$1(todayISO(now));
3165
- const age = Math.floor(differenceInCalendarDays(today, noon$1(startDate)) / 7) + 1;
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$1(t.completedAt) : today;
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$1(today);
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$1(lastDue.dueDate), base) : 0;
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$1(project.endDate), base);
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 === "project" ? [
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 === "project") screenNode = /* @__PURE__ */ jsx(ProjectView, {
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$2 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
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$2(queue.length, "task")} nobody has opened, moved or commented on for three weeks.`);
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$1(report.sent.length, "change")} to Harbor` : "",
18285
- report.added.length ? `${dryRun ? "would add" : "added"} ${plural$1(report.added.length, "task")}` : "",
18286
- report.took.length ? `${dryRun ? "would bring in" : "brought in"} ${plural$1(report.took.length, "change")} from Harbor` : ""
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$1(made.tasks, "task") : `${made.open} open and ${made.tasks - made.open} done ${made.tasks === 1 ? "task" : "tasks"}`;
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$1(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}.`);
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$1(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.`);
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$1, shownDir, HOOK;
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$1 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
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, { ui })
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
  }