@harborppm/cli 0.11.1 → 0.12.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 CHANGED
@@ -26,16 +26,16 @@ harbor # name your harbor and its first project, and you're in
26
26
 
27
27
  `harbor` on its own takes over the terminal, like `htop` or `lazygit`, and gives it back when you press `q`. It opens on Harbor's mark (the teal tile, the H and three boats, drawn in half blocks) while your harbor loads; after that the sidebar holds just the screens. Then:
28
28
 
29
- 1. **Today**: what's on you, late first; who's waiting on you; your risks and issues.
29
+ 1. **Today**: today's focus (`f` adds or takes off the task you're on, up to three) and your check-in (`c`); what's on you, late first; who's waiting on you; your risks and issues.
30
30
  2. **Inbox**: the same pings as the website: waiting on you, sign-offs, mentions. Enter opens one; it's marked read.
31
- 3. **Projects**: every portfolio, program and project with its health. A project opens on its board (columns side by side), its list, or its budget and spend ledger (`v`); `e` changes its details: lead, dates, budget, health by hand with a note.
31
+ 3. **Projects**: every portfolio, program and project with its health. A project opens on its board (columns side by side; `f` and `p` show only someone's cards or one priority), its list, or its budget and spend ledger (`v`); `e` changes its details: lead, dates, budget, health by hand with a note.
32
32
  4. **Programs**: a program's projects and milestones side by side (`m` adds a milestone, space ticks one), its next move and health.
33
33
  5. **Risks**: the register, worst first. Enter changes one (status, owner, likelihood, impact, mitigation); `n` logs a risk, issue or decision.
34
34
  6. **People**: open and late work and the next four weeks against each person's capacity; Enter changes their capacity, role, or the time they're promised to a program.
35
35
  7. **Roadmap**: a portfolio's programs and projects across the months, in their health's colour, ◆ for milestones.
36
36
  8. **History**: who changed what, for a portfolio, program or project (`s` picks which).
37
37
 
38
- A task changes in place too: title, status, owner, due date (`2026-10-14`, `today`, `+3`), priority, project, notes, checklist steps, comments.
38
+ A task changes in place too: title, status, owner, due date (`2026-10-14`, `today`, `+3`), priority, project, what it waits on (type a key or words), how it repeats, notes, checklist steps, comments (`C` changes or takes out one of yours).
39
39
 
40
40
  Keys: `↑↓` (or `j k`) and `←→` (or `h l`) move, Enter opens, Esc goes back, `:` finds anything (a screen, "new project", a project or program by name), `/` finds a task by its key or words, `n` makes something new, `e` changes what's open, `[` `]` move a card a column, `x` marks it done, `d` deletes (it asks first), `u` undoes your last change, `H` switches harbor, `?` lists them all.
41
41
 
@@ -94,13 +94,17 @@ harbor add "TITLE" --project P [--assignee me|NAME] [--due DATE]
94
94
  [--priority low|medium|high|urgent] [--status S] [--over-limit]
95
95
  harbor start TASK | review TASK | done TASK [--confirm] [--over-limit]
96
96
  harbor edit TASK [--title T] [--assignee me|none|NAME] [--due DATE|none]
97
- [--priority P] [--status S] [--confirm] [--over-limit]
97
+ [--priority P] [--status S] [--notes TEXT]
98
+ [--repeat weekly|biweekly|monthly|never]
99
+ [--waits-on TASK,TASK|none] [--confirm] [--over-limit]
98
100
  harbor comment TASK "TEXT"
99
101
  harbor checkin "WHAT YOU'RE DOING" [--on-blocker]
100
102
  harbor risk "TITLE" --project P|--program P [--impact 1-5] [--probability 1-5]
101
103
  [--owner NAME] [--due DATE]
102
104
  harbor issue "TITLE" --project P|--program P [--impact 1-5] [--owner NAME]
103
105
  [--due DATE]
106
+ harbor decision "TITLE" --project P|--program P [--owner NAME] [--due DATE]
107
+ harbor risks [words] [--kind risk,issue,decision] [--project P] [--closed]
104
108
  ```
105
109
 
106
110
  Every command takes `--json` for the raw result (a refusal then comes out as `{"error": "…"}` on standard output, still with exit code 1), and `harbor COMMAND --help` shows just that command. Statuses are backlog, todo, doing, review and done. A `DATE` is YYYY-MM-DD, `today`, `tomorrow`, or `+N` for N days from today.
package/dist/app.mjs CHANGED
@@ -2088,6 +2088,14 @@ function forecastLine(f) {
2088
2088
  const side = f.variance >= 0 ? "under" : "over";
2089
2089
  return `At this rate it ends at ${money(Math.round(f.eac))} (${money(gap)} ${side} budget)`;
2090
2090
  }
2091
+ const NO_FILTER = {
2092
+ who: "any",
2093
+ priority: "any"
2094
+ };
2095
+ function filterTasks(tasks, f) {
2096
+ if (f.who === "any" && f.priority === "any") return tasks;
2097
+ return tasks.filter((t) => (f.who === "any" || (f.who === "none" ? t.assigneeId === null : t.assigneeId === f.who)) && (f.priority === "any" || t.priority === f.priority));
2098
+ }
2091
2099
 
2092
2100
  //#endregion
2093
2101
  //#region src/tui/theme.ts
@@ -3348,19 +3356,23 @@ function RowList({ title, right, rows, empty, active, width, height, onOpen, key
3348
3356
  ]
3349
3357
  });
3350
3358
  }
3351
- /** Every project, under its program and portfolio, with health and progress. */
3359
+ /** Every project, under its program and portfolio, with health and progress. Done
3360
+ * projects and programs stay out of the way until D shows them. */
3352
3361
  function ProjectsView({ active, width, height, go }) {
3353
3362
  const portfolios = useHarbor((s) => s.portfolios);
3354
3363
  const programs = useHarbor((s) => s.programs);
3355
3364
  const projects = useHarbor((s) => s.projects);
3356
3365
  const tasks = useHarbor((s) => s.tasks);
3357
3366
  const { today } = useDeps();
3358
- const rows = useMemo(() => {
3367
+ const [showDone, setShowDone] = useState(false);
3368
+ const { rows, shown, hidden } = useMemo(() => {
3359
3369
  const data = {
3360
3370
  tasks,
3361
3371
  projects
3362
3372
  };
3363
3373
  const out = [];
3374
+ let shown = 0;
3375
+ let hidden = 0;
3364
3376
  for (const portfolio of portfolios) {
3365
3377
  out.push({
3366
3378
  id: portfolio.id,
@@ -3371,6 +3383,11 @@ function ProjectsView({ active, width, height, go }) {
3371
3383
  health: portfolio.health
3372
3384
  });
3373
3385
  for (const program of programs.filter((g) => g.portfolioId === portfolio.id)) {
3386
+ const { open, done } = splitDone(projects.filter((p) => p.programId === program.id));
3387
+ if (program.health === "done" && open.length === 0) {
3388
+ hidden += 1 + done.length;
3389
+ if (!showDone) continue;
3390
+ } else hidden += done.length;
3374
3391
  out.push({
3375
3392
  id: program.id,
3376
3393
  kind: "program",
@@ -3378,8 +3395,9 @@ function ProjectsView({ active, width, height, go }) {
3378
3395
  title: program.name,
3379
3396
  health: program.health
3380
3397
  });
3381
- for (const project of projects.filter((p) => p.programId === program.id)) {
3382
- const { done, total } = projectSummary(data, project);
3398
+ for (const project of showDone ? [...open, ...done] : open) {
3399
+ shown++;
3400
+ const { done: finished, total } = projectSummary(data, project);
3383
3401
  const late = tasksOf(tasks, project.id).filter((t) => isLate(t, today())).length;
3384
3402
  out.push({
3385
3403
  id: project.id,
@@ -3387,22 +3405,27 @@ function ProjectsView({ active, width, height, go }) {
3387
3405
  depth: 2,
3388
3406
  title: `${project.name}${project.key ? ` ${project.key}` : ""}`,
3389
3407
  health: project.health,
3390
- detail: `${done}/${total} done${late ? ` · ${late} late` : ""} · ends ${shortDay(project.endDate, today())}`
3408
+ detail: `${finished}/${total} done${late ? ` · ${late} late` : ""} · ends ${shortDay(project.endDate, today())}`
3391
3409
  });
3392
3410
  }
3393
3411
  }
3394
3412
  }
3395
- return out;
3413
+ return {
3414
+ rows: out,
3415
+ shown,
3416
+ hidden
3417
+ };
3396
3418
  }, [
3397
3419
  portfolios,
3398
3420
  programs,
3399
3421
  projects,
3400
3422
  tasks,
3401
- today
3423
+ today,
3424
+ showDone
3402
3425
  ]);
3403
3426
  return /* @__PURE__ */ jsx(RowList, {
3404
3427
  title: "Portfolios, programs and projects",
3405
- right: `${count(projects.length, "project")} · n new project · e edit`,
3428
+ right: `${count(shown, "project")}${hidden ? ` · ${hidden} done ${showDone ? "shown" : "hidden"} (D)` : ""} · n new project · e edit`,
3406
3429
  rows,
3407
3430
  empty: "No projects yet. n adds one.",
3408
3431
  active,
@@ -3414,7 +3437,8 @@ function ProjectsView({ active, width, height, go }) {
3414
3437
  const programId = r?.kind === "program" ? r.id : r?.kind === "project" ? projects.find((p) => p.id === r.id)?.programId : void 0;
3415
3438
  go.create("project", programId ? { programId } : {});
3416
3439
  },
3417
- e: (r) => r && r.kind !== "raid" && r.kind !== "person" && go.openPanel(r.kind, r.id)
3440
+ e: (r) => r && r.kind !== "raid" && r.kind !== "person" && go.openPanel(r.kind, r.id),
3441
+ D: () => hidden > 0 && setShowDone((v) => !v)
3418
3442
  }
3419
3443
  });
3420
3444
  }
@@ -3892,11 +3916,9 @@ function HarborSwitch({ harbors, current, width, onPick, onClose }) {
3892
3916
  });
3893
3917
  }
3894
3918
  const HELP = [
3895
- ["1–8", "the screens, as the sidebar lists them"],
3919
+ ["1–8 / Tab", "the screens / between the sidebar and a screen"],
3896
3920
  [":", "anything: a screen, something new, a name"],
3897
- ["/", "find a task by key or words"],
3898
- ["H", "switch harbor"],
3899
- ["Tab", "between the sidebar and the screen"],
3921
+ ["/ / H", "find a task by key or words / switch harbor"],
3900
3922
  ["↑↓ or j k", "move; ←→ or h l across columns and lists"],
3901
3923
  ["Enter / Esc", "open / back"],
3902
3924
  ["n", "new: a task, or what the screen lists"],
@@ -3907,7 +3929,9 @@ const HELP = [
3907
3929
  ["v", "on a project: board, list, budget"],
3908
3930
  ["m / R", "new milestone on a program / log a risk"],
3909
3931
  ["D", "show or hide Done projects and programs"],
3910
- ["c / a", "on a task: comment / add a checklist step"],
3932
+ ["c / a / C", "on a task: comment / add a step / change yours"],
3933
+ ["f / c", "on Today: focus on a task / check in"],
3934
+ ["f / p", "on a board: filter by owner / priority"],
3911
3935
  ["q", "quit (saves first)"]
3912
3936
  ];
3913
3937
  /**
@@ -4116,7 +4140,7 @@ function healthField(kind, item, go, set, clear) {
4116
4140
  }
4117
4141
  };
4118
4142
  }
4119
- function ProgramPanel({ id, width, height, go, onClose }) {
4143
+ function ProgramPanel({ id, width, height, go, active, onClose }) {
4120
4144
  const { store, today } = useDeps();
4121
4145
  const program = useHarbor((s) => s.programs.find((p) => p.id === id));
4122
4146
  const people = useHarbor((s) => s.people);
@@ -4212,10 +4236,11 @@ function ProgramPanel({ id, width, height, go, onClose }) {
4212
4236
  onClose();
4213
4237
  }, "Delete");
4214
4238
  } },
4239
+ active,
4215
4240
  onClose
4216
4241
  });
4217
4242
  }
4218
- function PortfolioPanel({ id, width, height, go, onClose }) {
4243
+ function PortfolioPanel({ id, width, height, go, active, onClose }) {
4219
4244
  const { store } = useDeps();
4220
4245
  const portfolio = useHarbor((s) => s.portfolios.find((p) => p.id === id));
4221
4246
  const people = useHarbor((s) => s.people);
@@ -4279,10 +4304,11 @@ function PortfolioPanel({ id, width, height, go, onClose }) {
4279
4304
  onClose();
4280
4305
  }, "Delete");
4281
4306
  } },
4307
+ active,
4282
4308
  onClose
4283
4309
  });
4284
4310
  }
4285
- function ProjectPanel({ id, width, height, go, onClose }) {
4311
+ function ProjectPanel({ id, width, height, go, active, onClose }) {
4286
4312
  const { store, today } = useDeps();
4287
4313
  const project = useHarbor((s) => s.projects.find((p) => p.id === id));
4288
4314
  const people = useHarbor((s) => s.people);
@@ -4392,10 +4418,11 @@ function ProjectPanel({ id, width, height, go, onClose }) {
4392
4418
  onClose();
4393
4419
  }, "Delete");
4394
4420
  } },
4421
+ active,
4395
4422
  onClose
4396
4423
  });
4397
4424
  }
4398
- function MilestonePanel({ id, width, height, go, onClose }) {
4425
+ function MilestonePanel({ id, width, height, go, active, onClose }) {
4399
4426
  const { store, today } = useDeps();
4400
4427
  const milestone = useHarbor((s) => s.milestones.find((m) => m.id === id));
4401
4428
  const people = useHarbor((s) => s.people);
@@ -4466,10 +4493,11 @@ function MilestonePanel({ id, width, height, go, onClose }) {
4466
4493
  onClose();
4467
4494
  }, "Delete")
4468
4495
  },
4496
+ active,
4469
4497
  onClose
4470
4498
  });
4471
4499
  }
4472
- function RaidPanel({ id, width, height, go, onClose }) {
4500
+ function RaidPanel({ id, width, height, go, active, onClose }) {
4473
4501
  const { store, today } = useDeps();
4474
4502
  const item = useHarbor((s) => s.raidItems.find((r) => r.id === id));
4475
4503
  const people = useHarbor((s) => s.people);
@@ -4616,10 +4644,11 @@ function RaidPanel({ id, width, height, go, onClose }) {
4616
4644
  onClose();
4617
4645
  }, "Delete")
4618
4646
  },
4647
+ active,
4619
4648
  onClose
4620
4649
  });
4621
4650
  }
4622
- function PersonPanel({ id, width, height, go, onClose }) {
4651
+ function PersonPanel({ id, width, height, go, active, onClose }) {
4623
4652
  const { store } = useDeps();
4624
4653
  const person = useHarbor((s) => s.people.find((p) => p.id === id));
4625
4654
  const programs = useHarbor((s) => s.programs);
@@ -4703,6 +4732,7 @@ function PersonPanel({ id, width, height, go, onClose }) {
4703
4732
  ]
4704
4733
  }, a.programId))
4705
4734
  ] }) : null,
4735
+ active,
4706
4736
  onClose
4707
4737
  });
4708
4738
  }
@@ -5020,8 +5050,21 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
5020
5050
  people,
5021
5051
  boardConfigs
5022
5052
  ]);
5023
- const columns = useMemo(() => boardOf(data, projectId), [data, projectId]);
5024
- const list = useMemo(() => projectTasks(data, projectId), [data, projectId]);
5053
+ const [filter, setFilter] = useState(NO_FILTER);
5054
+ const columns = useMemo(() => boardOf(data, projectId).map((c) => ({
5055
+ ...c,
5056
+ all: c.tasks.length,
5057
+ tasks: filterTasks(c.tasks, filter)
5058
+ })), [
5059
+ data,
5060
+ projectId,
5061
+ filter
5062
+ ]);
5063
+ const list = useMemo(() => filterTasks(projectTasks(data, projectId), filter), [
5064
+ data,
5065
+ projectId,
5066
+ filter
5067
+ ]);
5025
5068
  const [mode, setMode] = useState("board");
5026
5069
  const [spendAt, setSpendAt] = useState(0);
5027
5070
  const spendEntries = useHarbor((s) => s.spendEntries) ?? [];
@@ -5053,6 +5096,38 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
5053
5096
  if (input === "v") return setMode((m) => m === "board" ? "list" : m === "list" ? "budget" : "board");
5054
5097
  if (input === "e") return go.openPanel("project", projectId);
5055
5098
  if (input === "R") return go.create("raid", { projectId });
5099
+ if (input === "f" && mode !== "budget") return go.pick("Show cards assigned to", [
5100
+ {
5101
+ label: "Anyone",
5102
+ value: "any"
5103
+ },
5104
+ {
5105
+ label: "Me",
5106
+ value: store.getState().currentUserId
5107
+ },
5108
+ {
5109
+ label: "Nobody yet",
5110
+ value: "none"
5111
+ },
5112
+ ...people.map((p) => ({
5113
+ label: p.name,
5114
+ value: p.id,
5115
+ hint: p.role
5116
+ }))
5117
+ ], (who) => setFilter((f) => ({
5118
+ ...f,
5119
+ who
5120
+ })));
5121
+ if (input === "p" && mode !== "budget") return go.pick("Show cards of priority", [{
5122
+ label: "Any priority",
5123
+ value: "any"
5124
+ }, ...PRIORITIES.map((p) => ({
5125
+ label: PRIORITY_LABEL[p],
5126
+ value: p
5127
+ }))], (priority) => setFilter((f) => ({
5128
+ ...f,
5129
+ priority
5130
+ })));
5056
5131
  if (mode === "budget") {
5057
5132
  const entries = project ? budgetOf({ spendEntries }, project, /* @__PURE__ */ new Date()).entries : [];
5058
5133
  const entry = entries[Math.min(spendAt, entries.length - 1)];
@@ -5092,6 +5167,7 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
5092
5167
  })
5093
5168
  });
5094
5169
  const { done, total } = projectSummary(data, project);
5170
+ const filtered = [filter.who === "none" ? "nobody's" : filter.who !== "any" ? `${people.find((p) => p.id === filter.who)?.name ?? "someone"}'s` : null, filter.priority !== "any" ? PRIORITY_LABEL[filter.priority].toLowerCase() : null].filter(Boolean);
5095
5171
  const lead = people.find((p) => p.id === project.ownerId);
5096
5172
  const body = height - 3;
5097
5173
  const fit = Math.max(1, Math.floor((width - 2) / 14));
@@ -5140,7 +5216,16 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
5140
5216
  lead ? ` · ${lead.name}` : "",
5141
5217
  " · ",
5142
5218
  mode,
5143
- " · v next · e details"
5219
+ filtered.length && mode !== "budget" ? /* @__PURE__ */ jsxs(Text, {
5220
+ color: theme.warn,
5221
+ children: [
5222
+ " · only ",
5223
+ filtered.join(", "),
5224
+ " cards"
5225
+ ]
5226
+ }) : null,
5227
+ " ",
5228
+ "· v next · f p filter · e details"
5144
5229
  ]
