@harborppm/cli 0.63.0 → 0.64.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
@@ -3275,6 +3275,13 @@ function barnaclesOf(project, tasks, people, now, slip = null) {
3275
3275
  const weekly = weeklyFinished(tasks, project.startDate, now);
3276
3276
  const finished = weekly.reduce((a, b) => a + b, 0);
3277
3277
  const perWeek = finished > 0 ? Math.round(finished / weekly.length * 10) / 10 : null;
3278
+ const byDay = /* @__PURE__ */ new Map();
3279
+ for (const t of added) byDay.set(t.createdAt.slice(0, 10), (byDay.get(t.createdAt.slice(0, 10)) ?? 0) + 1);
3280
+ const [bulkDay, bulk] = [...byDay].sort((a, b) => b[1] - a[1])[0] ?? [null, 0];
3281
+ const age = differenceInCalendarDays(noon$2(today), addDays(noon$2(since), -7)) / 7;
3282
+ let caveat = null;
3283
+ if (added.length && bulkDay && bulk >= 5 && bulk >= added.length * .5) caveat = `${bulk} of the ${added.length} came in on ${shortDate(bulkDay)}, which looks like the plan being loaded rather than growth, so treat this as rough. Setting a baseline counts from then on.`;
3284
+ else if (added.length && from === "start" && (planned < 5 || age < 3)) caveat = "There's no baseline yet and the plan is young, so its first weeks may be the plan being written: treat this as rough. Setting a baseline counts from then on.";
3278
3285
  return {
3279
3286
  since,
3280
3287
  from,
@@ -3286,7 +3293,8 @@ function barnaclesOf(project, tasks, people, now, slip = null) {
3286
3293
  adders,
3287
3294
  perWeek,
3288
3295
  days: perWeek ? Math.round(added.length / perWeek * 7) : null,
3289
- slip
3296
+ slip,
3297
+ caveat
3290
3298
  };
3291
3299
  }
3292
3300
  /**
@@ -3305,12 +3313,12 @@ function slipAgainstPlan(project, tasks, now, tide = tideForecast(project, tasks
3305
3313
  function barnaclesHeadline(b) {
3306
3314
  if (b.added === 0) return null;
3307
3315
  const when = b.from === "baseline" ? "since the baseline" : "since its first week";
3308
- const pct = b.share != null ? `, ${Math.round(b.share * 100)}% more than planned` : "";
3316
+ const pct = b.share != null && !b.caveat ? `, ${Math.round(b.share * 100)}% more than planned` : "";
3309
3317
  return `+${plural$7(b.added, "task")} ${when}${pct}`;
3310
3318
  }
3311
3319
  /** What the added work costs in days, and how much of the slip it explains. */
3312
3320
  function barnaclesDaysLine(b) {
3313
- if (b.added === 0 || b.days == null || b.perWeek == null) return null;
3321
+ if (b.added === 0 || b.days == null || b.perWeek == null || b.caveat) return null;
3314
3322
  const work = `At the crew's pace (about ${b.perWeek} a week) that's about ${plural$7(b.days, "day")} of work`;
3315
3323
  if (b.slip == null || b.slip <= 0) return `${work}.`;
3316
3324
  if (b.days >= b.slip) return `${work}: enough to explain all ${plural$7(b.slip, "day")} of the slip.`;
@@ -3318,24 +3326,28 @@ function barnaclesDaysLine(b) {
3318
3326
  }
3319
3327
  /** One line for the brief or a list: "+14 tasks (38%) since the baseline, 3 this week, about 65 days of work". */
3320
3328
  function barnaclesShort(b) {
3321
- const pct = b.share != null ? ` (${Math.round(b.share * 100)}%)` : "";
3329
+ const pct = b.share != null && !b.caveat ? ` (${Math.round(b.share * 100)}%)` : "";
3322
3330
  const when = b.from === "baseline" ? "since the baseline" : "since its first week";
3323
3331
  const week = b.thisWeek ? `, ${b.thisWeek} this week` : "";
3324
- const days = b.days != null ? `, about ${plural$7(b.days, "day")} of work` : "";
3332
+ const days = b.days != null && !b.caveat ? `, about ${plural$7(b.days, "day")} of work` : "";
3325
3333
  const top = b.adders.find((a) => a.personId);
3326
3334
  const who = top && top.added * 2 > b.added ? `, mostly from ${top.name}` : "";
3327
3335
  return `+${plural$7(b.added, "task")}${pct} ${when}${week}${days}${who}`;
3328
3336
  }
3329
3337
  /** Whether the brief names it: measured from a baseline, enough added, and enough of the plan. */
3330
3338
  function briefWorthy(b) {
3331
- return b.from === "baseline" && b.added >= 3 && (b.share == null || b.share >= .15);
3339
+ return b.from === "baseline" && !b.caveat && b.added >= 3 && (b.share == null || b.share >= .15);
3332
3340
  }
3333
- var SETTLE_DAYS, BRIEF_MIN_ADDED, BRIEF_MIN_SHARE, noon$2, isoDay$3, plural$7;
3341
+ var SETTLE_DAYS, THIN_PLANNED, THIN_WEEKS, BULK_SHARE, BULK_MIN, BRIEF_MIN_ADDED, BRIEF_MIN_SHARE, noon$2, isoDay$3, plural$7;
3334
3342
  var init_barnacles = __esmMin((() => {
3335
3343
  init_date_fns();
3336
3344
  init_format();
3337
3345
  init_tide();
3338
3346
  SETTLE_DAYS = 7;
3347
+ THIN_PLANNED = 5;
3348
+ THIN_WEEKS = 3;
3349
+ BULK_SHARE = .5;
3350
+ BULK_MIN = 5;
3339
3351
  BRIEF_MIN_ADDED = 3;
3340
3352
  BRIEF_MIN_SHARE = .15;
3341
3353
  noon$2 = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
@@ -3358,7 +3370,7 @@ function projectKeyFor(name, taken) {
3358
3370
  for (let n = 2;; n++) if (!taken.has(`${base}${n}`)) return `${base}${n}`;
3359
3371
  }
3360
3372
  /** Whether `a` has the better claim to the number both hold. */
3361
- function first$2(a, b, stored) {
3373
+ function first$3(a, b, stored) {
3362
3374
  const ka = kept(a, stored);
3363
3375
  if (ka !== kept(b, stored)) return ka;
3364
3376
  return made(a, b) <= 0;
@@ -3406,7 +3418,7 @@ function settleKeys(data, stored) {
3406
3418
  }
3407
3419
  const other = holder.get(n);
3408
3420
  if (other) {
3409
- const [keep, move] = first$2(other, t, stored) ? [other, t] : [t, other];
3421
+ const [keep, move] = first$3(other, t, stored) ? [other, t] : [t, other];
3410
3422
  holder.set(n, keep);
3411
3423
  loose.push(move);
3412
3424
  } else holder.set(n, t);
@@ -3570,8 +3582,8 @@ function thanksSince(doc, since, keep = () => true) {
3570
3582
  if (!target || !keep(target)) continue;
3571
3583
  out.push({
3572
3584
  thanks: r,
3573
- from: first$1(doc, r.authorId),
3574
- to: first$1(doc, r.toId),
3585
+ from: first$2(doc, r.authorId),
3586
+ to: first$2(doc, r.toId),
3575
3587
  for: target.title
3576
3588
  });
3577
3589
  }
@@ -3581,12 +3593,12 @@ function thanksSince(doc, since, keep = () => true) {
3581
3593
  function thanksText(line) {
3582
3594
  return `${line.from} to ${line.to} for ${line.for}: “${line.thanks.body.trim()}”`;
3583
3595
  }
3584
- var isThanks, first$1;
3596
+ var isThanks, first$2;
3585
3597
  var init_thanks = __esmMin((() => {
3586
3598
  init_indexes();
3587
3599
  init_task_keys();
3588
3600
  isThanks = (r) => r.kind === "thanks" && !!r.thanksFor && !!r.toId;
3589
- first$1 = (doc, id) => doc.people.find((p) => p.id === id)?.name.split(" ")[0] ?? "Someone";
3601
+ first$2 = (doc, id) => doc.people.find((p) => p.id === id)?.name.split(" ")[0] ?? "Someone";
3590
3602
  }));
3591
3603
 
3592
3604
  //#endregion
@@ -3672,9 +3684,13 @@ function charterOverdue(project, today) {
3672
3684
  const days = differenceInCalendarDays(parseIso(today), parseIso(project.startDate.slice(0, 10)));
3673
3685
  return days >= 7 ? days : null;
3674
3686
  }
3675
- /** The items of an In or Out line: split on commas, semicolons, "and" lists and new lines. */
3687
+ /**
3688
+ * The items of an In or Out line: split on commas, semicolons, "and" lists and
3689
+ * new lines. A line that starts with a bullet (-, * or •) is one item whole,
3690
+ * commas and all, which is how `harbor charter --in A --in B` writes them.
3691
+ */
3676
3692
  function charterItems(text) {
3677
- return (text ?? "").split(/[\n,;•]+/).map((s) => s.replace(/^\s*(?:and|or|-|\*)\s+/i, "").trim()).filter((s) => contentWords(s).size > 0);
3693
+ return (text ?? "").split(/\n+/).flatMap((line) => /^\s*[-*•]\s+/.test(line) ? [line] : line.split(/[,;•]+/)).map((s) => s.replace(/^\s*(?:and|or|-|\*|•)\s+/i, "").trim()).filter((s) => contentWords(s).size > 0);
3678
3694
  }
3679
3695
  /** True when a task's title names an item: every meaningful word of a short item, or two of a longer one. */
3680
3696
  function names(item, title) {
@@ -3746,8 +3762,8 @@ init_date_fns();
3746
3762
  init_blockers();
3747
3763
  init_date_text();
3748
3764
  init_premortem();
3749
- const day$7 = (iso) => parseIso(iso.slice(0, 10));
3750
- const between = (a, b) => differenceInCalendarDays(day$7(b), day$7(a));
3765
+ const day$8 = (iso) => parseIso(iso.slice(0, 10));
3766
+ const between = (a, b) => differenceInCalendarDays(day$8(b), day$8(a));
3751
3767
  /** When the project was marked Done: its last move to Done in its health history. */
3752
3768
  function finishedOn(project, tasks) {
3753
3769
  const marked = [...project.healthHistory ?? []].reverse().find((h) => h.health === "done");
@@ -3820,7 +3836,7 @@ function voyageLog(data, projectId) {
3820
3836
  };
3821
3837
  }
3822
3838
  const plural$6 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
3823
- const md$2 = (iso) => format(day$7(iso), "MMM d");
3839
+ const md$2 = (iso) => format(day$8(iso), "MMM d");
3824
3840
  /** The three numbers at the top: how late, how much work, how many risks came true. */
3825
3841
  function voyageStats(log) {
3826
3842
  const happened = log.risks.filter((r) => r.happened).length;
@@ -4914,7 +4930,7 @@ var init_cascade = __esmMin((() => {
4914
4930
  //#region src/lib/forecast.ts
4915
4931
  init_cascade();
4916
4932
  init_date_fns();
4917
- const day$6 = (iso) => /* @__PURE__ */ new Date(`${iso}T12:00:00`);
4933
+ const day$7 = (iso) => /* @__PURE__ */ new Date(`${iso}T12:00:00`);
4918
4934
  /**
4919
4935
  * A straight-line estimate at completion from the burn so far: spend divided
4920
4936
  * by the share of the schedule elapsed. Null before anything is spent or
@@ -4923,8 +4939,8 @@ const day$6 = (iso) => /* @__PURE__ */ new Date(`${iso}T12:00:00`);
4923
4939
  */
4924
4940
  function forecastAtCompletion(item, now) {
4925
4941
  if (item.spent <= 0) return null;
4926
- const total = differenceInCalendarDays(day$6(item.endDate), day$6(item.startDate)) + 1;
4927
- const gone = differenceInCalendarDays(now, day$6(item.startDate)) + 1;
4942
+ const total = differenceInCalendarDays(day$7(item.endDate), day$7(item.startDate)) + 1;
4943
+ const gone = differenceInCalendarDays(now, day$7(item.startDate)) + 1;
4928
4944
  if (total <= 0 || gone <= 0) return null;
4929
4945
  const elapsed = Math.min(1, gone / total);
4930
4946
  const eac = Math.round(item.spent / elapsed);
@@ -5047,7 +5063,7 @@ var init_flow = __esmMin((() => {
5047
5063
  //#region src/lib/phasing.ts
5048
5064
  init_flow();
5049
5065
  init_date_fns();
5050
- const day$5 = (iso) => /* @__PURE__ */ new Date(`${iso}T12:00:00`);
5066
+ const day$6 = (iso) => /* @__PURE__ */ new Date(`${iso}T12:00:00`);
5051
5067
  function monthKey(iso) {
5052
5068
  return iso.slice(0, 7);
5053
5069
  }
@@ -5063,7 +5079,7 @@ function monthsBetween(from, to) {
5063
5079
  return out;
5064
5080
  }
5065
5081
  function daysIn(key) {
5066
- return getDaysInMonth(day$5(`${key}-01`));
5082
+ return getDaysInMonth(day$6(`${key}-01`));
5067
5083
  }
5068
5084
  /** How many of the schedule's days fall in `key`. */
5069
5085
  function overlapDays(key, start, end) {
@@ -5071,7 +5087,7 @@ function overlapDays(key, start, end) {
5071
5087
  const lastDay = `${key}-${String(daysIn(key)).padStart(2, "0")}`;
5072
5088
  const from = start > first ? start : first;
5073
5089
  const to = end < lastDay ? end : lastDay;
5074
- return to < from ? 0 : differenceInCalendarDays(day$5(to), day$5(from)) + 1;
5090
+ return to < from ? 0 : differenceInCalendarDays(day$6(to), day$6(from)) + 1;
5075
5091
  }
5076
5092
  /**
5077
5093
  * The budget spread straight across the schedule: each month gets the share
@@ -5081,7 +5097,7 @@ function overlapDays(key, start, end) {
5081
5097
  function evenPhasing(project) {
5082
5098
  const end = project.endDate < project.startDate ? project.startDate : project.endDate;
5083
5099
  const months = monthsBetween(project.startDate, end);
5084
- const total = differenceInCalendarDays(day$5(end), day$5(project.startDate)) + 1;
5100
+ const total = differenceInCalendarDays(day$6(end), day$6(project.startDate)) + 1;
5085
5101
  const out = {};
5086
5102
  let placed = 0;
5087
5103
  months.forEach((k, i) => {
@@ -5191,7 +5207,7 @@ function hasBaseline(p) {
5191
5207
  /** Days the end has moved past the baseline end; negative when it came in. Null without a baseline. */
5192
5208
  function slipDays(p) {
5193
5209
  if (!p.baselineEnd) return null;
5194
- return differenceInCalendarDays(day$4(p.endDate), day$4(p.baselineEnd));
5210
+ return differenceInCalendarDays(day$5(p.endDate), day$5(p.baselineEnd));
5195
5211
  }
5196
5212
  /**
5197
5213
  * The projects with the current dates committed as their baseline; ones that
@@ -5213,10 +5229,10 @@ function slipLabel(p) {
5213
5229
  const n = Math.abs(slip);
5214
5230
  return slip > 0 ? `slipped ${n} day${n === 1 ? "" : "s"}` : `${n} day${n === 1 ? "" : "s"} early`;
5215
5231
  }
5216
- var day$4;
5232
+ var day$5;
5217
5233
  var init_baseline = __esmMin((() => {
5218
5234
  init_date_fns();
5219
- day$4 = (iso) => /* @__PURE__ */ new Date(`${iso}T12:00:00`);
5235
+ day$5 = (iso) => /* @__PURE__ */ new Date(`${iso}T12:00:00`);
5220
5236
  }));
5221
5237
 
5222
5238
  //#endregion
@@ -5245,7 +5261,7 @@ const ZOOMS = [
5245
5261
  step: 3
5246
5262
  }
5247
5263
  ];
5248
- const day$3 = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
5264
+ const day$4 = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
5249
5265
  /** Names in the order people expect: "Phase 2" before "Phase 10". */
5250
5266
  const byName = (a, b) => a.localeCompare(b, void 0, { numeric: true });
5251
5267
  const isoDay$2 = (d) => format(d, "yyyy-MM-dd");
@@ -5255,7 +5271,7 @@ const isoDay$2 = (d) => format(d, "yyyy-MM-dd");
5255
5271
  */
5256
5272
  function roadmapWindow(zoom, today, shift = 0) {
5257
5273
  const z = ZOOMS.find((x) => x.id === zoom) ?? ZOOMS[1];
5258
- const first = addMonths$1(startOfMonth(day$3(today)), -1 + shift * z.step);
5274
+ const first = addMonths$1(startOfMonth(day$4(today)), -1 + shift * z.step);
5259
5275
  const after = addMonths$1(first, z.months);
5260
5276
  const days = differenceInCalendarDays(after, first);
5261
5277
  const months = Array.from({ length: z.months }, (_, i) => {
@@ -5279,15 +5295,15 @@ function roadmapWindow(zoom, today, shift = 0) {
5279
5295
  function at$1(iso, w) {
5280
5296
  if (!iso) return null;
5281
5297
  if (iso < w.start || iso >= w.end) return null;
5282
- return differenceInCalendarDays(day$3(iso), day$3(w.start)) / w.days;
5298
+ return differenceInCalendarDays(day$4(iso), day$4(w.start)) / w.days;
5283
5299
  }
5284
5300
  /** A bar for a start–end span, clipped to the window; null when it's wholly outside. */
5285
5301
  function span(start, end, w) {
5286
5302
  if (!start || !end || end < w.start || start >= w.end) return null;
5287
5303
  const from = start < w.start ? w.start : start;
5288
- const until = end >= w.end ? w.end : isoDay$2(new Date(day$3(end).getTime() + 864e5));
5289
- const left = differenceInCalendarDays(day$3(from), day$3(w.start)) / w.days;
5290
- const right = differenceInCalendarDays(day$3(until), day$3(w.start)) / w.days;
5304
+ const until = end >= w.end ? w.end : isoDay$2(new Date(day$4(end).getTime() + 864e5));
5305
+ const left = differenceInCalendarDays(day$4(from), day$4(w.start)) / w.days;
5306
+ const right = differenceInCalendarDays(day$4(until), day$4(w.start)) / w.days;
5291
5307
  return {
5292
5308
  left,
5293
5309
  width: Math.max(right - left, 1 / w.days),
@@ -7009,7 +7025,7 @@ function bottleState(note, ctx) {
7009
7025
  /** "opens on Nov 14", "opens when Factory buy-off is reached", "opens if Ridge Runner launch turns Off track" */
7010
7026
  function opensText(opens, ctx) {
7011
7027
  if (!opens) return "open now";
7012
- if (opens.on === "date") return `opens on ${day$2(opens.date)}`;
7028
+ if (opens.on === "date") return `opens on ${day$3(opens.date)}`;
7013
7029
  if (opens.on === "milestone") {
7014
7030
  const m = ctx.milestones.find((x) => x.id === opens.milestoneId);
7015
7031
  return m ? `opens when ${m.name} is reached` : "opens when its milestone is reached";
@@ -7043,7 +7059,7 @@ function freshlyOpened(data, projects, now) {
7043
7059
  }
7044
7060
  return out.sort((a, b) => (b.state.openedOn ?? "").localeCompare(a.state.openedOn ?? ""));
7045
7061
  }
7046
- var BOTTLE_FRESH_DAYS, isBottle, sealed, day$2;
7062
+ var BOTTLE_FRESH_DAYS, isBottle, sealed, day$3;
7047
7063
  var init_bottle = __esmMin((() => {
7048
7064
  init_date_fns();
7049
7065
  init_date_text();
@@ -7055,7 +7071,7 @@ var init_bottle = __esmMin((() => {
7055
7071
  open: false,
7056
7072
  openedOn: null
7057
7073
  };
7058
- day$2 = (iso) => format(parseIso(iso.slice(0, 10)), "MMM d");
7074
+ day$3 = (iso) => format(parseIso(iso.slice(0, 10)), "MMM d");
7059
7075
  }));
7060
7076
 
7061
7077
  //#endregion
@@ -7981,11 +7997,11 @@ function bridgeSince(program, now) {
7981
7997
  return addDays(now, -7).toISOString();
7982
7998
  }
7983
7999
  const after = (at, since) => !!at && !Number.isNaN(Date.parse(at)) && Date.parse(at) > Date.parse(since);
7984
- const first = (name) => name?.split(" ")[0] ?? null;
8000
+ const first$1 = (name) => name?.split(" ")[0] ?? null;
7985
8001
  /** "RR-4 Draft the copy (Sam)", with the key when it has one. */
7986
8002
  function taskLine$1(t, project, doc) {
7987
8003
  const key = taskKey(t, project);
7988
- const who = t.assigneeId ? first(byId(doc.people).get(t.assigneeId)?.name) : null;
8004
+ const who = t.assigneeId ? first$1(byId(doc.people).get(t.assigneeId)?.name) : null;
7989
8005
  return `${key ? `${key} ` : ""}${t.title}${who ? ` (${who})` : ""}`;
7990
8006
  }
7991
8007
  function listed(prefix, items) {
@@ -8141,7 +8157,7 @@ function meetingSummary(doc, slides, logged) {
8141
8157
  const project = t ? projects.get(t.projectId) : void 0;
8142
8158
  const program = project ? doc.programs.find((p) => p.id === project.programId) : void 0;
8143
8159
  if (!t || !project || !program) continue;
8144
- const who = t.assigneeId ? first(people.get(t.assigneeId)?.name) : null;
8160
+ const who = t.assigneeId ? first$1(people.get(t.assigneeId)?.name) : null;
8145
8161
  const key = taskKey(t, project);
8146
8162
  partOf(program).lines.push({
8147
8163
  kind: "action",
@@ -9077,11 +9093,11 @@ function dollars(value) {
9077
9093
  const n = Number(value ?? 0);
9078
9094
  return whole.format(Number.isFinite(n) ? n : 0);
9079
9095
  }
9080
- function day$1(value) {
9096
+ function day$2(value) {
9081
9097
  return value ? shortDate(value) : "no date";
9082
9098
  }
9083
9099
  /** Whole days from one ISO day to another. */
9084
- function daysBetween(from, to) {
9100
+ function daysBetween$1(from, to) {
9085
9101
  const a = Date.parse(`${from.slice(0, 10)}T12:00:00Z`);
9086
9102
  const b = Date.parse(`${to.slice(0, 10)}T12:00:00Z`);
9087
9103
  if (Number.isNaN(a) || Number.isNaN(b)) return 0;
@@ -9089,7 +9105,7 @@ function daysBetween(from, to) {
9089
9105
  }
9090
9106
  function slip(from, to) {
9091
9107
  if (!from || !to) return "";
9092
- const d = daysBetween(from, to);
9108
+ const d = daysBetween$1(from, to);
9093
9109
  if (d === 0) return "";
9094
9110
  const n = Math.abs(d);
9095
9111
  return ` (${n} day${n === 1 ? "" : "s"} ${d > 0 ? "later" : "earlier"})`;
@@ -9157,15 +9173,15 @@ function describeEvent(e, names) {
9157
9173
  };
9158
9174
  case "dueDate": return {
9159
9175
  action: "moved the due date of",
9160
- detail: `from ${day$1(from)} to ${day$1(to)}${slip(from, to)}`
9176
+ detail: `from ${day$2(from)} to ${day$2(to)}${slip(from, to)}`
9161
9177
  };
9162
9178
  case "startDate": return {
9163
9179
  action: "moved the start of",
9164
- detail: `from ${day$1(from)} to ${day$1(to)}${slip(from, to)}`
9180
+ detail: `from ${day$2(from)} to ${day$2(to)}${slip(from, to)}`
9165
9181
  };
9166
9182
  case "endDate": return {
9167
9183
  action: "moved the end of",
9168
- detail: `from ${day$1(from)} to ${day$1(to)}${slip(from, to)}`
9184
+ detail: `from ${day$2(from)} to ${day$2(to)}${slip(from, to)}`
9169
9185
  };
9170
9186
  case "budget": return {
9171
9187
  action: "changed the budget of",
@@ -18956,7 +18972,7 @@ const SIMILAR_MIN = 3;
18956
18972
  /** A finished task that took longer than this sat somewhere; it counts as this long. */
18957
18973
  const SIMILAR_CAP = 15;
18958
18974
  const firstName$1 = (name) => name.trim().split(/\s+/)[0] || name;
18959
- const day = (iso) => iso ? iso.slice(0, 10) : null;
18975
+ const day$1 = (iso) => iso ? iso.slice(0, 10) : null;
18960
18976
  function quantile(sorted, q) {
18961
18977
  const at = (sorted.length - 1) * q;
18962
18978
  const lo = Math.floor(at);
@@ -18972,7 +18988,7 @@ function pilotDate(task, data, localToday, at) {
18972
18988
  const owner = byId(data.people).get(task.assigneeId);
18973
18989
  if (!owner || owner.kind === "agent") return null;
18974
18990
  const today = at ? todayFor(owner, at, localToday) : localToday;
18975
- const chosen = day(task.dueDate);
18991
+ const chosen = day$1(task.dueDate);
18976
18992
  let underWay = 0;
18977
18993
  const dated = [];
18978
18994
  const finished = [];
@@ -18982,14 +18998,14 @@ function pilotDate(task, data, localToday, at) {
18982
18998
  if (t.projectId === task.projectId && t.completedAt) finished.push(t);
18983
18999
  continue;
18984
19000
  }
18985
- const due = day(t.dueDate);
19001
+ const due = day$1(t.dueDate);
18986
19002
  if (t.status === "doing" || t.status === "review") underWay += 1;
18987
19003
  else if (due) dated.push(due);
18988
19004
  }
18989
19005
  const perDay = Math.max(.2, (owner.weeklyCapacity ?? 5) / 5);
18990
19006
  let similar = null;
18991
19007
  if (finished.length >= SIMILAR_MIN) {
18992
- const took = finished.sort((a, b) => b.completedAt < a.completedAt ? -1 : 1).slice(0, SIMILAR).map((t) => Math.min(SIMILAR_CAP, Math.max(1, workingDaysBetween(day(t.createdAt), day(t.completedAt))))).sort((a, b) => a - b);
19008
+ const took = finished.sort((a, b) => b.completedAt < a.completedAt ? -1 : 1).slice(0, SIMILAR).map((t) => Math.min(SIMILAR_CAP, Math.max(1, workingDaysBetween(day$1(t.createdAt), day$1(t.completedAt))))).sort((a, b) => a - b);
18993
19009
  similar = {
18994
19010
  low: Math.round(quantile(took, .25)),
18995
19011
  high: Math.round(quantile(took, .75)),
@@ -18997,7 +19013,7 @@ function pilotDate(task, data, localToday, at) {
18997
19013
  };
18998
19014
  }
18999
19015
  const ownDays = Math.min(5, Math.max(1, similar?.low ?? 1));
19000
- const backOn = isAway(owner, today) ? day(owner.away.until) : null;
19016
+ const backOn = isAway(owner, today) ? day$1(owner.away.until) : null;
19001
19017
  const start = backOn && backOn > today ? dayBefore(backOn) : today;
19002
19018
  const tasks = byId(data.tasks);
19003
19019
  const projects = byId(data.projects);
@@ -19008,7 +19024,7 @@ function pilotDate(task, data, localToday, at) {
19008
19024
  for (const id of blockers) {
19009
19025
  const b = tasks.get(id);
19010
19026
  if (!b || b.status === "done") continue;
19011
- const due = day(b.dueDate);
19027
+ const due = day$1(b.dueDate);
19012
19028
  if (!due) {
19013
19029
  undated ??= b;
19014
19030
  continue;
@@ -19077,7 +19093,7 @@ const plural$3 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
19077
19093
  /** The line under the date: "Dana's queue says Nov 12 at the earliest", or, with no date yet, "Dana could likely have this by Nov 12". */
19078
19094
  function pilotHeadline(p, chosen, you = false) {
19079
19095
  const who = you ? "You" : firstName$1(p.owner.name);
19080
- if (!day(chosen)) return `${who} could likely have this by ${shortDate(p.earliest)}`;
19096
+ if (!day$1(chosen)) return `${who} could likely have this by ${shortDate(p.earliest)}`;
19081
19097
  return `${you ? "Your" : `${who}'s`} queue says ${shortDate(p.earliest)} at the earliest`;
19082
19098
  }
19083
19099
  /** Why: the queue, time away, what it waits on, and how long similar tasks took. */
@@ -19410,6 +19426,7 @@ function barnaclesData(b) {
19410
19426
  daysOfWork: b.days,
19411
19427
  slipDays: b.slip,
19412
19428
  daysLine: barnaclesDaysLine(b),
19429
+ caveat: b.caveat,
19413
19430
  byWeek: b.weeks.filter((w) => w.added > 0).map((w) => ({
19414
19431
  weekOf: w.week,
19415
19432
  added: w.added
@@ -22098,6 +22115,14 @@ function hierarchySlice({ get, write }) {
22098
22115
  const program = get().programs.find((p) => p.id === programId);
22099
22116
  get().log(`${reset ? "reset" : "set"} the baseline on ${program?.name ?? "a program"}`, `/programs/${programId}`);
22100
22117
  },
22118
+ setProjectBaseline: (projectId, reset = false) => {
22119
+ const project = get().projects.find((p) => p.id === projectId);
22120
+ if (!project) return;
22121
+ const [stamped] = withBaseline([project], reset, stamp());
22122
+ if (stamped === project) return;
22123
+ write({ projects: get().projects.map((p) => p.id === projectId ? stamped : p) });
22124
+ get().log(`${reset ? "reset" : "set"} the baseline on ${project.name}`, `/projects/${projectId}`);
22125
+ },
22101
22126
  setPriorityRules: (rules) => {
22102
22127
  write({ priorityRules: rules ? normalizeRules(rules) : null });
22103
22128
  },
@@ -26597,6 +26622,292 @@ function ShorePanel({ id, width, height, go, active, onClose }) {
26597
26622
  });
26598
26623
  }
26599
26624
 
26625
+ //#endregion
26626
+ //#region src/lib/seen-mark.ts
26627
+ /** Days without opening Harbor before the catch-up shows. */
26628
+ const UNSEEN_DAYS = 3;
26629
+ /** Calendar days from one ISO day (or moment) to another, by their dates alone. */
26630
+ function dayGap(from, to) {
26631
+ const utc = (iso) => {
26632
+ const [y, m, d] = iso.slice(0, 10).split("-").map(Number);
26633
+ return Date.UTC(y, m - 1, d);
26634
+ };
26635
+ return Math.round((utc(to) - utc(from)) / 864e5);
26636
+ }
26637
+ /**
26638
+ * Whether to catch someone up, and from when: back from time away they were
26639
+ * marked for (a handover with a return date that has come, until this device
26640
+ * has caught them up once since), or `UNSEEN_DAYS` or more since this device
26641
+ * last saw them. Null otherwise, and the first time a device sees them:
26642
+ * there's nothing to compare with.
26643
+ */
26644
+ function welcomeWhen(person, lastSeen, today) {
26645
+ const away = person?.away;
26646
+ if (away?.since && away.until && away.until.slice(0, 10) <= today) {
26647
+ if (!lastSeen || lastSeen.slice(0, 10) < away.until.slice(0, 10)) return {
26648
+ since: away.since,
26649
+ why: "away",
26650
+ days: dayGap(away.since, today)
26651
+ };
26652
+ }
26653
+ if (!lastSeen) return null;
26654
+ const days = dayGap(lastSeen, today);
26655
+ return days >= 3 ? {
26656
+ since: lastSeen,
26657
+ why: "unseen",
26658
+ days
26659
+ } : null;
26660
+ }
26661
+
26662
+ //#endregion
26663
+ //#region src/lib/welcome-back.ts
26664
+ init_date_text();
26665
+ init_indexes();
26666
+ init_task_keys();
26667
+ const day = (iso) => parseIso(iso.slice(0, 10));
26668
+ const daysBetween = dayGap;
26669
+ /** Their projects: ones they lead, in a program they lead, or with a task of theirs. */
26670
+ function projectsOf(data, personId) {
26671
+ const programs = new Set(data.programs.filter((p) => p.ownerId === personId).map((p) => p.id));
26672
+ return data.projects.filter((p) => p.ownerId === personId || programs.has(p.programId) || tasksOf(data.tasks, p.id).some((t) => t.assigneeId === personId));
26673
+ }
26674
+ const first = (p) => p?.name.split(" ")[0] ?? null;
26675
+ /** "due Thu, Oct 23", "3 days late", "due today". */
26676
+ function dueText(task, today) {
26677
+ if (!task.dueDate) return "no due date";
26678
+ const d = daysBetween(today, task.dueDate);
26679
+ if (d === 0) return "due today";
26680
+ if (d < 0) return `${-d} day${d === -1 ? "" : "s"} late`;
26681
+ if (d === 1) return "due tomorrow";
26682
+ return `due ${format(day(task.dueDate), "EEE, MMM d")}`;
26683
+ }
26684
+ /** What a person needs to hear about since `when.since`, as of `today` (YYYY-MM-DD). */
26685
+ function welcomeBack(data, personId, when, today) {
26686
+ const since = when.since;
26687
+ const people = byId(data.people);
26688
+ const projects = projectsOf(data, personId);
26689
+ const projectIds = new Set(projects.map((p) => p.id));
26690
+ const programIds = new Set(projects.map((p) => p.programId));
26691
+ const projectById = byId(data.projects);
26692
+ const label = (t) => {
26693
+ const p = projectById.get(t.projectId);
26694
+ const key = p ? taskKey(t, p) : null;
26695
+ return key ? `${key} ${t.title}` : t.title;
26696
+ };
26697
+ const mine = data.tasks.filter((t) => t.assigneeId === personId && t.status !== "done");
26698
+ const handed = mine.filter((t) => t.assignedAt && t.assignedAt >= since && t.assignedBy && t.assignedBy !== personId).sort((a, b) => (a.dueDate ?? "9999").localeCompare(b.dueDate ?? "9999")).map((task) => ({
26699
+ task,
26700
+ by: people.get(task.assignedBy) ?? null
26701
+ }));
26702
+ const decisions = data.raidItems.filter((r) => r.kind === "decision" && r.status !== "proposed" && Boolean(r.decidedAt) && r.decidedAt >= since && r.decidedById !== personId && (r.projectId ? projectIds.has(r.projectId) : Boolean(r.programId && programIds.has(r.programId)))).sort((a, b) => b.decidedAt.localeCompare(a.decidedAt)).map((item) => ({
26703
+ item,
26704
+ by: item.decidedById ? people.get(item.decidedById) ?? null : null
26705
+ }));
26706
+ const moved = [];
26707
+ for (const project of projects) {
26708
+ const tasks = tasksOf(data.tasks, project.id);
26709
+ const done = tasks.filter((t) => t.completedAt && t.completedAt >= since).length;
26710
+ const added = tasks.filter((t) => t.createdAt >= since).length;
26711
+ const history = project.healthHistory ?? [];
26712
+ const before = [...history].reverse().find((h) => h.at < since)?.health;
26713
+ const health = history.some((h) => h.at >= since) && before && before !== project.health ? {
26714
+ from: before,
26715
+ to: project.health
26716
+ } : null;
26717
+ if (done || added || health) moved.push({
26718
+ project,
26719
+ done,
26720
+ added,
26721
+ health
26722
+ });
26723
+ }
26724
+ moved.sort((a, b) => Number(Boolean(b.health)) - Number(Boolean(a.health)) || b.done - a.done);
26725
+ const look = [];
26726
+ const seen = /* @__PURE__ */ new Set();
26727
+ const push = (item) => {
26728
+ const id = item.taskId ?? item.raidId ?? item.text;
26729
+ if (look.length >= 3 || seen.has(id)) return;
26730
+ seen.add(id);
26731
+ look.push(item);
26732
+ };
26733
+ for (const h of handed) {
26734
+ const by = first(h.by);
26735
+ push({
26736
+ kind: "handed",
26737
+ taskId: h.task.id,
26738
+ text: `${label(h.task)}: handed to you${by ? ` by ${by}` : ""}, ${dueText(h.task, today)}`
26739
+ });
26740
+ }
26741
+ const dated = mine.filter((t) => t.dueDate).sort((a, b) => a.dueDate.localeCompare(b.dueDate));
26742
+ for (const t of dated) if (t.dueDate.slice(0, 10) < today) push({
26743
+ kind: "late",
26744
+ taskId: t.id,
26745
+ text: `${label(t)}: ${dueText(t, today)}`
26746
+ });
26747
+ for (const t of dated) {
26748
+ const d = daysBetween(today, t.dueDate);
26749
+ if (d >= 0 && d <= 3) push({
26750
+ kind: "due",
26751
+ taskId: t.id,
26752
+ text: `${label(t)}: ${dueText(t, today)}`
26753
+ });
26754
+ }
26755
+ for (const d of decisions) {
26756
+ const by = first(d.by);
26757
+ push({
26758
+ kind: "decision",
26759
+ raidId: d.item.id,
26760
+ text: `Decided without you: ${d.item.title}${by ? ` (${by})` : ""}`
26761
+ });
26762
+ }
26763
+ return {
26764
+ when,
26765
+ handed,
26766
+ decisions,
26767
+ moved: moved.slice(0, 6),
26768
+ lookFirst: look,
26769
+ quiet: !handed.length && !decisions.length && !moved.length && !look.length
26770
+ };
26771
+ }
26772
+ /** "Welcome back, Priya · away Oct 3 to Oct 17", or "· 5 days since you last looked". */
26773
+ function welcomeTitle(w, name, today) {
26774
+ const hi = name ? `Welcome back, ${name}` : "Welcome back";
26775
+ if (w.when.why === "away") return `${hi} · away ${format(day(w.when.since), "MMM d")} to ${format(day(today), "MMM d")}`;
26776
+ return `${hi} · ${w.when.days} days since you last looked`;
26777
+ }
26778
+ /** "Checkout v3: 4 done, 2 added, now At risk (was On track)". */
26779
+ function movedText(m, healthLabel) {
26780
+ const bits = [
26781
+ m.done ? `${m.done} done` : null,
26782
+ m.added ? `${m.added} added` : null,
26783
+ m.health ? `now ${healthLabel(m.health.to)} (was ${healthLabel(m.health.from)})` : null
26784
+ ].filter(Boolean);
26785
+ return `${m.project.name}: ${bits.join(", ")}`;
26786
+ }
26787
+
26788
+ //#endregion
26789
+ //#region src/tui/views/welcome.tsx
26790
+ init_swatches();
26791
+ const TAG = {
26792
+ handed: "Handed to you",
26793
+ late: "Late",
26794
+ due: "Due soon",
26795
+ decision: "Decided"
26796
+ };
26797
+ /**
26798
+ * While you were away, the app's first screen after time off or days
26799
+ * unseen: the three to look at first (Enter opens one), the calls made
26800
+ * without you and what moved. g or Esc says got it and goes on to Today.
26801
+ */
26802
+ function WelcomeView({ when, active, width, height, go, onDone }) {
26803
+ const { today } = useDeps();
26804
+ const tasks = useHarbor((s) => s.tasks);
26805
+ const projects = useHarbor((s) => s.projects);
26806
+ const programs = useHarbor((s) => s.programs);
26807
+ const people = useHarbor((s) => s.people);
26808
+ const raidItems = useHarbor((s) => s.raidItems);
26809
+ const me = useHarbor((s) => s.currentUserId);
26810
+ const day = today();
26811
+ const w = useMemo(() => welcomeBack({
26812
+ tasks,
26813
+ projects,
26814
+ programs,
26815
+ people,
26816
+ raidItems
26817
+ }, me, when, day), [
26818
+ tasks,
26819
+ projects,
26820
+ programs,
26821
+ people,
26822
+ raidItems,
26823
+ me,
26824
+ when,
26825
+ day
26826
+ ]);
26827
+ const name = people.find((p) => p.id === me)?.name.split(" ")[0] ?? null;
26828
+ const [at, setAt] = useState(0);
26829
+ const look = w.lookFirst;
26830
+ useInput((input, key) => {
26831
+ if (key.escape || input === "g") return onDone();
26832
+ if (key.upArrow || input === "k") setAt((i) => step(i, -1, look.length));
26833
+ else if (key.downArrow || input === "j") setAt((i) => step(i, 1, look.length));
26834
+ else if (key.return) {
26835
+ const l = look[at];
26836
+ if (!l) return onDone();
26837
+ if (l.taskId) go.openTask(l.taskId);
26838
+ else if (l.raidId) go.openPanel("raid", l.raidId);
26839
+ }
26840
+ }, { isActive: active });
26841
+ const [hello, ...rest] = welcomeTitle(w, name, day).split(" · ");
26842
+ const label = (h) => HEALTH_LABEL$1[h];
26843
+ const room = Math.max(0, height - 6 - look.length);
26844
+ const lines = [
26845
+ ...w.decisions.length ? [{
26846
+ head: true,
26847
+ text: "Decided without you"
26848
+ }] : [],
26849
+ ...w.decisions.slice(0, 3).map(({ item, by }) => ({
26850
+ head: false,
26851
+ text: `${item.title}${by ? ` · ${by.name.split(" ")[0]}` : ""}`
26852
+ })),
26853
+ ...w.moved.length ? [{
26854
+ head: true,
26855
+ text: "What moved"
26856
+ }] : [],
26857
+ ...w.moved.map((m) => ({
26858
+ head: false,
26859
+ text: movedText(m, label)
26860
+ }))
26861
+ ].slice(0, room);
26862
+ return /* @__PURE__ */ jsxs(Box, {
26863
+ flexDirection: "column",
26864
+ width,
26865
+ height,
26866
+ paddingX: 1,
26867
+ children: [
26868
+ /* @__PURE__ */ jsx(Heading, {
26869
+ right: rest.join(" · "),
26870
+ children: hello
26871
+ }),
26872
+ w.quiet ? /* @__PURE__ */ jsx(Text, {
26873
+ color: theme.muted,
26874
+ children: "Nothing moved on your projects while you were gone."
26875
+ }) : null,
26876
+ look.map((l, i) => {
26877
+ const on = active && i === at;
26878
+ return /* @__PURE__ */ jsxs(Text, {
26879
+ color: on ? theme.selected : void 0,
26880
+ bold: on,
26881
+ wrap: "truncate-end",
26882
+ children: [
26883
+ on ? "› " : " ",
26884
+ /* @__PURE__ */ jsx(Text, {
26885
+ color: l.kind === "due" || l.kind === "decision" ? theme.muted : theme.warn,
26886
+ children: TAG[l.kind].padEnd(14)
26887
+ }),
26888
+ l.text
26889
+ ]
26890
+ }, l.taskId ?? l.raidId ?? l.text);
26891
+ }),
26892
+ lines.map((l, i) => l.head ? /* @__PURE__ */ jsx(Box, {
26893
+ marginTop: 1,
26894
+ children: /* @__PURE__ */ jsx(Text, {
26895
+ color: theme.muted,
26896
+ children: l.text
26897
+ })
26898
+ }, i) : /* @__PURE__ */ jsxs(Text, {
26899
+ wrap: "truncate-end",
26900
+ children: [" ", l.text]
26901
+ }, i)),
26902
+ /* @__PURE__ */ jsx(Box, { flexGrow: 1 }),
26903
+ /* @__PURE__ */ jsxs(Text, {
26904
+ color: theme.muted,
26905
+ children: [look.length ? "↑↓ pick · Enter opens · " : "", "g or Esc: got it, on to Today"]
26906
+ })
26907
+ ]
26908
+ });
26909
+ }
26910
+
26600
26911
  //#endregion
26601
26912
  //#region src/tui/views/bands.tsx
26602
26913
  /**
@@ -29469,7 +29780,7 @@ const SAVE_LABEL = {
29469
29780
  offline: ["● offline, will save", "bad"]
29470
29781
  };
29471
29782
  function App({ ui, startIn }) {
29472
- const { store, sync, account, harbors, today, remember } = useDeps();
29783
+ const { store, sync, account, harbors, today, remember, seen } = useDeps();
29473
29784
  const { exit } = useApp();
29474
29785
  const { columns, rows } = useWindowSize();
29475
29786
  const harborName = useHarbor((s) => s.harborName);
@@ -29490,6 +29801,21 @@ function App({ ui, startIn }) {
29490
29801
  const [overlay, setOverlay] = useState(null);
29491
29802
  const [focus, setFocus] = useState("main");
29492
29803
  const [quitting, setQuitting] = useState(false);
29804
+ const [welcome, setWelcome] = useState(() => {
29805
+ const s = store.getState();
29806
+ const me = s.people.find((p) => p.id === s.currentUserId);
29807
+ if (!seen || !me || !s.workspaceId || s.workspaceId === "local") return null;
29808
+ return welcomeWhen(me, seen.last(s.workspaceId), today());
29809
+ });
29810
+ useEffect(() => {
29811
+ const id = store.getState().workspaceId;
29812
+ if (!welcome && seen && id && id !== "local") seen.mark(id, (/* @__PURE__ */ new Date()).toISOString());
29813
+ }, []);
29814
+ const doneWelcome = () => {
29815
+ const id = store.getState().workspaceId;
29816
+ if (seen && id) seen.mark(id, (/* @__PURE__ */ new Date()).toISOString());
29817
+ setWelcome(null);
29818
+ };
29493
29819
  useEffect(() => {
29494
29820
  if (!notice) return;
29495
29821
  const t = setTimeout(() => ui.setState({ notice: null }), 5e3);
@@ -29939,6 +30265,11 @@ function App({ ui, startIn }) {
29939
30265
  portfolioId: page.id,
29940
30266
  onBack: back
29941
30267
  }, page.id);
30268
+ else if (screen === "today" && welcome) screenNode = /* @__PURE__ */ jsx(WelcomeView, {
30269
+ ...viewProps,
30270
+ when: welcome,
30271
+ onDone: doneWelcome
30272
+ });
29942
30273
  else if (screen === "today") screenNode = /* @__PURE__ */ jsx(TodayView, { ...viewProps });
29943
30274
  else if (screen === "inbox") screenNode = /* @__PURE__ */ jsx(InboxView, { ...viewProps });
29944
30275
  else if (screen === "projects") screenNode = /* @__PURE__ */ jsx(ProjectsView, { ...viewProps });
@@ -30807,7 +31138,8 @@ async function startApp(deps, options = {}) {
30807
31138
  harbors,
30808
31139
  today,
30809
31140
  remember: deps.remember,
30810
- browse: deps.browse
31141
+ browse: deps.browse,
31142
+ seen: deps.seen
30811
31143
  },
30812
31144
  children: /* @__PURE__ */ jsx(App, {
30813
31145
  ui,
@@ -30891,7 +31223,8 @@ async function runApp(opts) {
30891
31223
  firstRun,
30892
31224
  remember: opts.remember,
30893
31225
  browse: opts.browse,
30894
- startIn: opts.startIn
31226
+ startIn: opts.startIn,
31227
+ seen: opts.seen
30895
31228
  }, { alternateScreen: true });
30896
31229
  await instance.waitUntilExit();
30897
31230
  }
package/lib/args.mjs CHANGED
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * A small argument reader: positionals, `--flag`, `--key value` and
3
- * `--key=value`. Unknown flags are an error, so a typo doesn't quietly do
3
+ * `--key=value`. An option named in `repeat` may be given more than once and
4
+ * reads as a list. Unknown flags are an error, so a typo doesn't quietly do
4
5
  * something else.
5
6
  */
6
- export function parseArgs(argv, { flags = [], options = [] } = {}) {
7
+ export function parseArgs(argv, { flags = [], options = [], repeat = [] } = {}) {
7
8
  const positionals = [];
8
9
  const values = {};
9
10
  for (let i = 0; i < argv.length; i++) {
@@ -20,7 +21,7 @@ export function parseArgs(argv, { flags = [], options = [] } = {}) {
20
21
  if (flags.includes(key)) {
21
22
  if (inline !== undefined) throw new Error(`--${key} takes no value.`);
22
23
  values[key] = true;
23
- } else if (options.includes(key)) {
24
+ } else if (options.includes(key) || repeat.includes(key)) {
24
25
  const value = inline ?? argv[++i];
25
26
  if (
26
27
  value === undefined ||
@@ -29,7 +30,7 @@ export function parseArgs(argv, { flags = [], options = [] } = {}) {
29
30
  ) {
30
31
  throw new Error(`--${key} needs a value.`);
31
32
  }
32
- values[key] = value;
33
+ values[key] = repeat.includes(key) ? [...(values[key] ?? []), value] : value;
33
34
  } else {
34
35
  throw new Error(`Unknown option --${key}.`);
35
36
  }
package/lib/cli.mjs CHANGED
@@ -103,6 +103,9 @@ export const HELP = `harbor: Harbor from the terminal
103
103
  harbor barnacles PROJECT work added since the plan was set:
104
104
  by week, by who, and the days of
105
105
  slip it explains
106
+ harbor baseline PROJECT [--reset] sets a project's baseline: its
107
+ dates now, and growth counted
108
+ from now
106
109
  harbor hill [--program P] where each project is on its hill
107
110
  harbor hill PROJECT --at 0-100|top|done move it: up to 50 is figuring it
108
111
  out, past 50 is getting it done
@@ -228,9 +231,10 @@ export const HELP = `harbor: Harbor from the terminal
228
231
  harbor voyage PROJECT ["YOUR LINE"] a Done project's voyage log, and
229
232
  your line in it
230
233
  harbor charter PROJECT [--why T] [--in T] [--out T] [--done-when T]
231
- [--calls T]
232
- a project's five-line charter:
233
- read it, or write some lines
234
+ [--calls NAME]
235
+ a project's five-line charter
236
+ (why, in, out, done when, who
237
+ makes the calls): read or set
234
238
  harbor note PROJECT ["NOTE"] [--opens DATE|--milestone NAME|--off-track]
235
239
  a note for later: sealed until
236
240
  its day, milestone, or Off track
@@ -293,9 +297,10 @@ function banner(io, env, words) {
293
297
  const THEMES = ["auto", "light", "dark"];
294
298
 
295
299
  const COMMON = { flags: ["json", "help"], options: ["harbor"] };
296
- const spec = (flags = [], options = []) => ({
300
+ const spec = (flags = [], options = [], repeat = []) => ({
297
301
  flags: [...COMMON.flags, ...flags],
298
302
  options: [...COMMON.options, ...options],
303
+ repeat,
299
304
  });
300
305
 
301
306
  /**
@@ -778,14 +783,21 @@ const DETAIL = {
778
783
  charter: [
779
784
  " harbor charter PROJECT reads its charter, and any open task",
780
785
  " that names something it leaves out",
781
- ' harbor charter PROJECT --why "TEXT" writes: that line; give any of',
782
- " --why, --in, --out, --done-when and",
783
- " --calls at once",
786
+ " Set any line, or all at once:",
787
+ ' --why "TEXT" why the project exists',
788
+ ' --in "A, B" what it covers (the voyage log counts the',
789
+ " finished tasks naming these)",
790
+ ' --out "C, D" what it leaves out (tasks naming these show as',
791
+ " Out of scope?)",
792
+ ' --done-when "TEXT" what finished looks like',
793
+ ' --calls "NAME" who makes the calls when scope is in question',
784
794
  "",
785
- " none clears a line: --out none. Separate items in --in and --out",
786
- " with commas, so Harbor can spot a task that names one. The review lists",
787
- " running projects still without a charter a week after they started.",
788
- " PROJECT is a name, a key like RR, or an id.",
795
+ " Items in --in and --out are split on commas; give --in or --out again",
796
+ ' for an item that has a comma in it: --out "Coupons" --out "Gift cards,',
797
+ ' vouchers". none clears a line: --out none. The review lists running',
798
+ " projects still without a charter a week after they started. A charter",
799
+ " is for the harbor's members: share links and public roadmaps never show",
800
+ " it. PROJECT is a name, a key like RR, or an id.",
789
801
  ],
790
802
  sound: [
791
803
  " harbor sound TASK reads its sounding: who hasn't answered,",
@@ -944,6 +956,30 @@ const DETAIL = {
944
956
  " days before it (negative: that many over). PROJECT is a name, a key",
945
957
  " like RR, or an id.",
946
958
  ],
959
+ clock: [
960
+ " harbor clock reads everyone's local time, and",
961
+ " who's working now",
962
+ " harbor clock NAME reads one person's",
963
+ " harbor clock [NAME] --zone ZONE writes: sets a time zone (yours",
964
+ " without NAME)",
965
+ " harbor clock [NAME] --hours 9-17 writes: sets working hours",
966
+ "",
967
+ " ZONE is an IANA name like America/New_York or Europe/London, here for",
968
+ " this machine's zone, or none to clear the zone and hours. --hours is",
969
+ " one start and end in that zone, like 9-17, 8:30-16:30 or 9am-5:30pm,",
970
+ " Monday to Friday: one span a day that ends after it starts, so no split",
971
+ " day or overnight shift. Without --hours a day runs 9 to 17. Agents",
972
+ " don't keep hours.",
973
+ ],
974
+ baseline: [
975
+ " harbor baseline PROJECT writes: commits its start and end",
976
+ " dates as its baseline",
977
+ " harbor baseline PROJECT --reset writes: replaces one already set",
978
+ "",
979
+ " The timeline then shows slips against those dates, and harbor barnacles",
980
+ " counts tasks added from now on as growth. On the website a program's",
981
+ " Set baseline does the same for all its projects.",
982
+ ],
947
983
  barnacles: [
948
984
  " harbor barnacles PROJECT reads how much the plan has grown",
949
985
  "",
@@ -1303,6 +1339,7 @@ export async function run(argv, io = {}) {
1303
1339
  const now = await readCredentials(env);
1304
1340
  return now ? { ...c, harbor: now.harbor ?? null, harborName: now.harborName ?? null } : c;
1305
1341
  };
1342
+ const settings = await readSettings(env);
1306
1343
  await runApp({
1307
1344
  source: tokenSource({
1308
1345
  creds,
@@ -1316,9 +1353,20 @@ export async function run(argv, io = {}) {
1316
1353
  const now = (await readCredentials(env)) ?? creds;
1317
1354
  await writeCredentials({ ...now, harbor: h.id, harborName: h.name }, env);
1318
1355
  },
1319
- theme: (await readSettings(env)).theme,
1356
+ theme: settings.theme,
1320
1357
  browse: io.browse ?? openBrowser,
1321
1358
  ...(command === "bridge" ? { startIn: "bridge" } : {}),
1359
+ // When this machine last saw you in each harbor: the app opens on a
1360
+ // catch-up after a few days away (src/lib/welcome-back.ts).
1361
+ seen: {
1362
+ last: (id) => settings.seen?.[id] ?? null,
1363
+ mark: (id, at) => {
1364
+ settings.seen = { ...settings.seen, [id]: at };
1365
+ void readSettings(env)
1366
+ .then((now) => writeSettings({ ...now, seen: { ...now.seen, [id]: at } }, env))
1367
+ .catch(() => {});
1368
+ },
1369
+ },
1322
1370
  });
1323
1371
  return 0;
1324
1372
  }
@@ -2680,11 +2728,15 @@ export async function run(argv, io = {}) {
2680
2728
  }
2681
2729
  case "charter": {
2682
2730
  const { positionals, values, harbor } = parse(
2683
- spec([], ["why", "in", "out", "done-when", "calls"]),
2731
+ spec([], ["why", "done-when", "calls"], ["in", "out"]),
2684
2732
  );
2685
2733
  const project = one(positionals, 'project: harbor charter "NAME"');
2686
- // none clears a line, as --due none does.
2687
- const line = (v) => (v.trim().toLowerCase() === "none" ? "" : v);
2734
+ // none clears a line, as --due none does. --in and --out given more than
2735
+ // once write one item each, as a bulleted list, so an item keeps its commas.
2736
+ const line = (v) => {
2737
+ if (Array.isArray(v)) v = v.length > 1 ? v.map((x) => `- ${x.trim()}`).join("\n") : v[0];
2738
+ return v.trim().toLowerCase() === "none" ? "" : v;
2739
+ };
2688
2740
  const lines = Object.fromEntries(
2689
2741
  [
2690
2742
  ["why", values.why],
@@ -2827,6 +2879,19 @@ export async function run(argv, io = {}) {
2827
2879
  if (!project) throw new HarborError("Say which project: harbor barnacles PROJECT.");
2828
2880
  return call("scope_growth", { harbor, project }, barnaclesText);
2829
2881
  }
2882
+ case "baseline": {
2883
+ const { positionals, values, harbor } = parse(spec(["reset"]));
2884
+ const project = positionals.join(" ").trim();
2885
+ if (!project) throw new HarborError("Say which project: harbor baseline PROJECT.");
2886
+ return call(
2887
+ "set_baseline",
2888
+ { harbor, project, ...(values.reset ? { reset: true } : {}) },
2889
+ (r) =>
2890
+ r.changed
2891
+ ? `Set ${r.project}'s baseline: ${r.baselineStart} to ${r.baselineEnd}. harbor barnacles counts tasks added from now on.`
2892
+ : `${r.project} already has a baseline (${r.baselineStart} to ${r.baselineEnd}); --reset replaces it.`,
2893
+ );
2894
+ }
2830
2895
  case "voyage": {
2831
2896
  const { positionals, harbor } = parse(spec());
2832
2897
  const [project, ...words] = positionals;
package/lib/format.mjs CHANGED
@@ -635,6 +635,7 @@ export function barnaclesText(r) {
635
635
  }
636
636
  head.push(`${r.headline} (${r.planned} planned, ${r.addedThisWeek} added this week).`);
637
637
  if (r.daysLine) head.push(r.daysLine);
638
+ if (r.caveat) head.push(r.caveat);
638
639
  const top = Math.max(1, ...r.byWeek.map((w) => w.added));
639
640
  const weeks = r.byWeek.map(
640
641
  (w) => ` w/c ${w.weekOf} ${"█".repeat(Math.max(1, Math.round((w.added / top) * 20)))} ${w.added}`,
@@ -643,7 +644,7 @@ export function barnaclesText(r) {
643
644
  const from =
644
645
  r.measuredFrom === "when the baseline was set"
645
646
  ? `Counted from when the baseline was set, ${r.since.slice(0, 10)}.`
646
- : `Counted from a week after its first task was made, ${r.since.slice(0, 10)}; setting a baseline counts from then instead.`;
647
+ : `Counted from a week after its first task was made, ${r.since.slice(0, 10)}; harbor baseline "${r.project}" counts from then instead.`;
647
648
  return [...head, "", "Added by week:", ...weeks, "", `By who: ${who}`, "", from].join("\n");
648
649
  }
649
650
 
@@ -751,12 +752,16 @@ const CHARTER_LINES = [
751
752
  export function charterText(r) {
752
753
  const project = r.name ?? r.project;
753
754
  if (!r.charter) {
754
- return `${project} has no charter yet. Write one, a line at a time:\n harbor charter "${project}" --why "…" --in "…, …" --out "…, …" --done-when "…" --calls "…"`;
755
+ return `${project} has no charter yet. Set any line, or all at once:\n harbor charter "${project}" --why "…" --in "…, …" --out "…, …" --done-when "…" --calls "…"`;
755
756
  }
756
757
  const lines = [`Charter · ${project}`];
757
758
  const width = Math.max(...CHARTER_LINES.map(([, label]) => label.length));
758
759
  for (const [key, label] of CHARTER_LINES) {
759
- if (r.charter[key]) lines.push(` ${label.padEnd(width)} ${r.charter[key]}`);
760
+ // A bulleted In or Out (--in given more than once) keeps its lines lined up.
761
+ if (r.charter[key])
762
+ lines.push(
763
+ ` ${label.padEnd(width)} ${String(r.charter[key]).replace(/\n/g, `\n ${" ".repeat(width)} `)}`,
764
+ );
760
765
  }
761
766
  if (r.outOfScope?.length) {
762
767
  lines.push("", "Out of scope?");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.63.0",
3
+ "version": "0.64.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",