@harborppm/cli 0.63.0 → 0.65.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
@@ -117,7 +117,8 @@ Task titles, names and notes can come from an intake form, a GitHub pull request
117
117
 
118
118
  ```
119
119
  harbor harbors the harbors you belong to
120
- harbor whoami who you're signed in as, and where
120
+ harbor whoami who you're signed in as, where, and which
121
+ build of the site answers
121
122
  harbor use HARBOR|none the harbor to work in when you're in several
122
123
  harbor programs programs and their projects
123
124
  harbor projects [words] [--program P] projects with health and open work
@@ -131,10 +132,14 @@ harbor why PROJECT|TASK|MILESTONE what's holding it up: the line of ta
131
132
  waits that sets its date, slack per link
132
133
  harbor barnacles PROJECT work added since the plan was set: by
133
134
  week, by who, and the slip it explains
135
+ harbor baseline PROJECT [--set|--reset] reads a project's baseline; --set commits
136
+ its dates now (--dry-run says what)
134
137
  harbor hill [--program P] where each project is on its hill
135
138
  harbor hill PROJECT --at 0-100|top|done move it: up to 50 is figuring it out,
136
139
  past 50 is getting it done
137
140
  harbor mine what's on you, late first
141
+ harbor catchup [--since DATE] while you were away: handed to you,
142
+ decided without you, what moved
138
143
  harbor clock [NAME] [--zone ZONE|here|none] [--hours 9-17]
139
144
  who's working now; set your (or NAME's) time zone and hours
140
145
  harbor tasks [words] [--project P] [--assignee me|none|NAME] [--late] [--status S,S] [--all]
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. */
@@ -19094,6 +19110,169 @@ function pilotReasons(p, you = false) {
19094
19110
  return out;
19095
19111
  }
19096
19112
 
19113
+ //#endregion
19114
+ //#region src/lib/seen-mark.ts
19115
+ /** Days without opening Harbor before the catch-up shows. */
19116
+ const UNSEEN_DAYS = 3;
19117
+ /** Calendar days from one ISO day (or moment) to another, by their dates alone. */
19118
+ function dayGap(from, to) {
19119
+ const utc = (iso) => {
19120
+ const [y, m, d] = iso.slice(0, 10).split("-").map(Number);
19121
+ return Date.UTC(y, m - 1, d);
19122
+ };
19123
+ return Math.round((utc(to) - utc(from)) / 864e5);
19124
+ }
19125
+ /**
19126
+ * Whether to catch someone up, and from when: back from time away they were
19127
+ * marked for (a handover with a return date that has come, until this device
19128
+ * has caught them up once since), or `UNSEEN_DAYS` or more since this device
19129
+ * last saw them. Null otherwise, and the first time a device sees them:
19130
+ * there's nothing to compare with.
19131
+ */
19132
+ function welcomeWhen(person, lastSeen, today) {
19133
+ const away = person?.away;
19134
+ if (away?.since && away.until && away.until.slice(0, 10) <= today) {
19135
+ if (!lastSeen || lastSeen.slice(0, 10) < away.until.slice(0, 10)) return {
19136
+ since: away.since,
19137
+ why: "away",
19138
+ days: dayGap(away.since, today)
19139
+ };
19140
+ }
19141
+ if (!lastSeen) return null;
19142
+ const days = dayGap(lastSeen, today);
19143
+ return days >= 3 ? {
19144
+ since: lastSeen,
19145
+ why: "unseen",
19146
+ days
19147
+ } : null;
19148
+ }
19149
+
19150
+ //#endregion
19151
+ //#region src/lib/welcome-back.ts
19152
+ init_date_text();
19153
+ init_indexes();
19154
+ init_task_keys();
19155
+ const day = (iso) => parseIso(iso.slice(0, 10));
19156
+ const daysBetween = dayGap;
19157
+ /** Their projects: ones they lead, in a program they lead, or with a task of theirs. */
19158
+ function projectsOf(data, personId) {
19159
+ const programs = new Set(data.programs.filter((p) => p.ownerId === personId).map((p) => p.id));
19160
+ return data.projects.filter((p) => p.ownerId === personId || programs.has(p.programId) || tasksOf(data.tasks, p.id).some((t) => t.assigneeId === personId));
19161
+ }
19162
+ const first = (p) => p?.name.split(" ")[0] ?? null;
19163
+ /** "due Thu, Oct 23", "3 days late", "due today". */
19164
+ function dueText(task, today) {
19165
+ if (!task.dueDate) return "no due date";
19166
+ const d = daysBetween(today, task.dueDate);
19167
+ if (d === 0) return "due today";
19168
+ if (d < 0) return `${-d} day${d === -1 ? "" : "s"} late`;
19169
+ if (d === 1) return "due tomorrow";
19170
+ return `due ${format(day(task.dueDate), "EEE, MMM d")}`;
19171
+ }
19172
+ /** What a person needs to hear about since `when.since`, as of `today` (YYYY-MM-DD). */
19173
+ function welcomeBack(data, personId, when, today) {
19174
+ const since = when.since;
19175
+ const people = byId(data.people);
19176
+ const projects = projectsOf(data, personId);
19177
+ const projectIds = new Set(projects.map((p) => p.id));
19178
+ const programIds = new Set(projects.map((p) => p.programId));
19179
+ const projectById = byId(data.projects);
19180
+ const label = (t) => {
19181
+ const p = projectById.get(t.projectId);
19182
+ const key = p ? taskKey(t, p) : null;
19183
+ return key ? `${key} ${t.title}` : t.title;
19184
+ };
19185
+ const mine = data.tasks.filter((t) => t.assigneeId === personId && t.status !== "done");
19186
+ 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) => ({
19187
+ task,
19188
+ by: people.get(task.assignedBy) ?? null
19189
+ }));
19190
+ 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) => ({
19191
+ item,
19192
+ by: item.decidedById ? people.get(item.decidedById) ?? null : null
19193
+ }));
19194
+ const moved = [];
19195
+ for (const project of projects) {
19196
+ const tasks = tasksOf(data.tasks, project.id);
19197
+ const done = tasks.filter((t) => t.completedAt && t.completedAt >= since).length;
19198
+ const added = tasks.filter((t) => t.createdAt >= since).length;
19199
+ const history = project.healthHistory ?? [];
19200
+ const before = [...history].reverse().find((h) => h.at < since)?.health;
19201
+ const health = history.some((h) => h.at >= since) && before && before !== project.health ? {
19202
+ from: before,
19203
+ to: project.health
19204
+ } : null;
19205
+ if (done || added || health) moved.push({
19206
+ project,
19207
+ done,
19208
+ added,
19209
+ health
19210
+ });
19211
+ }
19212
+ moved.sort((a, b) => Number(Boolean(b.health)) - Number(Boolean(a.health)) || b.done - a.done);
19213
+ const look = [];
19214
+ const seen = /* @__PURE__ */ new Set();
19215
+ const push = (item) => {
19216
+ const id = item.taskId ?? item.raidId ?? item.text;
19217
+ if (look.length >= 3 || seen.has(id)) return;
19218
+ seen.add(id);
19219
+ look.push(item);
19220
+ };
19221
+ for (const h of handed) {
19222
+ const by = first(h.by);
19223
+ push({
19224
+ kind: "handed",
19225
+ taskId: h.task.id,
19226
+ text: `${label(h.task)}: handed to you${by ? ` by ${by}` : ""}, ${dueText(h.task, today)}`
19227
+ });
19228
+ }
19229
+ const dated = mine.filter((t) => t.dueDate).sort((a, b) => a.dueDate.localeCompare(b.dueDate));
19230
+ for (const t of dated) if (t.dueDate.slice(0, 10) < today) push({
19231
+ kind: "late",
19232
+ taskId: t.id,
19233
+ text: `${label(t)}: ${dueText(t, today)}`
19234
+ });
19235
+ for (const t of dated) {
19236
+ const d = daysBetween(today, t.dueDate);
19237
+ if (d >= 0 && d <= 3) push({
19238
+ kind: "due",
19239
+ taskId: t.id,
19240
+ text: `${label(t)}: ${dueText(t, today)}`
19241
+ });
19242
+ }
19243
+ for (const d of decisions) {
19244
+ const by = first(d.by);
19245
+ push({
19246
+ kind: "decision",
19247
+ raidId: d.item.id,
19248
+ text: `Decided without you: ${d.item.title}${by ? ` (${by})` : ""}`
19249
+ });
19250
+ }
19251
+ return {
19252
+ when,
19253
+ handed,
19254
+ decisions,
19255
+ moved: moved.slice(0, 6),
19256
+ lookFirst: look,
19257
+ quiet: !handed.length && !decisions.length && !moved.length && !look.length
19258
+ };
19259
+ }
19260
+ /** "Welcome back, Priya · away Oct 3 to Oct 17", or "· 5 days since you last looked". */
19261
+ function welcomeTitle(w, name, today) {
19262
+ const hi = name ? `Welcome back, ${name}` : "Welcome back";
19263
+ if (w.when.why === "away") return `${hi} · away ${format(day(w.when.since), "MMM d")} to ${format(day(today), "MMM d")}`;
19264
+ return `${hi} · ${w.when.days} days since you last looked`;
19265
+ }
19266
+ /** "Checkout v3: 4 done, 2 added, now At risk (was On track)". */
19267
+ function movedText(m, healthLabel) {
19268
+ const bits = [
19269
+ m.done ? `${m.done} done` : null,
19270
+ m.added ? `${m.added} added` : null,
19271
+ m.health ? `now ${healthLabel(m.health.to)} (was ${healthLabel(m.health.from)})` : null
19272
+ ].filter(Boolean);
19273
+ return `${m.project.name}: ${bits.join(", ")}`;
19274
+ }
19275
+
19097
19276
  //#endregion
19098
19277
  //#region src/lib/agents.ts
19099
19278
  const isAgent = (p) => p?.kind === "agent";
@@ -19367,6 +19546,7 @@ init_search();
19367
19546
  init_derived_health();
19368
19547
  init_format();
19369
19548
  init_date_text();
19549
+ init_date_fns();
19370
19550
  init_shipping_forecast();
19371
19551
  init_handover();
19372
19552
  init_work();
@@ -19410,6 +19590,7 @@ function barnaclesData(b) {
19410
19590
  daysOfWork: b.days,
19411
19591
  slipDays: b.slip,
19412
19592
  daysLine: barnaclesDaysLine(b),
19593
+ caveat: b.caveat,
19413
19594
  byWeek: b.weeks.filter((w) => w.added > 0).map((w) => ({
19414
19595
  weekOf: w.week,
19415
19596
  added: w.added
@@ -19982,7 +20163,9 @@ const HARBOR_TOOLS = [
19982
20163
  } : {},
19983
20164
  starts: dayOf(p.startDate),
19984
20165
  ends: dayOf(p.endDate),
20166
+ ...p.baselineStart ? { baselineStarts: dayOf(p.baselineStart) } : {},
19985
20167
  ...p.baselineEnd ? { baselineEnds: dayOf(p.baselineEnd) } : {},
20168
+ ...p.baselineAt ? { baselineSetAt: p.baselineAt } : {},
19986
20169
  budget: p.budget,
19987
20170
  spent: p.spent,
19988
20171
  tasks: byStatus,
@@ -20184,6 +20367,67 @@ const HARBOR_TOOLS = [
20184
20367
  return weekOut(ctx.data, mine.id, weekOf(input.week ?? todayISO(ctx.now)));
20185
20368
  }
20186
20369
  }),
20370
+ tool({
20371
+ name: "catch_up",
20372
+ title: "While you were away",
20373
+ description: "The catch-up the website and the terminal app open on after time away or a few days unseen (while you were away): tasks handed to the person since then and by whom, decisions made on their projects without them, what moved on their projects (done, added, health changes), and the three to look at first. It counts from the start of time away they were marked for once its return date has come, else from since (when they last looked; any ISO date or moment), else the last 7 days. Reading it doesn't clear the catch-up anywhere." + DATA_NOTE,
20374
+ input: object({
20375
+ harbor: harborArg,
20376
+ since: string().regex(/^\d{4}-\d{2}-\d{2}/, "a date as YYYY-MM-DD, or an ISO moment").max(40).optional().describe("When the person last looked, YYYY-MM-DD or an ISO moment.")
20377
+ }),
20378
+ run: (ctx, input) => {
20379
+ const { data } = ctx;
20380
+ const mine = me(ctx);
20381
+ if (!mine) return {
20382
+ you: null,
20383
+ note: "You aren't one of this harbor's people yet, so there's nothing to catch up on."
20384
+ };
20385
+ const today = todayFor(mine, ctx.now, todayISO(ctx.now));
20386
+ const since = input.since ?? subDays(ctx.now, 7).toISOString();
20387
+ const away = welcomeWhen(mine, input.since ?? null, today);
20388
+ const when = away?.why === "away" ? away : {
20389
+ since,
20390
+ why: "unseen",
20391
+ days: dayGap(since, today)
20392
+ };
20393
+ const w = welcomeBack(data, mine.id, when, today);
20394
+ const projects = byId(data.projects);
20395
+ const keyOf = (t) => taskKey(t, projects.get(t.projectId));
20396
+ return {
20397
+ you: mine.name,
20398
+ from: when.why === "away" ? "time away" : input.since ? "when you last looked" : "the last 7 days",
20399
+ since: dayOf(when.since),
20400
+ days: when.days,
20401
+ quiet: w.quiet,
20402
+ lookFirst: w.lookFirst.map((l) => ({
20403
+ kind: l.kind,
20404
+ text: l.text,
20405
+ ...l.taskId ? { taskId: l.taskId } : {},
20406
+ ...l.raidId ? { raidId: l.raidId } : {}
20407
+ })),
20408
+ handed: w.handed.map(({ task, by }) => ({
20409
+ id: task.id,
20410
+ ...keyOf(task) ? { key: keyOf(task) } : {},
20411
+ title: task.title,
20412
+ due: dayOf(task.dueDate),
20413
+ by: by?.name ?? null
20414
+ })),
20415
+ decisions: w.decisions.map(({ item, by }) => ({
20416
+ id: item.id,
20417
+ title: item.title,
20418
+ decidedAt: dayOf(item.decidedAt),
20419
+ by: by?.name ?? null
20420
+ })),
20421
+ moved: w.moved.map((m) => ({
20422
+ project: m.project.name,
20423
+ ...m.project.key ? { key: m.project.key } : {},
20424
+ done: m.done,
20425
+ added: m.added,
20426
+ health: m.health
20427
+ }))
20428
+ };
20429
+ }
20430
+ }),
20187
20431
  tool({
20188
20432
  name: "weekly_brief",
20189
20433
  title: "Weekly brief",
@@ -22098,6 +22342,14 @@ function hierarchySlice({ get, write }) {
22098
22342
  const program = get().programs.find((p) => p.id === programId);
22099
22343
  get().log(`${reset ? "reset" : "set"} the baseline on ${program?.name ?? "a program"}`, `/programs/${programId}`);
22100
22344
  },
22345
+ setProjectBaseline: (projectId, reset = false) => {
22346
+ const project = get().projects.find((p) => p.id === projectId);
22347
+ if (!project) return;
22348
+ const [stamped] = withBaseline([project], reset, stamp());
22349
+ if (stamped === project) return;
22350
+ write({ projects: get().projects.map((p) => p.id === projectId ? stamped : p) });
22351
+ get().log(`${reset ? "reset" : "set"} the baseline on ${project.name}`, `/projects/${projectId}`);
22352
+ },
22101
22353
  setPriorityRules: (rules) => {
22102
22354
  write({ priorityRules: rules ? normalizeRules(rules) : null });
22103
22355
  },
@@ -26597,6 +26849,129 @@ function ShorePanel({ id, width, height, go, active, onClose }) {
26597
26849
  });
26598
26850
  }
26599
26851
 
26852
+ //#endregion
26853
+ //#region src/tui/views/welcome.tsx
26854
+ init_swatches();
26855
+ const TAG = {
26856
+ handed: "Handed to you",
26857
+ late: "Late",
26858
+ due: "Due soon",
26859
+ decision: "Decided"
26860
+ };
26861
+ /**
26862
+ * While you were away, the app's first screen after time off or days
26863
+ * unseen: the three to look at first (Enter opens one), the calls made
26864
+ * without you and what moved. g or Esc says got it and goes on to Today.
26865
+ */
26866
+ function WelcomeView({ when, active, width, height, go, onDone }) {
26867
+ const { today } = useDeps();
26868
+ const tasks = useHarbor((s) => s.tasks);
26869
+ const projects = useHarbor((s) => s.projects);
26870
+ const programs = useHarbor((s) => s.programs);
26871
+ const people = useHarbor((s) => s.people);
26872
+ const raidItems = useHarbor((s) => s.raidItems);
26873
+ const me = useHarbor((s) => s.currentUserId);
26874
+ const day = today();
26875
+ const w = useMemo(() => welcomeBack({
26876
+ tasks,
26877
+ projects,
26878
+ programs,
26879
+ people,
26880
+ raidItems
26881
+ }, me, when, day), [
26882
+ tasks,
26883
+ projects,
26884
+ programs,
26885
+ people,
26886
+ raidItems,
26887
+ me,
26888
+ when,
26889
+ day
26890
+ ]);
26891
+ const name = people.find((p) => p.id === me)?.name.split(" ")[0] ?? null;
26892
+ const [at, setAt] = useState(0);
26893
+ const look = w.lookFirst;
26894
+ useInput((input, key) => {
26895
+ if (key.escape || input === "g") return onDone();
26896
+ if (key.upArrow || input === "k") setAt((i) => step(i, -1, look.length));
26897
+ else if (key.downArrow || input === "j") setAt((i) => step(i, 1, look.length));
26898
+ else if (key.return) {
26899
+ const l = look[at];
26900
+ if (!l) return onDone();
26901
+ if (l.taskId) go.openTask(l.taskId);
26902
+ else if (l.raidId) go.openPanel("raid", l.raidId);
26903
+ }
26904
+ }, { isActive: active });
26905
+ const [hello, ...rest] = welcomeTitle(w, name, day).split(" · ");
26906
+ const label = (h) => HEALTH_LABEL$1[h];
26907
+ const room = Math.max(0, height - 6 - look.length);
26908
+ const lines = [
26909
+ ...w.decisions.length ? [{
26910
+ head: true,
26911
+ text: "Decided without you"
26912
+ }] : [],
26913
+ ...w.decisions.slice(0, 3).map(({ item, by }) => ({
26914
+ head: false,
26915
+ text: `${item.title}${by ? ` · ${by.name.split(" ")[0]}` : ""}`
26916
+ })),
26917
+ ...w.moved.length ? [{
26918
+ head: true,
26919
+ text: "What moved"
26920
+ }] : [],
26921
+ ...w.moved.map((m) => ({
26922
+ head: false,
26923
+ text: movedText(m, label)
26924
+ }))
26925
+ ].slice(0, room);
26926
+ return /* @__PURE__ */ jsxs(Box, {
26927
+ flexDirection: "column",
26928
+ width,
26929
+ height,
26930
+ paddingX: 1,
26931
+ children: [
26932
+ /* @__PURE__ */ jsx(Heading, {
26933
+ right: rest.join(" · "),
26934
+ children: hello
26935
+ }),
26936
+ w.quiet ? /* @__PURE__ */ jsx(Text, {
26937
+ color: theme.muted,
26938
+ children: "Nothing moved on your projects while you were gone."
26939
+ }) : null,
26940
+ look.map((l, i) => {
26941
+ const on = active && i === at;
26942
+ return /* @__PURE__ */ jsxs(Text, {
26943
+ color: on ? theme.selected : void 0,
26944
+ bold: on,
26945
+ wrap: "truncate-end",
26946
+ children: [
26947
+ on ? "› " : " ",
26948
+ /* @__PURE__ */ jsx(Text, {
26949
+ color: l.kind === "due" || l.kind === "decision" ? theme.muted : theme.warn,
26950
+ children: TAG[l.kind].padEnd(14)
26951
+ }),
26952
+ l.text
26953
+ ]
26954
+ }, l.taskId ?? l.raidId ?? l.text);
26955
+ }),
26956
+ lines.map((l, i) => l.head ? /* @__PURE__ */ jsx(Box, {
26957
+ marginTop: 1,
26958
+ children: /* @__PURE__ */ jsx(Text, {
26959
+ color: theme.muted,
26960
+ children: l.text
26961
+ })
26962
+ }, i) : /* @__PURE__ */ jsxs(Text, {
26963
+ wrap: "truncate-end",
26964
+ children: [" ", l.text]
26965
+ }, i)),
26966
+ /* @__PURE__ */ jsx(Box, { flexGrow: 1 }),
26967
+ /* @__PURE__ */ jsxs(Text, {
26968
+ color: theme.muted,
26969
+ children: [look.length ? "↑↓ pick · Enter opens · " : "", "g or Esc: got it, on to Today"]
26970
+ })
26971
+ ]
26972
+ });
26973
+ }
26974
+
26600
26975
  //#endregion
26601
26976
  //#region src/tui/views/bands.tsx
26602
26977
  /**
@@ -29469,7 +29844,7 @@ const SAVE_LABEL = {
29469
29844
  offline: ["● offline, will save", "bad"]
29470
29845
  };
29471
29846
  function App({ ui, startIn }) {
29472
- const { store, sync, account, harbors, today, remember } = useDeps();
29847
+ const { store, sync, account, harbors, today, remember, seen } = useDeps();
29473
29848
  const { exit } = useApp();
29474
29849
  const { columns, rows } = useWindowSize();
29475
29850
  const harborName = useHarbor((s) => s.harborName);
@@ -29490,6 +29865,21 @@ function App({ ui, startIn }) {
29490
29865
  const [overlay, setOverlay] = useState(null);
29491
29866
  const [focus, setFocus] = useState("main");
29492
29867
  const [quitting, setQuitting] = useState(false);
29868
+ const [welcome, setWelcome] = useState(() => {
29869
+ const s = store.getState();
29870
+ const me = s.people.find((p) => p.id === s.currentUserId);
29871
+ if (!seen || !me || !s.workspaceId || s.workspaceId === "local") return null;
29872
+ return welcomeWhen(me, seen.last(s.workspaceId), today());
29873
+ });
29874
+ useEffect(() => {
29875
+ const id = store.getState().workspaceId;
29876
+ if (!welcome && seen && id && id !== "local") seen.mark(id, (/* @__PURE__ */ new Date()).toISOString());
29877
+ }, []);
29878
+ const doneWelcome = () => {
29879
+ const id = store.getState().workspaceId;
29880
+ if (seen && id) seen.mark(id, (/* @__PURE__ */ new Date()).toISOString());
29881
+ setWelcome(null);
29882
+ };
29493
29883
  useEffect(() => {
29494
29884
  if (!notice) return;
29495
29885
  const t = setTimeout(() => ui.setState({ notice: null }), 5e3);
@@ -29939,6 +30329,11 @@ function App({ ui, startIn }) {
29939
30329
  portfolioId: page.id,
29940
30330
  onBack: back
29941
30331
  }, page.id);
30332
+ else if (screen === "today" && welcome) screenNode = /* @__PURE__ */ jsx(WelcomeView, {
30333
+ ...viewProps,
30334
+ when: welcome,
30335
+ onDone: doneWelcome
30336
+ });
29942
30337
  else if (screen === "today") screenNode = /* @__PURE__ */ jsx(TodayView, { ...viewProps });
29943
30338
  else if (screen === "inbox") screenNode = /* @__PURE__ */ jsx(InboxView, { ...viewProps });
29944
30339
  else if (screen === "projects") screenNode = /* @__PURE__ */ jsx(ProjectsView, { ...viewProps });
@@ -30807,7 +31202,8 @@ async function startApp(deps, options = {}) {
30807
31202
  harbors,
30808
31203
  today,
30809
31204
  remember: deps.remember,
30810
- browse: deps.browse
31205
+ browse: deps.browse,
31206
+ seen: deps.seen
30811
31207
  },
30812
31208
  children: /* @__PURE__ */ jsx(App, {
30813
31209
  ui,
@@ -30891,7 +31287,8 @@ async function runApp(opts) {
30891
31287
  firstRun,
30892
31288
  remember: opts.remember,
30893
31289
  browse: opts.browse,
30894
- startIn: opts.startIn
31290
+ startIn: opts.startIn,
31291
+ seen: opts.seen
30895
31292
  }, { alternateScreen: true });
30896
31293
  await instance.waitUntilExit();
30897
31294
  }
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
@@ -45,6 +45,9 @@ import {
45
45
  whyText,
46
46
  bridgeText,
47
47
  barnaclesText,
48
+ baselineData,
49
+ baselineText,
50
+ catchupText,
48
51
  handoverText,
49
52
  fogText,
50
53
  agentWorkText,
@@ -103,10 +106,15 @@ export const HELP = `harbor: Harbor from the terminal
103
106
  harbor barnacles PROJECT work added since the plan was set:
