@harborppm/cli 0.70.0 → 0.71.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/lib/cli.mjs CHANGED
@@ -156,7 +156,7 @@ export const HELP = `harbor: Harbor from the terminal
156
156
  [--ends DATE] [--budget N] [--from "DONE PROJECT"]
157
157
  --from: plan it from a finished
158
158
  project as that one really ran
159
- harbor add "TITLE" --project P [--assignee me|none|NAME]
159
+ harbor add "TITLE" [--project P] [--assignee me|none|NAME]
160
160
  [--due DATE|pilot] [--priority low|medium|high|urgent]
161
161
  [--status S] [--comment TEXT] [--over-limit] [--dry-run]
162
162
  with no TITLE: one title a line
@@ -284,6 +284,10 @@ export const HELP = `harbor: Harbor from the terminal
284
284
  Changes show in Harbor's History as yours, "via Harbor CLI". These commands
285
285
  never delete; the full-screen app can, as the website can, and asks first,
286
286
  and so can harbor fog --sweep's x (drop it), one task at a time.
287
+ Practice: a new harbor starts with First voyage, a project of practice
288
+ tasks that tick themselves off as you try things; the brief, harbor week,
289
+ bridge and forecast leave it out. To look around a made-up harbor instead,
290
+ open harborppm.com/demo or run ssh try.harborppm.com.
287
291
  `;
288
292
 
289
293
  /**
@@ -442,6 +446,32 @@ const REPEAT_WORD = { weekly: "every week", biweekly: "every two weeks", monthly
442
446
 
443
447
  const WEEKDAYS = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
444
448
 
449
+ /** The one open project a task can go in (First voyage aside), or why --project is needed. */
450
+ async function onlyProject(client, harbor) {
451
+ const r = await client.call("list_projects", {
452
+ harbor,
453
+ health: ["on-track", "at-risk", "off-track", "paused"],
454
+ limit: 5,
455
+ });
456
+ const open = (r.projects ?? []).filter((p) => p.id !== "voyage");
457
+ if (open.length === 1) return open[0].name;
458
+ if (open.length === 0)
459
+ throw new HarborError('This harbor has no open project yet; harbor project "NAME" --program P makes one.');
460
+ const names = open.slice(0, 3).map((p) => p.name);
461
+ const more = open.length > 3 || r.total > 5 ? ", …" : "";
462
+ throw new HarborError(`Say which project: --project NAME (${names.join(", ")}${more}).`);
463
+ }
464
+
465
+ const WEEKDAY_NAME = WEEKDAYS.map((d) => d[0].toUpperCase() + d.slice(1));
466
+
467
+ /** The Monday of a day's week; Saturday and Sunday look ahead to the next, as Harbor's weeks do. */
468
+ function mondayOf(iso) {
469
+ const d = new Date(`${iso}T12:00:00`);
470
+ const wd = d.getDay();
471
+ const back = wd === 0 ? -1 : wd === 6 ? -2 : wd - 1;
472
+ return todayLocal(new Date(d.getFullYear(), d.getMonth(), d.getDate() - back));
473
+ }
474
+
445
475
  /** "fri", "Friday": how many days until that weekday comes round (today counts). */
446
476
  function daysToWeekday(v, today) {
447
477
  const w = v.toLowerCase();
@@ -621,6 +651,9 @@ const DETAIL = {
621
651
  " harbor add --project P --dry-run reads: says what it would add, and",
622
652
  " adds nothing",
623
653
  "",
654
+ " --project can be left out when the harbor has one open project (First",
655
+ " voyage aside); the task goes there.",
656
+ "",
624
657
  " --assignee me|none|NAME, --due DATE|pilot, --priority low|medium|high|urgent,",
625
658
  " --status S, --comment TEXT and --over-limit apply to every task added, so",
626
659
  " ten lines with --comment add the same comment to all ten. More than 100",
@@ -645,7 +678,10 @@ const DETAIL = {
645
678
  " harbor week --day-hours N writes: the hours you work a day",
646
679
  "",
647
680
  " Only you see your week. Tasks in review are left out of what's not on a",
648
- " day yet, since they wait on someone else.",
681
+ " day yet, since they wait on someone else. A weekday already gone this",
682
+ " week means next week's (mon on a Friday is the coming Monday); add",
683
+ " --week today to plan this week's. First voyage's practice tasks are left",
684
+ " out.",
649
685
  ],
650
686
  init: [
651
687
  " harbor init [PROJECT] [--hook] writes: .harbor/ at the top of this",
@@ -1809,7 +1845,7 @@ export async function run(argv, io = {}) {
1809
1845
  const client = createClient({ creds, save: (c) => writeCredentials(c, env), fetcher });
1810
1846
  // A terminal's lines fit its width; piped output stays whole for scripts.
1811
1847
  const opts = {
1812
- today: todayLocal(),
1848
+ today: todayLocal(io.now ? new Date(io.now()) : new Date()),
1813
1849
  width: process.stdout.isTTY ? process.stdout.columns : undefined,
1814
1850
  };
1815
1851
 
@@ -2298,7 +2334,8 @@ export async function run(argv, io = {}) {
2298
2334
  ["project", "assignee", "due", "priority", "status", "comment"],
2299
2335
  ),
2300
2336
  );
2301
- if (!values.project) throw new HarborError("Say which project: --project NAME.");
2337
+ // No --project: the harbor's one open project, if that's all there is.
2338
+ if (!values.project) values.project = await onlyProject(client, harbor);
2302
2339
  // Several at once: one title a line on stdin, with no title (or -) after add.
2303
2340
  const piped =
2304
2341
  positionals.length === 0 || (positionals.length === 1 && positionals[0] === "-");
@@ -2335,7 +2372,11 @@ export async function run(argv, io = {}) {
2335
2372
  dryRun: true,
2336
2373
  project: values.project,
2337
2374
  titles,
2338
- ...(pilot ? { due: pilot.map((d) => d.earliest) } : {}),
2375
+ ...(pilot
2376
+ ? { due: pilot.map((d) => d.earliest) }
2377
+ : values.due
2378
+ ? { due: titles.map(() => dueDate(values.due)) }
2379
+ : {}),
2339
2380
  }),
2340
2381
  );
2341
2382
  else
@@ -2343,7 +2384,10 @@ export async function run(argv, io = {}) {
2343
2384
  plain(
2344
2385
  [
2345
2386
  `Would add ${n} to ${values.project}:`,
2346
- ...titles.map((t, i) => ` ${t}${pilot ? ` (due ${pilot[i].earliest})` : ""}`),
2387
+ ...titles.map(
2388
+ (t, i) =>
2389
+ ` ${t}${pilot ? ` (due ${pilot[i].earliest})` : values.due ? ` (due ${dueDate(values.due)})` : ""}`,
2390
+ ),
2347
2391
  ...(pilot
2348
2392
  ? [
2349
2393
  `--due pilot: ${pilot[0].says}; ${pilot[0].why.map((w) => w.charAt(0).toLowerCase() + w.slice(1)).join("; ")}.`,
@@ -2536,11 +2580,14 @@ export async function run(argv, io = {}) {
2536
2580
  throw new HarborError(`--${name} is a number of hours, like 2 or 1.5.`);
2537
2581
  return n;
2538
2582
  };
2539
- // The day comes as --day; a bare second word still works, as it did first.
2583
+ // The day comes as --day; a bare second word (the first form) is refused, not guessed.
2540
2584
  const [task, bareDay, ...extra] = positionals;
2541
- if (extra.length || (bareDay && values.day))
2542
- throw new HarborError("Give a task key and a day: harbor week RR-42 --day wed.");
2543
- const day = values.day ?? bareDay;
2585
+ if (bareDay && !extra.length && !values.day)
2586
+ throw new HarborError(
2587
+ `Say the day with --day: harbor week ${task} --day ${bareDay.toLowerCase()}.`,
2588
+ );
2589
+ if (bareDay) throw new HarborError("Give a task key and a day: harbor week RR-42 --day wed.");
2590
+ const day = values.day;
2544
2591
  const dayHours = number(values["day-hours"], "day-hours");
2545
2592
  const show = (r) =>
2546
2593
  [r.done, weekText(r, { ...opts, review: Boolean(values.review) })]
@@ -2555,18 +2602,28 @@ export async function run(argv, io = {}) {
2555
2602
  throw new HarborError(
2556
2603
  `Say which day for ${task}: harbor week ${task} --day wed, or --off to take it off the week.`,
2557
2604
  );
2605
+ // A weekday already gone this week means the next one, as --due friday does.
2606
+ let planWeek = week;
2607
+ let ahead = "";
2608
+ if (day && !values.off && !values.week && !values.next && !values.last) {
2609
+ const next = dueDate(day, new Date(`${opts.today}T12:00:00`));
2610
+ if (next !== day && mondayOf(next) !== mondayOf(opts.today)) {
2611
+ planWeek = next;
2612
+ ahead = `${WEEKDAY_NAME[new Date(`${next}T12:00:00`).getDay()]} has gone this week, so that's next week's (${next}); add --week today for this week's.`;
2613
+ }
2614
+ }
2558
2615
  return call(
2559
2616
  "plan_week",
2560
2617
  {
2561
2618
  harbor,
2562
- week,
2619
+ week: planWeek,
2563
2620
  ...(task ? { task } : {}),
2564
2621
  ...(day ? { day } : {}),
2565
2622
  ...(values.hours ? { hours: number(values.hours, "hours") } : {}),
2566
2623
  ...(values.off ? { remove: true } : {}),
2567
2624
  ...(dayHours !== undefined ? { dayHours } : {}),
2568
2625
  },
2569
- show,
2626
+ (r) => [ahead, show(r)].filter(Boolean).join("\n\n"),
2570
2627
  );
