@harborppm/cli 0.24.0 → 0.28.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
@@ -94,6 +94,9 @@ harbor programs programs and their projects
94
94
  harbor projects [words] [--program P] projects with health and open work
95
95
  harbor forecast [PROJECT] [--program P] when projects will likely finish, from
96
96
  how fast their crews finish tasks
97
+ harbor hill [--program P] where each project is on its hill
98
+ harbor hill PROJECT --at 0-100|top|done move it: up to 50 is figuring it out,
99
+ past 50 is getting it done
97
100
  harbor mine what's on you, late first
98
101
  harbor tasks [words] [--project P] [--assignee me|none|NAME] [--late] [--status S,S]
99
102
  harbor show TASK one task in full, with its web link
package/dist/app.mjs CHANGED
@@ -4062,6 +4062,48 @@ function CalendarView({ active, width, height, go }) {
4062
4062
  });
4063
4063
  }
4064
4064
 
4065
+ //#endregion
4066
+ //#region src/lib/hill.ts
4067
+ /**
4068
+ * Hill charts, after Basecamp's Shape Up: each project is a dot on a hill.
4069
+ * Climbing the left side means still figuring out how to do it; rolling down
4070
+ * the right means the unknowns are solved and it's just the work. Owners
4071
+ * place their own dot, and a dot nobody has moved in two weeks fades, so a
4072
+ * stuck project shows without anyone saying so.
4073
+ */
4074
+ /** Where the top of the hill is, on the 0–100 scale. */
4075
+ const HILL_TOP = 50;
4076
+ /** Days without a move before a dot reads as still. */
4077
+ const STILL_DAYS = 14;
4078
+ const HILL_PHASE_LABEL = {
4079
+ figuring: "Figuring it out",
4080
+ doing: "Getting it done"
4081
+ };
4082
+ /** A position kept on the scale, in whole steps. */
4083
+ function clampHill(at) {
4084
+ if (!Number.isFinite(at)) return 0;
4085
+ return Math.max(0, Math.min(100, Math.round(at)));
4086
+ }
4087
+ function hillPhase(at) {
4088
+ return clampHill(at) < 50 ? "figuring" : "doing";
4089
+ }
4090
+ /** Days since the dot last moved, or null when it's never been placed. */
4091
+ function stillFor(project, today) {
4092
+ if (!project.hillMovedAt) return null;
4093
+ return Math.max(0, differenceInCalendarDays(/* @__PURE__ */ new Date(`${today}T12:00:00`), /* @__PURE__ */ new Date(`${project.hillMovedAt.slice(0, 10)}T12:00:00`)));
4094
+ }
4095
+ function isStill(project, today) {
4096
+ const days = stillFor(project, today);
4097
+ return days != null && days >= 14;
4098
+ }
4099
+ /** "Figuring it out, 40 of 100", or "Not on the hill yet". */
4100
+ function hillText(project) {
4101
+ if (project.hillAt == null) return "Not on the hill yet";
4102
+ const at = clampHill(project.hillAt);
4103
+ if (at === 100) return "Over the hill: done";
4104
+ return `${HILL_PHASE_LABEL[hillPhase(at)]} (${at} of 100)`;
4105
+ }
4106
+
4065
4107
  //#endregion
4066
4108
  //#region src/lib/history-copy.ts