5145
5230
  })
5146
5231
  })]
@@ -5164,7 +5249,7 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
5164
5249
  const on = active && ci === col;
5165
5250
  const r = Math.min(rowByCol[ci] ?? 0, Math.max(0, c.tasks.length - 1));
5166
5251
  const view = windowOf(c.tasks, r, Math.max(1, Math.floor((body - 3) / 3)));
5167
- const over = c.limit != null && c.tasks.length > c.limit;
5252
+ const over = c.limit != null && c.all > c.limit;
5168
5253
  return /* @__PURE__ */ jsxs(Box, {
5169
5254
  flexDirection: "column",
5170
5255
  width: w,
@@ -5189,7 +5274,11 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
5189
5274
  " ",
5190
5275
  /* @__PURE__ */ jsxs(Text, {
5191
5276
  color: over ? theme.bad : theme.muted,
5192
- children: [c.tasks.length, c.limit != null ? `/${c.limit}` : ""]
5277
+ children: [
5278
+ c.tasks.length < c.all ? `${c.tasks.length} of ` : "",
5279
+ c.all,
5280
+ c.limit != null ? `/${c.limit}` : ""
5281
+ ]
5193
5282
  })
5194
5283
  ]
5195
5284
  })
@@ -5481,6 +5570,78 @@ function RoadmapView({ active, width, height, go }) {
5481
5570
  });
