@harborppm/cli 0.94.0 → 0.95.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.
Files changed (3) hide show
  1. package/dist/app.mjs +137 -5
  2. package/lib/cli.mjs +27 -3
  3. package/package.json +1 -1
package/dist/app.mjs CHANGED
@@ -5779,7 +5779,7 @@ function matches$1(row, f) {
5779
5779
  if (f.hideDone && row.health === "done") return false;
5780
5780
  return true;
5781
5781
  }
5782
- function milestoneOf(m, today) {
5782
+ function milestoneOf$1(m, today) {
5783
5783
  return {
5784
5784
  id: m.id,
5785
5785
  name: m.name,
@@ -5818,7 +5818,7 @@ function roadmapRows(data, portfolioId, filter, collapsed, today) {
5818
5818
  baselineEnd: null,
5819
5819
  slip: null,
5820
5820
  waitsOn: blockerIds(program).filter((id) => programIds.has(id)),
5821
- milestones: data.milestones.filter((m) => m.programId === program.id && !(filter.hideDone && m.done)).sort((a, b) => a.dueDate.localeCompare(b.dueDate)).map((m) => milestoneOf(m, today)),
5821
+ milestones: data.milestones.filter((m) => m.programId === program.id && !(filter.hideDone && m.done)).sort((a, b) => a.dueDate.localeCompare(b.dueDate)).map((m) => milestoneOf$1(m, today)),
5822
5822
  projectCount: projects.length
5823
5823
  });
5824
5824
  if (collapsed.has(program.id)) continue;
@@ -14121,6 +14121,107 @@ function applyDock(actions, projectId, changes) {
14121
14121
  return made;
14122
14122
  }
14123
14123
 
14124
+ // src/lib/task-milestones.ts
14125
+ /** The milestone a task is tied to, or null when it isn't (or the tie no longer holds). */
14126
+ function milestoneOf(task, milestones, projects) {
14127
+ if (!task.milestoneId) return null;
14128
+ const m = byId(milestones).get(task.milestoneId);
14129
+ if (!m) return null;
14130
+ const project = byId(projects).get(task.projectId);
14131
+ return project && project.programId === m.programId ? m : null;
14132
+ }
14133
+ /** For each milestone with tasks tied to it, how many there are and how many are done. One pass. */
14134
+ function milestoneProgress(doc) {
14135
+ const out = /* @__PURE__ */ new Map();
14136
+ if (doc.milestones.length === 0) return out;
14137
+ for (const t of doc.tasks) {
14138
+ if (!t.milestoneId) continue;
14139
+ const m = milestoneOf(t, doc.milestones, doc.projects);
14140
+ if (!m) continue;
14141
+ const p = out.get(m.id) ?? {
14142
+ done: 0,
14143
+ total: 0
14144
+ };
14145
+ p.total++;
14146
+ if (t.status === "done") p.done++;
14147
+ out.set(m.id, p);
14148
+ }
14149
+ return out;
14150
+ }
14151
+ /** Every project it waits on is done (no waits, or a project gone, count as clear). */
14152
+ function waitsClear(m, projects) {
14153
+ const index = byId(projects);
14154
+ return blockerIds(m).every((id) => {
14155
+ const p = index.get(id);
14156
+ return !p || p.health === "done";
14157
+ });
14158
+ }
14159
+ /**
14160
+ * The document after a write, with milestones moved by what happened to their
14161
+ * tasks: a milestone that lost an open task (finished, deleted, untied or
14162
+ * moved away) and now has all its tasks done is reached; a reached one that
14163
+ * gained an open task (reopened, tied, added or moved in) is open again. The
14164
+ * same document back when nothing moved. Only tasks this write changed are
14165
+ * looked at, then one pass counts the milestones they touch.
14166
+ */
14167
+ function followMilestones(before, after) {
14168
+ if (after.tasks === before.tasks || after.milestones.length === 0) return after;
14169
+ const was = byId(before.tasks);
14170
+ const tie = (t, doc) => milestoneOf(t, doc.milestones, doc.projects)?.id ?? null;
14171
+ const lost = /* @__PURE__ */ new Set();
14172
+ const gained = /* @__PURE__ */ new Set();
14173
+ const seen = /* @__PURE__ */ new Set();
14174
+ for (const t of after.tasks) {
14175
+ const old = was.get(t.id);
14176
+ if (old) seen.add(t.id);
14177
+ if (old === t) continue;
14178
+ const from = old ? tie(old, before) : null;
14179
+ const to = tie(t, after);
14180
+ const wasOpen = old ? old.status !== "done" : false;
14181
+ const isOpen = t.status !== "done";
14182
+ if (from && wasOpen && (from !== to || !isOpen)) lost.add(from);
14183
+ if (to && isOpen && (from !== to || !wasOpen)) gained.add(to);
14184
+ }
14185
+ if (seen.size < before.tasks.length) for (const old of before.tasks) {
14186
+ if (seen.has(old.id) || old.status === "done") continue;
14187
+ const from = tie(old, before);
14188
+ if (from) lost.add(from);
14189
+ }
14190
+ if (lost.size === 0 && gained.size === 0) return after;
14191
+ const progress = milestoneProgress(after);
14192
+ let changed = false;
14193
+ const milestones = after.milestones.map((m) => {
14194
+ if (!follows(m)) return m;
14195
+ const p = progress.get(m.id);
14196
+ if (!m.done && lost.has(m.id) && p && p.total > 0 && p.done === p.total) {
14197
+ if (!waitsClear(m, after.projects)) return m;
14198
+ changed = true;
14199
+ return {
14200
+ ...m,
14201
+ done: true
14202
+ };
14203
+ }
14204
+ if (m.done && gained.has(m.id)) {
14205
+ changed = true;
14206
+ return {
14207
+ ...m,
14208
+ done: false
14209
+ };
14210
+ }
14211
+ return m;
14212
+ });
14213
+ return changed ? {
14214
+ ...after,
14215
+ milestones
14216
+ } : after;
14217
+ }
14218
+ var follows;
14219
+ var init_task_milestones = __esmMin((() => {
14220
+ init_blockers();
14221
+ init_indexes();
14222
+ follows = (m) => !m.approverId;
14223
+ }));
14224
+
14124
14225
  // src/lib/time-limits.ts
14125
14226
  var MAX_ENTRY_MINUTES;
14126
14227
  var init_time_limits = __esmMin((() => {
@@ -14128,6 +14229,7 @@ var init_time_limits = __esmMin((() => {
14128
14229
  }));
14129
14230
 
14130
14231
  // src/lib/time-log.ts
14232
+ init_task_milestones();
14131
14233
  /** "1h 30m", "45m", "12h". */
14132
14234
  function formatMinutes(total) {
14133
14235
  const n = Math.max(0, Math.round(total));
@@ -21947,7 +22049,7 @@ const HARBOR_TOOLS = [
21947
22049
  tool({
21948
22050
  name: "get_task",
21949
22051
  title: "Task details",
21950
- description: "One task: its owner (assignee) and anyone also on it (alsoOn), notes, checklist, its files, what it waits on, what waits on it, its latest comments, the earliest due date its owner's queue suggests (pilotDue: fits says whether the due date holds; absent with no owner, an agent owner, or once done), its place in its board column (`position`, from 1 at the top), time logged on it (timeLogged: the total and the latest entries, when there is any), its values for the project's own fields by name (fields, when it has any; set them with set_field), its subtasks or the task it's a subtask of (subtaskOf), and its url on the website." + DATA_NOTE,
22052
+ description: "One task: its owner (assignee) and anyone also on it (alsoOn), notes, checklist, its files, what it waits on, what waits on it, its latest comments, the earliest due date its owner's queue suggests (pilotDue: fits says whether the due date holds; absent with no owner, an agent owner, or once done), its place in its board column (`position`, from 1 at the top), time logged on it (timeLogged: the total and the latest entries, when there is any), its values for the project's own fields by name (fields, when it has any; set them with set_field), its subtasks or the task it's a subtask of (subtaskOf), the milestone it's tied to (milestone: its due day, whether it's reached, and how many of its tasks are done of all tied to it), and its url on the website." + DATA_NOTE,
21951
22053
  input: object({
21952
22054
  harbor: harborArg,
21953
22055
  task: string().min(1).max(100).describe("Task id or key (\"RR-42\"), from search_tasks or my_work.")
@@ -22001,6 +22103,12 @@ const HARBOR_TOOLS = [
22001
22103
  waitingOn: blockersOf(t, data.tasks).map(brief),
22002
22104
  waitedOnBy: waiters.map(brief),
22003
22105
  ...((parent) => parent ? { subtaskOf: brief(parent) } : {})(parentOf(t, data.tasks)),
22106
+ ...((m) => m ? { milestone: {
22107
+ name: m.name,
22108
+ due: dayOf(m.dueDate),
22109
+ reached: m.done,
22110
+ ...milestoneProgress(data).get(m.id)
22111
+ } } : {})(milestoneOf(t, data.milestones, data.projects)),
22004
22112
  ...((subs) => subs.length ? { subtasks: subs.map((x) => ({
22005
22113
  ...brief(x),
22006
22114
  assignee: nameOf$2(people, x.assigneeId),
@@ -24511,6 +24619,7 @@ function nextOccurrence(task, id, today, createdAt, siblings = []) {
24511
24619
  id,
24512
24620
  status: "todo",
24513
24621
  dueDate,
24622
+ milestoneId: null,
24514
24623
  ...task.startDate ? { startDate: lead != null && lead >= 0 ? format(addDays(/* @__PURE__ */ new Date(`${dueDate}T12:00:00`), -lead), "yyyy-MM-dd") : null } : {},
24515
24624
  createdAt,
24516
24625
  completedAt: null,
@@ -25962,10 +26071,10 @@ function createAppStore(onNotice = () => {}) {
25962
26071
  };
25963
26072
  const write = (partial) => {
25964
26073
  const before = get();
25965
- guardedSet((s) => stampHealth(numberNewTasks(s, {
26074
+ guardedSet((s) => stampHealth(followMilestones(s, numberNewTasks(s, {
25966
26075
  ...s,
25967
26076
  ...partial
25968
- })));
26077
+ }))));
25969
26078
  undo = nextStep(undo, before, get(), Date.now());
25970
26079
  };
25971
26080
  const api = {
@@ -26031,6 +26140,7 @@ var init_store = __esmMin((() => {
26031
26140
  init_vanilla();
26032
26141
  init_derived_health();
26033
26142
  init_task_keys();
26143
+ init_task_milestones();
26034
26144
  init_payload();
26035
26145
  init_undo();
26036
26146
  init_view_only();
@@ -31311,6 +31421,7 @@ init_recurrence();
31311
31421
  init_task_people();
31312
31422
  init_search();
31313
31423
  init_swatches();
31424
+ init_task_milestones();
31314
31425
  const FIELDS = [
31315
31426
  {
31316
31427
  field: "title",
@@ -31360,6 +31471,7 @@ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
31360
31471
  const { store, today } = useDeps();
31361
31472
  const tasks = useHarbor((s) => s.tasks);
31362
31473
  const projects = useHarbor((s) => s.projects);
31474
+ const milestones = useHarbor((s) => s.milestones);
31363
31475
  const people = useHarbor((s) => s.people);
31364
31476
  const comments = useHarbor((s) => s.comments);
31365
31477
  const reflections = useHarbor((s) => s.reflections);
@@ -31426,6 +31538,12 @@ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
31426
31538
  const mine = comments.filter((c) => c.taskId === task.id && c.authorId === me);
31427
31539
  const blockers = blockersOf(task, tasks);
31428
31540
  const subtaskOf = parentOf(task, tasks);
31541
+ const milestone = milestoneOf(task, milestones, projects);
31542
+ const toward = milestone ? milestoneProgress({
31543
+ tasks,
31544
+ milestones,
31545
+ projects
31546
+ }).get(milestone.id) : void 0;
31429
31547
  const subs = subtasksOf(task, tasks);
31430
31548
  const done = () => setEditing(null);
31431
31549
  const nameOf = (id) => people.find((p) => p.id === id)?.name;
@@ -31790,6 +31908,20 @@ function TaskPanel({ taskId, width, height, go, active = true, onClose }) {
31790
31908
  subtaskOf.title
31791
31909
  ]
31792
31910
  }) : null,
31911
+ milestone ? /* @__PURE__ */ jsxs(Text, {
31912
+ color: theme.muted,
31913
+ wrap: "truncate-end",
31914
+ children: [
31915
+ " ",
31916
+ "Toward ",
31917
+ milestone.name,
31918
+ " (",
31919
+ milestone.dueDate.slice(0, 10),
31920
+ "):",
31921
+ " ",
31922
+ milestone.done ? "reached" : `${toward?.done ?? 0}/${toward?.total ?? 0} of its tasks done`
31923
+ ]
31924
+ }) : null,
31793
31925
  subs.length > 0 ? /* @__PURE__ */ jsxs(Text, {
31794
31926
  color: theme.muted,
31795
31927
  children: [
package/lib/cli.mjs CHANGED
@@ -170,6 +170,7 @@ export const HELP = `harbor: Harbor from the terminal
170
170
  [--priority low|medium|high|urgent]
171
171
  [--status S] [--comment TEXT] [--over-limit] [--dry-run]
172
172
  [--literal] [--template NAME] [--parent TASK]
173
+ [--milestone NAME]
173
174
  reads plain words in TITLE as the
174
175
  website does: @name, by friday,
175
176
  !high, #project (--literal: keep
@@ -186,7 +187,7 @@ export const HELP = `harbor: Harbor from the terminal
186
187
  [--priority P] [--status S] [--notes TEXT]
187
188
  [--repeat weekly|biweekly|monthly|never]
188
189
  [--waits-on TASK,TASK|none] [--parent TASK|none]
189
- [--confirm] [--over-limit]
190
+ [--milestone NAME|none] [--confirm] [--over-limit]
190
191
  harbor limit PROJECT [COLUMN N|none] how many tasks a board column
191
192
  takes at once; the owner sets it
192
193
  harbor week [--next|--last|--week DATE] [--review]
@@ -710,7 +711,7 @@ export function hint(message, project) {
710
711
  )
711
712
  .replace(
712
713
  /^Nothing to change: pass a field to update\.$/,
713
- "Nothing to change. Say what with --title, --assignee, --also, --due, --starts, --priority, --status, --notes, --repeat, --waits-on or --parent.",
714
+ "Nothing to change. Say what with --title, --assignee, --also, --due, --starts, --priority, --status, --notes, --repeat, --waits-on, --parent or --milestone.",
714
715
  )
715
716
  .replace(
716
717
  /^This connection was allowed to read only\..*$/s,
@@ -795,6 +796,11 @@ const DETAIL = {
795
796
  " own owner and date, shown in the parent's details rather than on the",
796
797
  " board. It goes in the parent's project; subtasks go one level deep.",
797
798
  "",
799
+ " --milestone NAME ties it to one of the program's milestones: the milestone",
800
+ " is reached once its tasks are done, and opens again when one reopens. The",
801
+ " project's timeline shows each milestone under its tasks. harbor edit TASK",
802
+ " --milestone NAME|none changes or clears it.",
803
+ "",
798
804
  " --starts DATE gives it a start day as well as a due day, so the project's",
799
805
  " timeline draws it as a bar from one to the other; a start after the due",
800
806
  " day comes to the due day. harbor edit TASK --starts DATE|none moves it.",
@@ -2312,6 +2318,10 @@ export async function run(argv, io = {}) {
2312
2318
  if (t.alsoOn?.length) lines.push(`Also on it: ${t.alsoOn.join(", ")}.`);
2313
2319
  if (t.starts) lines.push(`Starts ${t.starts}${t.due ? `, due ${t.due}` : ""}.`);
2314
2320
  if (t.subtaskOf) lines.push(`Subtask of ${t.subtaskOf.key ?? t.subtaskOf.id} ${t.subtaskOf.title}.`);
2321
+ if (t.milestone)
2322
+ lines.push(
2323
+ `Toward the milestone ${t.milestone.name} (${t.milestone.due}): ${t.milestone.reached ? "reached" : `${t.milestone.done} of ${t.milestone.total} of its tasks done`}.`,
2324
+ );
2315
2325
  if (t.repeats) lines.push(`Repeats ${REPEAT_WORD[t.repeats] ?? t.repeats}.`);
2316
2326
  for (const [name, v] of Object.entries(t.fields ?? {}))
2317
2327
  lines.push(`${name}: ${Array.isArray(v) ? v.join(", ") : v === true ? "yes" : v}`);
@@ -2564,7 +2574,7 @@ export async function run(argv, io = {}) {
2564
2574
  const { positionals, values, harbor } = parse(
2565
2575
  spec(
2566
2576
  ["over-limit", "dry-run", "literal"],
2567
- ["project", "assignee", "also", "due", "starts", "priority", "status", "comment", "template", "parent"],
2577
+ ["project", "assignee", "also", "due", "starts", "priority", "status", "comment", "template", "parent", "milestone"],
2568
2578
  ),
2569
2579
  );
2570
2580
  // A subtask goes in its parent's project.
@@ -2674,6 +2684,7 @@ export async function run(argv, io = {}) {
2674
2684
  priority: values.priority ?? words.priority?.value,
2675
2685
  status: values.status === undefined ? undefined : statusWord(values.status),
2676
2686
  parent: values.parent,
2687
+ milestone: values.milestone,
2677
2688
  overLimit: values["over-limit"] || undefined,
2678
2689
  })
2679
2690
  .catch((err) => {
@@ -2712,6 +2723,8 @@ export async function run(argv, io = {}) {
2712
2723
  ...(dueWords(values.due) ? [`--due ${values.due}: ${dayWithWeekday(dueDate(values.due))}.`] : []),
2713
2724
  // Pilot speaks up only when a date was given that the queue says won't hold.
2714
2725
  ...(values.due ? pilotLines(r, r.created) : []),
2726
+ ...(r.created.milestone ? [`Toward the milestone ${r.created.milestone}.`] : []),
2727
+ ...(r.milestoneReopened ? [`${r.milestoneReopened} is open again: it has an open task now.`] : []),
2715
2728
  ].join("\n"),
2716
2729
  )
2717
2730
  .join("\n"),
@@ -2777,6 +2790,8 @@ export async function run(argv, io = {}) {
2777
2790
  [
2778
2791
  `${r.updated.title}: ${r.reopened ? "reopened, " : ""}${{ doing: "started", review: "in review", done: "done" }[status]}.`,
2779
2792
  r.queuedNext ? `Next one: ${taskLine(r.queuedNext, opts)}` : "",
2793
+ r.milestoneReached ? `That was the last of its tasks: ${r.milestoneReached} is reached.` : "",
2794
+ r.milestoneReopened ? `${r.milestoneReopened} is open again: one of its tasks is open.` : "",
2780
2795
  r.projectFinished
2781
2796
  ? `That was the last open task in ${r.projectFinished}. Mark the project Done in the app (harbor) or on the website.`
2782
2797
  : "",
@@ -2801,6 +2816,7 @@ export async function run(argv, io = {}) {
2801
2816
  "repeat",
2802
2817
  "waits-on",
2803
2818
  "parent",
2819
+ "milestone",
2804
2820
  ],
2805
2821
  ),
2806
2822
  );
@@ -2833,6 +2849,7 @@ export async function run(argv, io = {}) {
2833
2849
  .map((x) => x.trim())
2834
2850
  .filter(Boolean),
2835
2851
  parent: values.parent,
2852
+ milestone: values.milestone,
2836
2853
  confirmed: values.confirm || undefined,
2837
2854
  overLimit: values["over-limit"] || undefined,
2838
2855
  },
@@ -2849,6 +2866,13 @@ export async function run(argv, io = {}) {
2849
2866
  ? `A subtask of ${r.updated.subtaskOf}.`
2850
2867
  : "A task of its own now."
2851
2868
  : null,
2869
+ values.milestone !== undefined
2870
+ ? r.updated.milestone
2871
+ ? `Toward the milestone ${r.updated.milestone}.`
2872
+ : "Not tied to a milestone now."
2873
+ : null,
2874
+ r.milestoneReached ? `That was the last of its tasks: ${r.milestoneReached} is reached.` : null,
2875
+ r.milestoneReopened ? `${r.milestoneReopened} is open again: one of its tasks is open.` : null,
2852
2876
  values.also !== undefined
2853
2877
  ? r.updated.alsoOn?.length
2854
2878
  ? `Also on it: ${r.updated.alsoOn.join(", ")}.`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.94.0",
3
+ "version": "0.95.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",