4067
4109
  /**
@@ -4231,6 +4273,10 @@ function describeEvent(e, names) {
4231
4273
  action: "cleared the health override on",
4232
4274
  detail: null
4233
4275
  };
4276
+ case "hillAt": return {
4277
+ action: "moved",
4278
+ detail: `on the hill to ${hillText({ hillAt: to == null ? null : Number(to) }).toLowerCase()}`
4279
+ };
4234
4280
  case "programId": return {
4235
4281
  action: "moved",
4236
4282
  detail: `to ${nameOf$1(names, to, "another program")}`
@@ -5475,7 +5521,7 @@ const HELP = [
5475
5521
  ["Enter / Esc", "open / back"],
5476
5522
  ["n", "new: a task, or what the screen lists"],
5477
5523
  ["e", "change what's open (health, lead, dates)"],
5478
- ["[ ]", "move a card one column left or right"],
5524
+ ["[ ] / { }", "move a card a column / a project along its hill"],
5479
5525
  ["x / d", "mark done / delete (asks first)"],
5480
5526
  ["u / r", "undo your last change / retry saving"],
5481
5527
  ["v", "switch layout: board, list, budget, by project"],
@@ -7205,6 +7251,10 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
7205
7251
  else if (input === "d" && entry) go.confirm(`Take out ${money(entry.amount)}${entry.note ? ` for “${entry.note}”` : ""}? u undoes it.`, () => store.getState().removeSpend(entry.id), "Take it out");
7206
7252
  return;
7207
7253
  }
7254
+ if ((input === "{" || input === "}") && project) {
7255
+ const at = project.hillAt ?? (input === "}" ? -5 : 0);
7256
+ return store.getState().moveOnHill(projectId, at + (input === "}" ? 5 : -5));
7257
+ }
7208
7258
  if (input === "[") return moveSelected(-1);
7209
7259
  if (input === "]") return moveSelected(1);
7210
7260
  if (key.return && selected) return go.openTask(selected.id);
@@ -7299,10 +7349,19 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
7299
7349
  })
7300
7350
  })]
7301
7351
  }),
7302
- /* @__PURE__ */ jsx(Text, {
7303
- color: tide?.late ? theme.warn : theme.muted,
7352
+ /* @__PURE__ */ jsxs(Text, {
7304
7353
  wrap: "truncate-end",
7305
- children: tide ? tide.text : " "
7354
+ children: [/* @__PURE__ */ jsx(Text, {
7355
+ color: tide?.late ? theme.warn : theme.muted,
7356
+ children: tide ? `${tide.text} · ` : ""
7357
+ }), /* @__PURE__ */ jsxs(Text, {
7358
+ color: isStill(project, day) ? theme.warn : theme.muted,
7359
+ children: [
7360
+ project.hillAt == null ? "Not on the hill · } puts it on" : `Hill: ${hillText(project).toLowerCase()}`,
7361
+ isStill(project, day) ? `, still ${stillFor(project, day)} days` : "",
7362
+ project.hillAt == null ? "" : " · { } move it"
7363
+ ]
7364
+ })]
7306
7365
  }),
7307
7366
  mode === "budget" ? /* @__PURE__ */ jsx(BudgetPane, {
7308
7367
  project,
@@ -9774,6 +9833,14 @@ function hierarchySlice({ get, write }) {
9774
9833
  ...patch
9775
9834
  } : p) });
9776
9835
  },