5482
5571
  }
5483
5572
 
5573
+ //#endregion
5574
+ //#region src/lib/deps.ts
5575
+ /**
5576
+ * Everything that waits on `id`, directly or through others: the items that
5577
+ * would close a loop if `id` waited on them. One walk backwards from `id`,
5578
+ * instead of a walk forwards from every candidate.
5579
+ */
5580
+ function waitingOnChain(items, id) {
5581
+ const waiters = /* @__PURE__ */ new Map();
5582
+ for (const n of items) for (const b of blockerIds(n)) {
5583
+ const list = waiters.get(b);
5584
+ if (list) list.push(n.id);
5585
+ else waiters.set(b, [n.id]);
5586
+ }
5587
+ const out = /* @__PURE__ */ new Set();
5588
+ const stack = [...waiters.get(id) ?? []];
5589
+ while (stack.length) {
5590
+ const cursor = stack.pop();
5591
+ if (out.has(cursor)) continue;
5592
+ out.add(cursor);
5593
+ stack.push(...waiters.get(cursor) ?? []);
5594
+ }
5595
+ return out;
5596
+ }
5597
+ /** The things `id` may wait on: everything except itself and anything already waiting on it, directly or through others. */
5598
+ function blockerOptions(items, id) {
5599
+ const loops = waitingOnChain(items, id);
5600
+ return items.filter((n) => n.id !== id && !loops.has(n.id));
5601
+ }
5602
+
5603
+ //#endregion
5604
+ //#region src/lib/recurrence.ts
5605
+ const REPEAT_LABEL = {
5606
+ weekly: "Every week",
5607
+ biweekly: "Every two weeks",
5608
+ monthly: "Every month"
5609
+ };
5610
+ const iso$1 = (d) => {
5611
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
5612
+ };
5613
+ /** The due date one interval after `from`. */
5614
+ function nextDueDate(from, repeat) {
5615
+ const d = /* @__PURE__ */ new Date(`${from}T12:00:00`);
5616
+ if (repeat === "weekly") return iso$1(addDays(d, 7));
5617
+ if (repeat === "biweekly") return iso$1(addDays(d, 14));
5618
+ return iso$1(addMonths(d, 1));
5619
+ }
5620
+ /**
5621
+ * The next occurrence of a repeating task once this one is done: same
5622
+ * title, owner, priority and checklist (unticked), due one interval after
5623
+ * the old due date (or after today when it had none), back in To do.
5624
+ * Null when the task does not repeat.
5625
+ */
5626
+ function nextOccurrence(task, id, today, createdAt) {
5627
+ if (!task.repeat) return null;
5628
+ const anchor = task.dueDate && task.dueDate >= today ? task.dueDate : today;
5629
+ return {
5630
+ ...task,
5631
+ id,
5632
+ status: "todo",
5633
+ dueDate: nextDueDate(anchor, task.repeat),
5634
+ createdAt,
5635
+ completedAt: null,
5636
+ blockedById: null,
5637
+ blockedByIds: [],
5638
+ checklist: (task.checklist ?? []).map((c) => ({
5639
+ ...c,
5640
+ done: false
5641
+ }))
5642
+ };
5643
+ }
5644
+
5484
5645
  //#endregion