2571
2628
  }
2572
2629
  case "comment": {
package/lib/format.mjs CHANGED
@@ -31,10 +31,13 @@ export function statusWord(typed) {
31
31
  * piped output keeps every title whole.
32
32
  */
33
33
  export function taskLine(t, { today, mine = false, width } = {}) {
34
- const late = t.due && today && t.due < today && t.status !== "done";
34
+ const open = t.status !== "done";
35
+ const late = t.due && today && t.due < today && open;
36
+ // Today and tomorrow in words, so what's close stands out from the dates.
37
+ const soon = open && today && t.due ? soonWord(t.due, today) : null;
35
38
  const bits = [
36
39
  STATUS[t.status] ?? t.status,
37
- t.due ? (late ? `late, due ${t.due}` : `due ${t.due}`) : null,
40
+ t.due ? (late ? `late, due ${t.due}` : soon ? `due ${soon}` : `due ${t.due}`) : null,
38
41
  t.priority && t.priority !== "medium" ? t.priority : null,
39
42
  mine ? null : (t.assignee ?? null),
40
43
  t.project ?? null,
@@ -45,6 +48,14 @@ export function taskLine(t, { today, mine = false, width } = {}) {
45
48
  return key + fitTitle(t.title, width ? width - 1 - key.length - tail.length : Infinity) + tail;
46
49
  }
47
50
 
51
+ /** "today" or "tomorrow" for a due date that close, else null. */
52
+ function soonWord(due, today) {
53
+ if (due === today) return "today";
54
+ const d = new Date(`${today}T12:00:00`);
55
+ d.setDate(d.getDate() + 1);
56
+ return due === todayLocal(d) ? "tomorrow" : null;
57
+ }
58
+
48
59
  /** The title cut to `room` characters with an ellipsis, but never below 20. */
49
60
  function fitTitle(title, room) {
50
61
  const chars = [...title];
@@ -1053,7 +1064,10 @@ export function weekText(w, { today, review = false, width } = {}) {
1053
1064
  }
1054
1065
  }
1055
1066
  if (w.unplannedCount) {
1056
- out.push("", `Not on a day yet: ${w.unplannedCount}. harbor week KEY DAY puts one there.`);
1067
+ out.push(
1068
+ "",
1069
+ `Not on a day yet: ${w.unplannedCount}. harbor week KEY --day DAY puts one there.`,
1070
+ );
1057
1071
  for (const t of w.unplanned.slice(0, 5))
1058
1072
  out.push(` ${taskLine(t, { today, mine: true, width: width ? width - 2 : undefined })}`);
1059
1073
  if (w.unplannedCount > 5)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.70.0",
3
+ "version": "0.71.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",