9836
+ moveOnHill: (id, at) => {
9837
+ const hillAt = clampHill(at);
9838
+ write({ projects: get().projects.map((p) => p.id === id ? {
9839
+ ...p,
9840
+ hillAt,
9841
+ hillMovedAt: stamp()
9842
+ } : p) });
9843
+ },
9777
9844
  addCustomField: (projectId, def) => {
9778
9845
  const id = uid("cf");
9779
9846
  write({ projects: get().projects.map((p) => p.id === projectId ? {
package/lib/cli.mjs CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  STATUS,
23
23
  harbors,
24
24
  limitsText,
25
+ hillList,
25
26
  myWork,
26
27
  premortemText,
27
28
  voyageText,
@@ -64,6 +65,9 @@ export const HELP = `harbor: Harbor from the terminal
64
65
  projects with health and open work
65
66
  (--json adds each board's limits)
66
67
  harbor forecast [PROJECT] [--program P] when projects will likely finish
68
+ harbor hill [--program P] where each project is on its hill
69
+ harbor hill PROJECT --at 0-100|top|done move it: up to 50 is figuring it
70
+ out, past 50 is getting it done
67
71
  harbor mine what's on you, late first
68
72
  harbor tasks [words] [--project P] [--assignee me|none|NAME]
69
73
  [--late] [--status S,S]
@@ -223,6 +227,7 @@ const FLAG = {
223
227
  body: "the comment",
224
228
  column: "the column",
225
229
  limit: "the limit",
230
+ at: "--at",
226
231
  };
227
232
 
228
233
  /** Harbor's refusals are worded for agents; say them as the commands and flags that answer them. */
@@ -834,6 +839,27 @@ export async function run(argv, io = {}) {
834
839
  if (project) return call("get_project", { harbor, project }, forecastOne);
835
840
  return call("list_projects", { harbor, program: values.program, limit: 100 }, forecastList);
836
841
  }
842
+ case "hill": {
843
+ const { positionals, values, harbor } = parse(spec([], ["program", "at"]));
844
+ const project = positionals.join(" ").trim();
845
+ if (!project) {
846
+ if (values.at !== undefined)
847
+ throw new HarborError("Say which project: harbor hill PROJECT --at N.");
848
+ return call("list_projects", { harbor, program: values.program, limit: 100 }, hillList);
849
+ }
850
+ if (values.at === undefined) {
851
+ throw new HarborError("Say where with --at: 0 to 100, or start, top, doing or done.");
852
+ }
853
+ const at = readHillWord(values.at);
854
+ if (at === null)
855
+ throw new HarborError("--at is 0 to 100, or start, figuring, top, doing or done.");
856
+ return call(
857
+ "move_on_hill",
858
+ { harbor, project, at },
859
+ (r) =>
860
+ `Moved ${r.moved.project} on the hill: ${r.moved.where.replace(/^\w/, (c) => c.toLowerCase())}.`,
861
+ );
862
+ }
837
863
  case "mine": {
838
864
  const { positionals, harbor } = parse(spec());
839
865
  none(positionals);
@@ -1223,3 +1249,10 @@ export async function run(argv, io = {}) {
1223
1249
  throw new HarborError(`No command "${command}". Run harbor help.`);
1224
1250
  }
1225
1251
  }
1252
+
1253
+ /** --at for harbor hill: a number 0–100 or a word (src/lib/hill.ts readHill says the same). */
1254
+ function readHillWord(text) {
1255
+ const t = String(text).trim().toLowerCase();
1256
+ if (/^\d{1,3}$/.test(t)) return Number(t) <= 100 ? Number(t) : null;
1257
+ return { start: 0, figuring: 25, top: 50, doing: 75, done: 100 }[t] ?? null;
1258
+ }
package/lib/format.mjs CHANGED
@@ -287,3 +287,37 @@ export function voyageText(r) {
287
287
  "\n",
288
288
  );
289
289
  }
290
+
291
+ /** A position on a 21-wide track, the top of the hill marked in the middle. */
292
+ function hillTrack(at) {
293
+ const cells = Array.from({ length: 21 }, (_, i) => (i === 10 ? "┼" : "─"));
294
+ cells[Math.round(at / 5)] = "●";
295
+ return cells.join("");
296
+ }
297
+
298
+ /** Each open project's place on its program's hill, by program, unplaced ones named after. */
299
+ export function hillList(r) {
300
+ const projects = (r.projects ?? []).filter((p) => p.health !== "done");
301
+ if (!projects.length) return "No open projects match.";
302
+ const byProgram = new Map();
303
+ for (const p of projects) {
304
+ const key = p.program ?? "No program";
305
+ if (!byProgram.has(key)) byProgram.set(key, []);
306
+ byProgram.get(key).push(p);
307
+ }
308
+ const width = Math.min(28, Math.max(...projects.map((p) => p.name.length)));
309
+ const blocks = [];
310
+ for (const [program, list] of byProgram) {
311
+ const placed = list.filter((p) => p.hill).sort((a, b) => a.hill.at - b.hill.at);
312
+ const unplaced = list.filter((p) => !p.hill);
313
+ const lines = [program, ` ${"".padEnd(width)} figuring it out ┼ getting it done`];
314
+ for (const p of placed) {
315
+ const name = p.name.length > width ? `${p.name.slice(0, width - 1)}…` : p.name.padEnd(width);
316
+ const still = p.hill.daysSinceMoved >= 14 ? ` still ${p.hill.daysSinceMoved} days` : "";
317
+ lines.push(` ${name} ${hillTrack(p.hill.at)} ${String(p.hill.at).padStart(3)}${still}`);
318
+ }
319
+ if (unplaced.length) lines.push(` Not on the hill: ${unplaced.map((p) => p.name).join(", ")}`);
320
+ blocks.push(lines.join("\n"));
321
+ }
322
+ return blocks.join("\n\n");
323
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.24.0",
3
+ "version": "0.28.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",