104
107
  by week, by who, and the days of
105
108
  slip it explains
109
+ harbor baseline PROJECT reads a project's baseline
110
+ harbor baseline PROJECT --set|--reset sets it: its dates now, and
111
+ [--dry-run] growth counted from now
106
112
  harbor hill [--program P] where each project is on its hill
107
113
  harbor hill PROJECT --at 0-100|top|done move it: up to 50 is figuring it
108
114
  out, past 50 is getting it done
109
115
  harbor mine what's on you, late first
116
+ harbor catchup [--since DATE] while you were away: handed to
117
+ you, decided, what moved
110
118
  harbor clock [NAME] [--zone ZONE|here|none] [--hours 9-17]
111
119
  who's working now, or set your
112
120
  (or NAME's) time zone and hours
@@ -228,9 +236,10 @@ export const HELP = `harbor: Harbor from the terminal
228
236
  harbor voyage PROJECT ["YOUR LINE"] a Done project's voyage log, and
229
237
  your line in it
230
238
  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
239
+ [--calls NAME]
240
+ a project's five-line charter
241
+ (why, in, out, done when, who
242
+ makes the calls): read or set
234
243
  harbor note PROJECT ["NOTE"] [--opens DATE|--milestone NAME|--off-track]
235
244
  a note for later: sealed until
236
245
  its day, milestone, or Off track
@@ -293,9 +302,10 @@ function banner(io, env, words) {
293
302
  const THEMES = ["auto", "light", "dark"];
294
303
 
295
304
  const COMMON = { flags: ["json", "help"], options: ["harbor"] };
296
- const spec = (flags = [], options = []) => ({
305
+ const spec = (flags = [], options = [], repeat = []) => ({
297
306
  flags: [...COMMON.flags, ...flags],
298
307
  options: [...COMMON.options, ...options],
308
+ repeat,
299
309
  });
300
310
 
301
311
  /**
@@ -778,14 +788,21 @@ const DETAIL = {
778
788
  charter: [
779
789
  " harbor charter PROJECT reads its charter, and any open task",
780
790
  " 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",
791
+ " Set any line, or all at once:",
792
+ ' --why "TEXT" why the project exists',
793
+ ' --in "A, B" what it covers (the voyage log counts the',
794
+ " finished tasks naming these)",
795
+ ' --out "C, D" what it leaves out (tasks naming these show as',
796
+ " Out of scope?)",
797
+ ' --done-when "TEXT" what finished looks like',
798
+ ' --calls "NAME" who makes the calls when scope is in question',
784
799
  "",
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.",
800
+ " Items in --in and --out are split on commas; give --in or --out again",
801
+ ' for an item that has a comma in it: --out "Coupons" --out "Gift cards,',
802
+ ' vouchers". none clears a line: --out none. The review lists running',
803
+ " projects still without a charter a week after they started. A charter",
804
+ " is for the harbor's members: share links and public roadmaps never show",
805
+ " it. PROJECT is a name, a key like RR, or an id.",
789
806
  ],
790
807
  sound: [
791
808
  " harbor sound TASK reads its sounding: who hasn't answered,",
@@ -944,6 +961,43 @@ const DETAIL = {
944
961
  " days before it (negative: that many over). PROJECT is a name, a key",
945
962
  " like RR, or an id.",
946
963
  ],
964
+ clock: [
965
+ " harbor clock reads everyone's local time, and",
966
+ " who's working now",
967
+ " harbor clock NAME reads one person's",
968
+ " harbor clock [NAME] --zone ZONE writes: sets a time zone (yours",
969
+ " without NAME)",
970
+ " harbor clock [NAME] --hours 9-17 writes: sets working hours",
971
+ "",
972
+ " ZONE is an IANA name like America/New_York or Europe/London, here for",
973
+ " this machine's zone, or none to clear the zone and hours. --hours is",
974
+ " one start and end in that zone, like 9-17, 8:30-16:30 or 9am-5:30pm,",
975
+ " Monday to Friday: one span a day that ends after it starts, so no split",
976
+ " day or overnight shift. Without --hours a day runs 9 to 17. Agents",
977
+ " don't keep hours.",
978
+ ],
979
+ baseline: [
980
+ " harbor baseline PROJECT reads its baseline and when it was set",
981
+ " harbor baseline PROJECT --set writes: commits its start and end",
982
+ " dates as its baseline",
983
+ " harbor baseline PROJECT --reset writes: replaces one already set",
984
+ " harbor baseline PROJECT --set --dry-run",
985
+ " reads: says what it would set",
986
+ "",
987
+ " The timeline then shows slips against those dates, and harbor barnacles",
988
+ " counts tasks added from now on as growth. On the website a program's",
989
+ " Set baseline does the same for all its projects.",
990
+ ],
991
+ catchup: [
992
+ " harbor catchup reads the catch-up the app opens on",
993
+ " harbor catchup --since DATE reads it from a day (YYYY-MM-DD)",
994
+ "",
995
+ " What was handed to you and by whom, the calls made on your projects",
996
+ " without you, what moved on them, and the three to look at first. It",
997
+ " counts from your time away once you're back, else from when the app on",
998
+ " this machine last saw you, else the last 7 days. Reading it here",
999
+ " doesn't clear the app's or the website's catch-up.",
1000
+ ],
947
1001
  barnacles: [
948
1002
  " harbor barnacles PROJECT reads how much the plan has grown",
949
1003
  "",
@@ -1303,6 +1357,7 @@ export async function run(argv, io = {}) {
1303
1357
  const now = await readCredentials(env);
1304
1358
  return now ? { ...c, harbor: now.harbor ?? null, harborName: now.harborName ?? null } : c;
1305
1359
  };
1360
+ const settings = await readSettings(env);
1306
1361
  await runApp({
1307
1362
  source: tokenSource({
1308
1363
  creds,
@@ -1316,9 +1371,20 @@ export async function run(argv, io = {}) {
1316
1371
  const now = (await readCredentials(env)) ?? creds;
1317
1372
  await writeCredentials({ ...now, harbor: h.id, harborName: h.name }, env);
1318
1373
  },
1319
- theme: (await readSettings(env)).theme,
1374
+ theme: settings.theme,
1320
1375
  browse: io.browse ?? openBrowser,
1321
1376
  ...(command === "bridge" ? { startIn: "bridge" } : {}),
1377
+ // When this machine last saw you in each harbor: the app opens on a
1378
+ // catch-up after a few days away (src/lib/welcome-back.ts).
1379
+ seen: {
1380
+ last: (id) => settings.seen?.[id] ?? null,
1381
+ mark: (id, at) => {
1382
+ settings.seen = { ...settings.seen, [id]: at };
1383
+ void readSettings(env)
1384
+ .then((now) => writeSettings({ ...now, seen: { ...now.seen, [id]: at } }, env))
1385
+ .catch(() => {});
1386
+ },
1387
+ },
1322
1388
  });
1323
1389
  return 0;
1324
1390
  }
@@ -2680,11 +2746,15 @@ export async function run(argv, io = {}) {
2680
2746
  }
2681
2747
  case "charter": {
2682
2748
  const { positionals, values, harbor } = parse(
2683
- spec([], ["why", "in", "out", "done-when", "calls"]),
2749
+ spec([], ["why", "done-when", "calls"], ["in", "out"]),
2684
2750
  );
2685
2751
  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);
2752
+ // none clears a line, as --due none does. --in and --out given more than
2753
+ // once write one item each, as a bulleted list, so an item keeps its commas.
2754
+ const line = (v) => {
2755
+ if (Array.isArray(v)) v = v.length > 1 ? v.map((x) => `- ${x.trim()}`).join("\n") : v[0];
2756
+ return v.trim().toLowerCase() === "none" ? "" : v;
2757
+ };
2688
2758
  const lines = Object.fromEntries(
2689
2759
  [
2690
2760
  ["why", values.why],
@@ -2821,12 +2891,41 @@ export async function run(argv, io = {}) {
2821
2891
  if (!of) throw new HarborError("Say what: a project, a task key like RR-42, or a milestone.");
2822
2892
  return call("long_pole", { harbor, of }, whyText);
2823
2893
  }
2894
+ case "catchup": {
2895
+ const { positionals, values, harbor } = parse(spec([], ["since"]));
2896
+ none(positionals);
2897
+ if (values.since && !/^\d{4}-\d{2}-\d{2}$/.test(values.since))
2898
+ throw new HarborError("--since takes a date like 2026-10-14.");
2899
+ // When the app on this machine last saw you in this harbor (it keeps the mark by id).
2900
+ const seen = values.since ?? (harbor ? (await readSettings(env)).seen?.[harbor] : null);
2901
+ return call("catch_up", { harbor, ...(seen ? { since: seen } : {}) }, catchupText);
2902
+ }
2824
2903
  case "barnacles": {
2825
2904
  const { positionals, harbor } = parse(spec());
2826
2905
  const project = positionals.join(" ").trim();
2827
2906
  if (!project) throw new HarborError("Say which project: harbor barnacles PROJECT.");
2828
2907
  return call("scope_growth", { harbor, project }, barnaclesText);
2829
2908
  }
2909
+ case "baseline": {
2910
+ const { positionals, values, harbor } = parse(spec(["set", "reset", "dry-run"]));
2911
+ const project = positionals.join(" ").trim();
2912
+ if (!project) throw new HarborError("Say which project: harbor baseline PROJECT.");
2913
+ const dryRun = Boolean(values["dry-run"]);
2914
+ // Reading is the default: only --set or --reset writes.
2915
+ if (dryRun || !(values.set || values.reset)) {
2916
+ return call("get_project", { harbor, project }, baselineText, (r) =>
2917
+ baselineData(r, { dryRun, reset: Boolean(values.reset) }),
2918
+ );
2919
+ }
2920
+ return call(
2921
+ "set_baseline",
2922
+ { harbor, project, ...(values.reset ? { reset: true } : {}) },
2923
+ (r) =>
2924
+ r.changed
2925
+ ? `Set ${r.project}'s baseline: ${r.baselineStart} to ${r.baselineEnd}. harbor barnacles counts tasks added from now on.`
2926
+ : `${r.project} already has a baseline (${r.baselineStart} to ${r.baselineEnd}); --reset replaces it.`,
2927
+ );
2928
+ }
2830
2929
  case "voyage": {
2831
2930
  const { positionals, harbor } = parse(spec());
2832
2931
  const [project, ...words] = positionals;
package/lib/format.mjs CHANGED
@@ -205,9 +205,21 @@ export function whoami(r, creds) {
205
205
  else if (list.length)
206
206
  lines.push("No harbor set: say --harbor NAME, or set one with harbor use NAME.");
207
207
  else lines.push("You aren't in any harbor yet. Run harbor to make one.");
208
+ // Shown from whoamiData (build), or straight from list_harbors (server).
209
+ lines.push(serverLine(r.build ?? (typeof r.server === "object" ? r.server : null)));
208
210
  return lines.join("\n");
209
211
  }
210
212
 
213
+ /**
214
+ * "Server: built Oct 22 03:14 UTC from 5e1c6d4." A site from before the stamp
215
+ * says nothing about itself, which means it's older than this CLI's whoami.
216
+ */
217
+ export function serverLine(server) {
218
+ if (!server?.builtAt) return "Server: an older build that doesn't say which; some commands may not be there yet.";
219
+ const at = `${monthDay(server.builtAt)} ${server.builtAt.slice(11, 16)} UTC`;
220
+ return `Server: built ${at}${server.commit ? ` from ${server.commit.slice(0, 7)}` : ""}.`;
221
+ }
222
+
211
223
  /** whoami --json: the account and its harbors, with where it signed in and the harbor commands use. */
212
224
  export function whoamiData(r, creds) {
213
225
  const list = r.harbors ?? [];
@@ -224,6 +236,8 @@ export function whoamiData(r, creds) {
224
236
  server: creds.server,
225
237
  readOnly: !creds.scope?.split(" ").includes("harbor:write"),
226
238
  harbor: using,
239
+ // The site's build (commit and when), or null from a site too old to say.
240
+ build: r.server ?? null,
227
241
  };
228
242
  }
229
243
 
@@ -635,6 +649,7 @@ export function barnaclesText(r) {
635
649
  }
636
650
  head.push(`${r.headline} (${r.planned} planned, ${r.addedThisWeek} added this week).`);
637
651
  if (r.daysLine) head.push(r.daysLine);
652
+ if (r.caveat) head.push(r.caveat);
638
653
  const top = Math.max(1, ...r.byWeek.map((w) => w.added));
639
654
  const weeks = r.byWeek.map(
640
655
  (w) => ` w/c ${w.weekOf} ${"█".repeat(Math.max(1, Math.round((w.added / top) * 20)))} ${w.added}`,
@@ -643,7 +658,7 @@ export function barnaclesText(r) {
643
658
  const from =
644
659
  r.measuredFrom === "when the baseline was set"
645
660
  ? `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.`;
661
+ : `Counted from a week after its first task was made, ${r.since.slice(0, 10)}; harbor baseline "${r.project}" --set counts from then instead.`;
647
662
  return [...head, "", "Added by week:", ...weeks, "", `By who: ${who}`, "", from].join("\n");
648
663
  }
649
664
 
@@ -751,12 +766,16 @@ const CHARTER_LINES = [
751
766
  export function charterText(r) {
752
767
  const project = r.name ?? r.project;
753
768
  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 "…"`;
769
+ return `${project} has no charter yet. Set any line, or all at once:\n harbor charter "${project}" --why "…" --in "…, …" --out "…, …" --done-when "…" --calls "…"`;
755
770
  }
756
771
  const lines = [`Charter · ${project}`];
757
772
  const width = Math.max(...CHARTER_LINES.map(([, label]) => label.length));
758
773
  for (const [key, label] of CHARTER_LINES) {
759
- if (r.charter[key]) lines.push(` ${label.padEnd(width)} ${r.charter[key]}`);
774
+ // A bulleted In or Out (--in given more than once) keeps its lines lined up.
775
+ if (r.charter[key])
776
+ lines.push(
777
+ ` ${label.padEnd(width)} ${String(r.charter[key]).replace(/\n/g, `\n ${" ".repeat(width)} `)}`,
778
+ );
760
779
  }
761
780
  if (r.outOfScope?.length) {
762
781
  lines.push("", "Out of scope?");
@@ -1105,3 +1124,84 @@ export function followedText(r) {
1105
1124
  const f = r.followed;
1106
1125
  return `${f.person} is a second pair of hands on ${f.what}: they follow it now, and it stays with whoever has it.`;
1107
1126
  }
1127
+
1128
+ /** A project's baseline from get_project, as harbor baseline reads it (or would set it, with --dry-run). */
1129
+ export function baselineData(p, { dryRun = false, reset = false } = {}) {
1130
+ const has = Boolean(p.baselineEnds);
1131
+ return {
1132
+ project: p.name,
1133
+ ...(p.key ? { key: p.key } : {}),
1134
+ baselineStart: p.baselineStarts ?? null,
1135
+ baselineEnd: p.baselineEnds ?? null,
1136
+ setAt: p.baselineSetAt ?? null,
1137
+ starts: p.starts ?? null,
1138
+ ends: p.ends ?? null,
1139
+ ...(dryRun
1140
+ ? {
1141
+ dryRun: true,
1142
+ wouldSet: !has || reset ? { baselineStart: p.starts ?? null, baselineEnd: p.ends ?? null } : null,
1143
+ }
1144
+ : {}),
1145
+ };
1146
+ }
1147
+
1148
+ const span = (from, to) => (from ? `${monthDay(from)} – ${monthDay(to)}` : `ending ${monthDay(to)}`);
1149
+
1150
+ export function baselineText(b) {
1151
+ const name = b.project;
1152
+ const now = b.starts && b.ends ? span(b.starts, b.ends) : null;
1153
+ const had = b.baselineEnd
1154
+ ? `${b.setAt ? `Baseline set ${monthDay(b.setAt)}` : "Baseline"}: ${span(b.baselineStart, b.baselineEnd)}.`
1155
+ : null;
1156
+ if (b.dryRun) {
1157
+ if (!b.wouldSet) {
1158
+ return `${name} already has a baseline (${span(b.baselineStart, b.baselineEnd)}); --reset would replace it${now ? ` with ${now}` : ""}. Nothing changed.`;
1159
+ }
1160
+ if (!now) return `${name} has no start and end dates yet, so there's nothing to set as its baseline.`;
1161
+ return `Would set ${name}'s baseline: ${now}${had ? `, replacing ${span(b.baselineStart, b.baselineEnd)}` : ""}. Nothing changed; leave out --dry-run to set it.`;
1162
+ }
1163
+ if (!had) {
1164
+ return now
1165
+ ? `${name}: no baseline yet. harbor baseline "${name}" --set commits its dates now (${now}).`
1166
+ : `${name}: no baseline yet, and no start and end dates to set one from.`;
1167
+ }
1168
+ const moved = now && (b.starts !== b.baselineStart || b.ends !== b.baselineEnd) && b.baselineStart;
1169
+ return [`${name}: ${had}`, ...(moved ? [`Its dates now: ${now}. --reset makes those the baseline.`] : [])].join("\n");
1170
+ }
1171
+
1172
+
1173
+ /** catch_up as the terminal shows it: the three to look at first, the calls made without you, what moved. */
1174
+ export function catchupText(r) {
1175
+ if (!r.you) return r.note ?? "Nothing to catch up on.";
1176
+ const first = r.you.split(" ")[0];
1177
+ const head =
1178
+ r.from === "time away"
1179
+ ? `While you were away, ${first} · since ${monthDay(r.since)}`
1180
+ : r.from === "when you last looked"
1181
+ ? `Since you last looked, ${first} · ${monthDay(r.since)}, ${r.days} day${r.days === 1 ? "" : "s"} ago`
1182
+ : `The last 7 days, ${first} · since ${monthDay(r.since)}`;
1183
+ if (r.quiet) return `${head}\n\nNothing moved on your projects since then.`;
1184
+ const out = [head];
1185
+ if (r.lookFirst.length) {
1186
+ out.push("", "Look at first:");
1187
+ for (const l of r.lookFirst) out.push(` ${l.text}`);
1188
+ }
1189
+ if (r.decisions.length) {
1190
+ out.push("", "Decided without you:");
1191
+ for (const d of r.decisions) out.push(` ${d.title}${d.by ? ` · ${d.by.split(" ")[0]}` : ""} (${monthDay(d.decidedAt)})`);
1192
+ }
1193
+ if (r.moved.length) {
1194
+ out.push("", "What moved:");
1195
+ for (const m of r.moved) {
1196
+ const bits = [
1197
+ m.done ? `${m.done} done` : null,
1198
+ m.added ? `${m.added} added` : null,
1199
+ m.health ? `now ${HEALTH[m.health.to] ?? m.health.to} (was ${HEALTH[m.health.from] ?? m.health.from})` : null,
1200
+ ].filter(Boolean);
1201
+ out.push(` ${m.project}: ${bits.join(", ")}`);
1202
+ }
1203
+ }
1204
+ const more = r.handed.length > r.lookFirst.filter((l) => l.kind === "handed").length;
1205
+ if (more) out.push("", `${r.handed.length} handed to you in all; harbor catchup --json lists them.`);
1206
+ return out.join("\n");
1207
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.63.0",
3
+ "version": "0.65.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",