5485
5646
  //#region src/tui/views/task.tsx
5486
5647
  const FIELDS = [
@@ -5508,19 +5669,34 @@ const FIELDS = [
5508
5669
  field: "project",
5509
5670
  label: "Project"
5510
5671
  },
5672
+ {
5673
+ field: "waits",
5674
+ label: "Waits on"
5675
+ },
5676
+ {
5677
+ field: "repeat",
5678
+ label: "Repeats"
5679
+ },
5511
5680
  {
5512
5681
  field: "notes",
5513
5682
  label: "Notes"
5514
5683
  }
5515
5684
  ];
5685
+ const REPEATS = [
5686
+ null,
5687
+ "weekly",
5688
+ "biweekly",
5689
+ "monthly"
5690
+ ];
5516
5691
  /** Everything about one task, every field editable: ↑↓ to a field, Enter to change it. */
5517
- function TaskPanel({ taskId, width, height, go, onClose }) {
5692
+ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
5518
5693
  const { store, today } = useDeps();
5519
5694
  const tasks = useHarbor((s) => s.tasks);
5520
5695
  const projects = useHarbor((s) => s.projects);
5521
5696
  const people = useHarbor((s) => s.people);
5522
5697
  const comments = useHarbor((s) => s.comments);
5523
5698
  const viewOnly = useHarbor((s) => s.myRole === "viewer" || s.keepPaused);
5699
+ const me = useHarbor((s) => s.currentUserId);
5524
5700
  const task = tasks.find((t) => t.id === taskId);
5525
5701
  const [at, setAt] = useState(0);
5526
5702
  const [editing, setEditing] = useState(null);
@@ -5533,6 +5709,10 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
5533
5709
  if (key.downArrow || input === "j") return setAt((i) => step(i, 1, rows));
5534
5710
  if (input === "c") return setEditing({ kind: "comment" });
5535
5711
  if (input === "a") return setEditing({ kind: "step" });
5712
+ if (input === "C") {
5713
+ if (comments.some((c) => c.taskId === task.id && c.authorId === me)) return setEditing({ kind: "mine" });
5714
+ return go.notify("You haven't commented on this task.");
5715
+ }
5536
5716
  if (input === "x") return moveTask(store, go, task, "done");
5537
5717
  if (input === "d") return go.confirm(`Delete ${keyOf({ projects }, task)} “${task.title}”? u undoes it.`, () => {
5538
5718
  store.getState().deleteTask(task.id);
@@ -5545,12 +5725,14 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
5545
5725
  kind: "field",
5546
5726
  field: FIELDS[at].field
5547
5727
  });
5548
- }, { isActive: editing === null });
5728
+ }, { isActive: active && editing === null });
5549
5729
  if (!task) return null;
5550
5730
  const project = projects.find((p) => p.id === task.projectId);
5551
5731
  const owner = people.find((p) => p.id === task.assigneeId);
5552
5732
  const waits = openBlockerNames(task, tasks);
5553
5733
  const thread = comments.filter((c) => c.taskId === task.id).slice(-4);
5734
+ const mine = comments.filter((c) => c.taskId === task.id && c.authorId === me);
5735
+ const blockers = blockersOf(task, tasks);
5554
5736
  const done = () => setEditing(null);
5555
5737
  const value = {
5556
5738
  title: task.title,
@@ -5559,6 +5741,8 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
5559
5741
  due: task.dueDate ? `${shortDay(task.dueDate, day)}${isLate(task, day) ? " (late)" : ""}` : "None",
5560
5742
  priority: PRIORITY_LABEL[task.priority],
5561
5743
  project: project ? `${project.name}${project.key ? ` (${project.key})` : ""}` : "",
5744
+ waits: blockers.length ? blockers.map((b) => `${keyOf({ projects }, b)}${b.status === "done" ? " (done)" : ""}`).join(", ") : "Nothing",
5745
+ repeat: task.repeat ? REPEAT_LABEL[task.repeat] : "Never",
5562
5746
  notes: task.description.replace(/\s+/g, " ").trim() || "None"
5563
5747
  };
5564
5748
  let editor = null;
@@ -5579,7 +5763,44 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
5579
5763
  done();
5580
5764
  }
5581
5765
  });
5582
- else if (editing?.kind === "field") {
5766
+ else if (editing?.kind === "mine") editor = /* @__PURE__ */ jsx(Picker, {
5767
+ title: "One of your comments: Enter to change it or take it out",
5768
+ choices: mine.map((c) => ({
5769
+ label: c.body.replace(/\s+/g, " "),
5770
+ value: c.id
5771
+ })),
5772
+ initial: mine.length - 1,
5773
+ onCancel: done,
5774
+ onPick: (id) => {
5775
+ const c = mine.find((x) => x.id === id);
5776
+ setEditing(null);
5777
+ go.pick("This comment", [{
5778
+ label: "Change the words",
5779
+ value: "edit"
5780
+ }, {
5781
+ label: "Take it out",
5782
+ value: "delete"
5783
+ }], (what) => {
5784
+ if (what === "edit") setEditing({
5785
+ kind: "reword",
5786
+ id: c.id
5787
+ });
5788
+ else go.confirm("Take out your comment? u undoes it.", () => store.getState().deleteComment(c.id), "Take it out");
5789
+ });
5790
+ }
5791
+ });
5792
+ else if (editing?.kind === "reword") {
5793
+ const c = comments.find((x) => x.id === editing.id);
5794
+ editor = /* @__PURE__ */ jsx(TextField, {
5795
+ label: "Your comment",
5796
+ initial: c?.body ?? "",
5797
+ onCancel: done,
5798
+ onSubmit: (text) => {
5799
+ if (c && text.trim() && text.trim() !== c.body) store.getState().updateComment(c.id, text.trim());
5800
+ done();
5801
+ }
5802
+ });
5803
+ } else if (editing?.kind === "field") {
5583
5804
  const f = editing.field;
5584
5805
  if (f === "title" || f === "notes") editor = /* @__PURE__ */ jsx(TextField, {
5585
5806
  label: f === "title" ? "Title" : "Notes",
@@ -5647,7 +5868,24 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
5647
5868
  done();
5648
5869
  }
5649
5870
  });
5650
- } else if (f === "project") {
5871
+ } else if (f === "repeat") editor = /* @__PURE__ */ jsx(Picker, {
5872
+ title: "Repeats (finishing it queues the next one)",
5873
+ choices: REPEATS.map((r) => ({
5874
+ label: r ? REPEAT_LABEL[r] : "Never",
5875
+ value: r
5876
+ })),
5877
+ initial: REPEATS.indexOf(task.repeat ?? null),
5878
+ onCancel: done,
5879
+ onPick: (r) => {
5880
+ store.getState().updateTask(task.id, { repeat: r });
5881
+ done();
5882
+ }
5883
+ });
5884
+ else if (f === "waits") editor = /* @__PURE__ */ jsx(WaitsEditor, {
5885
+ task,
5886
+ onDone: done
5887
+ });
5888
+ else if (f === "project") {
5651
5889
  const choices = projects.map((p) => ({
5652
5890
  label: p.name,
5653
5891
  value: p.id,
@@ -5780,7 +6018,7 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
5780
6018
  editor ?? /* @__PURE__ */ jsx(Text, {
5781
6019
  color: theme.muted,
5782
6020
  wrap: "truncate-end",
5783
- children: "↑↓ field · Enter change · c comment · a add step · space tick · x done · d delete · Esc close"
6021
+ children: "↑↓ field · Enter change · c comment · C yours · a add step · space tick · x done · d delete · Esc close"
5784
6022
  })
5785
6023
  ]
5786
6024
  });
@@ -5803,16 +6041,140 @@ function DueField({ initial, today, onSubmit, onCancel }) {
5803
6041
  })
5804
6042
  });
5805
6043
  }
6044
+ /**
6045
+ * What a task waits on. Empty, it lists the current ones (Enter stops
6046
+ * waiting on one); typing finds others by key or words (Enter adds it).
6047
+ * Tasks already waiting on this one aren't offered, so nothing loops.
6048
+ */
6049
+ function WaitsEditor({ task, onDone }) {
6050
+ const { store } = useDeps();
6051
+ const tasks = useHarbor((s) => s.tasks);
6052
+ const projects = useHarbor((s) => s.projects);
6053
+ const [query, setQuery] = useState("");
6054
+ const [at, setAt] = useState(0);
6055
+ const current = blockersOf(task, tasks);
6056
+ const hits = useMemo(() => {
6057
+ if (!query.trim()) return [];
6058
+ const taken = new Set(current.map((b) => b.id));
6059
+ const open = blockerOptions(tasks, task.id).filter((t) => !taken.has(t.id));
6060
+ return searchTasks(open, projects, query.trim(), 8).map((h) => h.item);
6061
+ }, [
6062
+ query,
6063
+ tasks,
6064
+ projects,
6065
+ task.id,
6066
+ current
6067
+ ]);
6068
+ const list = query.trim() ? hits : current;
6069
+ const set = (ids) => store.getState().updateTask(task.id, blockersPatch(ids));
6070
+ useInput((input, key) => {
6071
+ if (key.escape) return onDone();
6072
+ if (key.upArrow) return setAt((i) => step(i, -1, list.length));
6073
+ if (key.downArrow) return setAt((i) => step(i, 1, list.length));
6074
+ if (key.return) {
6075
+ const pick = list[Math.min(at, list.length - 1)];
6076
+ if (!pick) return onDone();
6077
+ if (query.trim()) {
6078
+ set([...current.map((b) => b.id), pick.id]);
6079
+ setQuery("");
6080
+ } else set(current.filter((b) => b.id !== pick.id).map((b) => b.id));
6081
+ return setAt(0);
6082
+ }
6083
+ if (key.backspace || key.delete) {
6084
+ setAt(0);
6085
+ return setQuery((q) => q.slice(0, -1));
6086
+ }
6087
+ if (key.ctrl || key.meta || key.tab || key.leftArrow || key.rightArrow) return;
6088
+ if (input) {
6089
+ setAt(0);
6090
+ setQuery((q) => q + input.replace(/[\r\n]+/g, " "));
6091
+ }
6092
+ });
6093
+ return /* @__PURE__ */ jsxs(Box, {
6094
+ flexDirection: "column",
6095
+ borderStyle: "round",
6096
+ borderColor: theme.border,
6097
+ paddingX: 1,
6098
+ children: [
6099
+ /* @__PURE__ */ jsx(Text, {
6100
+ bold: true,
6101
+ children: "Waits on"
6102
+ }),
6103
+ /* @__PURE__ */ jsxs(Text, { children: [query || /* @__PURE__ */ jsx(Text, {
6104
+ color: theme.muted,
6105
+ children: "type a key like RR-42, or words, to add one"
6106
+ }), /* @__PURE__ */ jsx(Text, {
6107
+ inverse: true,
6108
+ children: " "
6109
+ })] }),
6110
+ list.length === 0 ? /* @__PURE__ */ jsx(Text, {
6111
+ color: theme.muted,
6112
+ children: query.trim() ? "Nothing matches." : "Nothing yet."
6113
+ }) : null,
6114
+ list.map((t, i) => {
6115
+ const on = i === Math.min(at, list.length - 1);
6116
+ return /* @__PURE__ */ jsxs(Text, {
6117
+ color: on ? theme.selected : void 0,
6118
+ bold: on,
6119
+ wrap: "truncate-end",
6120
+ children: [
6121
+ on ? "› " : " ",
6122
+ /* @__PURE__ */ jsx(Text, {
6123
+ color: theme.muted,
6124
+ children: keyOf({ projects }, t).padEnd(7)
6125
+ }),
6126
+ t.title,
6127
+ /* @__PURE__ */ jsxs(Text, {
6128
+ color: theme.muted,
6129
+ children: [" · ", STATUS_LABEL[t.status]]
6130
+ })
6131
+ ]
6132
+ }, t.id);
6133
+ }),
6134
+ /* @__PURE__ */ jsxs(Text, {
6135
+ color: theme.muted,
6136
+ children: [query.trim() ? "Enter adds it" : "Enter stops waiting on it", " · Esc done"]
6137
+ })
6138
+ ]
6139
+ });
6140
+ }
6141
+
6142
+ //#endregion
6143
+ //#region src/lib/focus.ts
6144
+ const FOCUS_CAP = 3;
6145
+ const CHECK_IN_QUESTION = "On the blocker, or on something else?";
6146
+ function focusIdsOf(data, personId, date = todayISO()) {
6147
+ return (data.ropes ?? []).find((r) => r.personId === personId && r.date === date)?.taskIds ?? [];
6148
+ }
6149
+ function upsertFocus(ropes, personId, date, taskIds) {
6150
+ const rest = (ropes ?? []).filter((r) => !(r.personId === personId && r.date === date));
6151
+ if (taskIds.length === 0) return rest;
6152
+ return [...rest, {
6153
+ personId,
6154
+ date,
6155
+ taskIds: taskIds.slice(0, 3)
6156
+ }];
6157
+ }
6158
+ function myCheckIn(data, personId, date = todayISO()) {
6159
+ return (data.checkIns ?? []).find((c) => c.personId === personId && c.date === date);
6160
+ }
5806
6161
 
5807
6162
  //#endregion
5808
6163
  //#region src/tui/views/today.tsx
5809
- /** What's on you: your open tasks, late first; who waits on you; your risks and issues. */
6164
+ /**
6165
+ * What's on you: today's focus and check-in, your open tasks, late first;
6166
+ * who waits on you; your risks and issues.
6167
+ */
5810
6168
  function TodayView({ active, width, height, go }) {
5811
6169
  const { store, today } = useDeps();
5812
6170
  const tasks = useHarbor((s) => s.tasks);
5813
6171
  const projects = useHarbor((s) => s.projects);
5814
6172
  const raidItems = useHarbor((s) => s.raidItems);
5815
6173
  const me = useHarbor((s) => s.currentUserId);
6174
+ const ropes = useHarbor((s) => s.ropes);
6175
+ const checkIns = useHarbor((s) => s.checkIns);
6176
+ const focus = focusIdsOf({ ropes }, me).map((id) => tasks.find((t) => t.id === id)).filter((t) => t !== void 0 && t.status !== "done");
6177
+ const said = myCheckIn({ checkIns }, me);
5816
6178
  const day = today();
5817
6179
  const data = useMemo(() => ({
5818
6180
  tasks,
@@ -5841,8 +6203,24 @@ function TodayView({ active, width, height, go }) {
5841
6203
  else if (key.return && selected) go.openTask(selected.id);
5842
6204
  else if (input === "x" && selected) moveTask(store, go, selected, "done");
5843
6205
  else if (input === "n") go.newTask(selected?.projectId ?? null);
6206
+ else if (input === "f" && selected) {
6207
+ if (store.getState().toggleFocus(selected.id) === "full") go.notify(`Today's focus holds ${3}. f on one of them takes it off first.`);
6208
+ } else if (input === "c") go.ask("Check in: what are you on today? (one sentence)", (text) => {
6209
+ const note = text.trim().slice(0, 280);
6210
+ if (!note) return;
6211
+ go.pick(CHECK_IN_QUESTION, [{
6212
+ label: "On the blocker",
6213
+ value: true
6214
+ }, {
6215
+ label: "Other work",
6216
+ value: false
6217
+ }], (onDrum) => {
6218
+ store.getState().submitCheckIn(note, onDrum === true);
6219
+ go.notify("Checked in for today.");
6220
+ });
6221
+ }, said?.body);
5844
6222
  }, { isActive: active });
5845
- const extra = (waits.length ? waits.length + 2 : 0) + (risks.length ? Math.min(risks.length, 5) + 2 : 0);
6223
+ const extra = (focus.length ? focus.length + 2 : 0) + 1 + (waits.length ? waits.length + 2 : 0) + (risks.length ? Math.min(risks.length, 5) + 2 : 0);
5846
6224
  const rows = Math.max(3, height - 2 - extra);
5847
6225
  const view = windowOf(mine, at, rows);
5848
6226
  return /* @__PURE__ */ jsxs(Box, {
@@ -5851,8 +6229,32 @@ function TodayView({ active, width, height, go }) {
5851
6229
  height,
5852
6230
  paddingX: 1,
5853
6231
  children: [
6232
+ focus.length > 0 && /* @__PURE__ */ jsxs(Box, {
6233
+ flexDirection: "column",
6234
+ marginBottom: 1,
6235
+ children: [/* @__PURE__ */ jsx(Heading, {
6236
+ right: `${focus.length} of ${3} · f takes one off`,
6237
+ children: "Today's focus"
6238
+ }), focus.map((t) => /* @__PURE__ */ jsxs(Text, {
6239
+ wrap: "truncate-end",
6240
+ children: [
6241
+ " ",
6242
+ /* @__PURE__ */ jsx(Text, {
6243
+ color: theme.accent,
6244
+ children: keyOf({ projects }, t)
6245
+ }),
6246
+ " ",
6247
+ t.title
6248
+ ]
6249
+ }, t.id))]
6250
+ }),
6251
+ /* @__PURE__ */ jsx(Text, {
6252
+ color: theme.muted,
6253
+ wrap: "truncate-end",
6254
+ children: said ? `Your check-in: ${said.body}${said.onDrum ? " (on the blocker)" : ""} · c changes it` : "No check-in today · c checks in"
6255
+ }),
5854
6256
  /* @__PURE__ */ jsx(Heading, {
5855
- right: `${mine.length} open${late ? ` · ${late} late` : ""}`,
6257
+ right: `${mine.length} open${late ? ` · ${late} late` : ""} · f focus`,
5856
6258
  children: "On you"
5857
6259
  }),
5858
6260
  mine.length === 0 ? /* @__PURE__ */ jsx(Text, {
@@ -6272,6 +6674,7 @@ function App({ ui }) {
6272
6674
  width: mainWidth,
6273
6675
  height: bodyHeight,
6274
6676
  go,
6677
+ active: !covered,
6275
6678
  onClose: () => setPanel(null)
6276
6679
  };
6277
6680
  let panelNode = null;
@@ -6311,6 +6714,7 @@ function App({ ui }) {
6311
6714
  ["x", "done"],
6312
6715
  ["d", "delete"],
6313
6716
  ["v", "board/list/budget"],
6717
+ ["f p", "filter"],
6314
6718
  ["e", "details"],
6315
6719
  ["Esc", "back"]
6316
6720
  ] : page ? [
@@ -6437,9 +6841,8 @@ function App({ ui }) {
6437
6841
  display: overlayNode || panelNode ? "none" : "flex",
6438
6842
  children: screenNode
6439
6843
  }),
6440
- panelNode && !overlayNode ? panelNode : null,
6441
- panelNode && overlayNode ? /* @__PURE__ */ jsx(Box, {
6442
- display: "none",
6844
+ panelNode ? /* @__PURE__ */ jsx(Box, {
6845
+ display: overlayNode ? "none" : "flex",
6443
6846
  children: panelNode
6444
6847
  }) : null
6445
6848
  ]
@@ -6477,7 +6880,7 @@ function recordHealth(history, health, at, override, note) {
6477
6880
 
6478
6881
  //#endregion
6479
6882
  //#region src/lib/derived-health.ts
6480
- const iso$1 = (d) => {
6883
+ const iso = (d) => {
6481
6884
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
6482
6885
  };
6483
6886
  function parseDate(value) {
@@ -6520,7 +6923,7 @@ let overdueDay = "";
6520
6923
  const overdueByDue = /* @__PURE__ */ new Map();
6521
6924
  function daysOverdue(dueDate, now) {
6522
6925
  if (!dueDate) return 0;
6523
- const today = iso$1(now);
6926
+ const today = iso(now);
6524
6927
  if (today !== overdueDay) {
6525
6928
  overdueDay = today;
6526
6929
  overdueByDue.clear();
@@ -6541,7 +6944,7 @@ let untilDay = "";
6541
6944
  const untilByDate = /* @__PURE__ */ new Map();
6542
6945
  function daysUntil(date, now) {
6543
6946
  if (!date) return null;
6544
- const today = iso$1(now);
6947
+ const today = iso(now);
6545
6948
  if (today !== untilDay) {
6546
6949
  untilDay = today;
6547
6950
  untilByDate.clear();
@@ -6602,7 +7005,7 @@ function effectiveProjectHealth(project, data, now) {
6602
7005
  }
6603
7006
  /** Milestones past their date and still open. */
6604
7007
  function lateMilestonesOf(milestones, now) {
6605
- const today = iso$1(now);
7008
+ const today = iso(now);
6606
7009
  return milestones.filter((m) => !m.done && m.dueDate < today);
6607
7010
  }
6608
7011
  /**
@@ -7825,56 +8228,6 @@ function applyAutomations(project, prev, next, opts) {
7825
8228
  };
7826
8229
  }
7827
8230
 
7828
- //#endregion
7829
- //#region src/lib/focus.ts
7830
- const FOCUS_CAP = 3;
7831
- function upsertFocus(ropes, personId, date, taskIds) {
7832
- const rest = (ropes ?? []).filter((r) => !(r.personId === personId && r.date === date));
7833
- if (taskIds.length === 0) return rest;
7834
- return [...rest, {
7835
- personId,
7836
- date,
7837
- taskIds: taskIds.slice(0, 3)
7838
- }];
7839
- }
7840
-
7841
- //#endregion
7842
- //#region src/lib/recurrence.ts
7843
- const iso = (d) => {
7844
- return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
7845
- };
7846
- /** The due date one interval after `from`. */
7847
- function nextDueDate(from, repeat) {
7848
- const d = /* @__PURE__ */ new Date(`${from}T12:00:00`);
7849
- if (repeat === "weekly") return iso(addDays(d, 7));
7850
- if (repeat === "biweekly") return iso(addDays(d, 14));
7851
- return iso(addMonths(d, 1));
7852
- }
7853
- /**
7854
- * The next occurrence of a repeating task once this one is done: same
7855
- * title, owner, priority and checklist (unticked), due one interval after
7856
- * the old due date (or after today when it had none), back in To do.
7857
- * Null when the task does not repeat.
7858
- */
7859
- function nextOccurrence(task, id, today, createdAt) {
7860
- if (!task.repeat) return null;
7861
- const anchor = task.dueDate && task.dueDate >= today ? task.dueDate : today;
7862
- return {
7863
- ...task,
7864
- id,
7865
- status: "todo",
7866
- dueDate: nextDueDate(anchor, task.repeat),
7867
- createdAt,
7868
- completedAt: null,
7869
- blockedById: null,
7870
- blockedByIds: [],
7871
- checklist: (task.checklist ?? []).map((c) => ({
7872
- ...c,
7873
- done: false
7874
- }))
7875
- };
7876
- }
7877
-
7878
8231
  //#endregion
7879
8232
  //#region src/lib/assignment.ts
7880
8233
  /**
package/lib/cli.mjs CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  import {
20
20
  harbors,
21
21
  myWork,
22
+ raidList,
22
23
  programTree,
23
24
  projectList,
24
25
  taskLine,
@@ -59,13 +60,18 @@ export const HELP = `harbor: Harbor from the terminal
59
60
  [--over-limit]
60
61
  harbor start TASK | review TASK | done TASK [--confirm] [--over-limit]
61
62
  harbor edit TASK [--title T] [--assignee me|none|NAME] [--due DATE|none]
62
- [--priority P] [--status S] [--confirm] [--over-limit]
63
+ [--priority P] [--status S] [--notes TEXT]
64
+ [--repeat weekly|biweekly|monthly|never]
65
+ [--waits-on TASK,TASK|none] [--confirm] [--over-limit]
63
66
  harbor comment TASK "TEXT"
64
67
  harbor checkin "WHAT YOU'RE DOING" [--on-blocker]
65
68
  harbor risk "TITLE" --project P|--program P [--impact 1-5] [--probability 1-5]
66
69
  [--owner NAME] [--due DATE]
67
70
  harbor issue "TITLE" --project P|--program P [--impact 1-5] [--owner NAME]
68
71
  [--due DATE]
72
+ harbor decision "TITLE" --project P|--program P [--owner NAME] [--due DATE]
73
+ harbor risks [words] [--kind risk,issue,decision] [--project P] [--closed]
74
+ the register, worst first
69
75
 
70
76
  Every command takes --json, and --harbor NAME when you're in more than one
71
77
  harbor (or set one with harbor use). harbor help COMMAND shows just that one.
@@ -144,6 +150,7 @@ const level = (v, name) => {
144
150
  return n;
145
151
  };
146
152
  const nullable = (v) => (v === "none" ? null : v);
153
+ const REPEAT_WORD = { weekly: "every week", biweekly: "every two weeks", monthly: "every month" };
147
154
 
148
155
  /** --due as people say it: today, tomorrow, +3, or a date. Anything else goes to Harbor to judge. */
149
156
  export function dueDate(v, today = new Date()) {
@@ -176,6 +183,9 @@ const FLAG = {
176
183
  query: "the words",
177
184
  task: "the task",
178
185
  note: "the check-in",
186
+ waitsOn: "--waits-on",
187
+ repeat: "--repeat",
188
+ description: "--notes",
179
189
  body: "the comment",
180
190
  };
181
191
 
@@ -213,7 +223,7 @@ export function hint(message) {
213
223
  .replace(/Find it with search_tasks\.$/, "Find it with harbor tasks.")
214
224
  .replace(
215
225
  /^Nothing to change: pass a field to update\.$/,
216
- "Nothing to change. Say what with --title, --assignee, --due, --priority or --status.",
226
+ "Nothing to change. Say what with --title, --assignee, --due, --priority, --status, --notes, --repeat or --waits-on.",
217
227
  )
218
228
  .replace(
219
229
  /^This connection was allowed to read only\..*$/s,
@@ -782,8 +792,12 @@ export async function run(argv, io = {}) {
782
792
  }
783
793
  case "edit": {
784
794
  const { positionals, values, harbor } = parse(
785
- spec(["confirm", "over-limit"], ["title", "assignee", "due", "priority", "status"]),
795
+ spec(
796
+ ["confirm", "over-limit"],
797
+ ["title", "assignee", "due", "priority", "status", "notes", "repeat", "waits-on"],
798
+ ),
786
799
  );
800
+ const waits = values["waits-on"];
787
801
  return call(
788
802
  "update_task",
789
803
  {
@@ -794,10 +808,28 @@ export async function run(argv, io = {}) {
794
808
  due: values.due === undefined ? undefined : nullable(dueDate(values.due)),
795
809
  priority: values.priority,
796
810
  status: values.status,
811
+ description: values.notes,
812
+ repeat: values.repeat,
813
+ waitsOn:
814
+ waits === undefined
815
+ ? undefined
816
+ : waits === "none"
817
+ ? []
818
+ : waits
819
+ .split(",")
820
+ .map((x) => x.trim())
821
+ .filter(Boolean),
797
822
  confirmed: values.confirm || undefined,
798
823
  overLimit: values["over-limit"] || undefined,
799
824
  },
800
- (r) => `Updated: ${taskLine(r.updated, opts)}`,
825
+ (r) =>
826
+ [
827
+ `Updated: ${taskLine(r.updated, opts)}`,
828
+ r.updated.waitsOn ? `Waits on ${r.updated.waitsOn.join(", ")}.` : null,
829
+ r.updated.repeat ? `Repeats ${REPEAT_WORD[r.updated.repeat]}.` : null,
830
+ ]
831
+ .filter(Boolean)
832
+ .join("\n"),
801
833
  );
802
834
  }
803
835
  case "comment": {
@@ -823,13 +855,34 @@ export async function run(argv, io = {}) {
823
855
  () => "Checked in for today.",
824
856
  );
825
857
  }
858
+ case "risks": {
859
+ const { positionals, values, harbor } = parse(spec(["closed"], ["kind", "project"]));
860
+ const kind = values.kind
861
+ ?.split(",")
862
+ .map((x) => x.trim().replace(/s$/, ""))
863
+ .filter(Boolean);
864
+ const bad = kind?.find((k) => !["risk", "issue", "decision"].includes(k));
865
+ if (bad) throw new HarborError(`--kind is risk, issue or decision (not "${bad}").`);
866
+ return call(
867
+ "list_risks",
868
+ {
869
+ harbor,
870
+ kind,
871
+ project: values.project,
872
+ includeClosed: Boolean(values.closed),
873
+ limit: 50,
874
+ },
875
+ (r) => raidList(r, positionals.join(" ")),
876
+ );
877
+ }
826
878
  case "risk":
827
- case "issue": {
879
+ case "issue":
880
+ case "decision": {
828
881
  const { positionals, values, harbor } = parse(
829
882
  spec(
830
883
  [],
831
- ["project", "program", "owner", "impact", "due"].concat(
832
- command === "risk" ? ["probability"] : [],
884
+ ["project", "program", "owner", "due"].concat(
885
+ command === "risk" ? ["impact", "probability"] : command === "issue" ? ["impact"] : [],
833
886
  ),
834
887
  ),
835
888
  );
package/lib/format.mjs CHANGED
@@ -158,3 +158,28 @@ export function todayLocal(d = new Date()) {
158
158
  const p = (n) => String(n).padStart(2, "0");
159
159
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
160
160
  }
161
+
162
+ /** The register, worst first: kind, title, then status, scores, owner and where it sits. */
163
+ export function raidList(r, words = "") {
164
+ const want = words.trim().toLowerCase();
165
+ const items = (r.items ?? []).filter(
166
+ (x) => !want || `${x.title} ${x.description ?? ""}`.toLowerCase().includes(want),
167
+ );
168
+ if (!items.length) return want ? "Nothing in the register matches." : "The register is clear.";
169
+ const lines = items.map((x) => {
170
+ const bits = [
171
+ x.status?.toLowerCase(),
172
+ x.probability != null ? `likelihood ${x.probability}` : null,
173
+ x.impact != null ? `impact ${x.impact}` : null,
174
+ x.owner,
175
+ x.project ?? x.program ?? x.portfolio ?? null,
176
+ x.due ? `${x.kind === "Decision" ? "decide by" : "review by"} ${x.due}` : null,
177
+ ].filter(Boolean);
178
+ return `${x.kind.toLowerCase().padEnd(8)} ${x.title} (${bits.join(" · ")})`;
179
+ });
180
+ const more =
181
+ !want && r.total > items.length
182
+ ? `\n…and ${r.total - items.length} more. Narrow it with --kind or --project.`
183
+ : "";
184
+ return lines.join("\n") + more;
185
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.11.1",
3
+ "version": "0.12.0",
4
4
  "description": "Harbor from the terminal: a full-screen app with everything, plus commands for scripts and agents.",
5
5
  "keywords": [
6
6
  "harbor",