@harborppm/cli 0.62.0 → 0.63.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
@@ -135,8 +135,11 @@ harbor hill [--program P] where each project is on its hill
135
135
  harbor hill PROJECT --at 0-100|top|done move it: up to 50 is figuring it out,
136
136
  past 50 is getting it done
137
137
  harbor mine what's on you, late first
138
+ harbor clock [NAME] [--zone ZONE|here|none] [--hours 9-17]
139
+ who's working now; set your (or NAME's) time zone and hours
138
140
  harbor tasks [words] [--project P] [--assignee me|none|NAME] [--late] [--status S,S] [--all]
139
- harbor show TASK one task in full, with its web link
141
+ harbor show TASK one task in full, with its web link and the
142
+ date its owner's queue suggests (pilotDue in --json)
140
143
  harbor open [TASK|PROJECT] open it on the website (or Today)
141
144
  harbor invite EMAIL [--viewer] invite someone to your harbor by email
142
145
  harbor calendar [--program P] [--new|--off] your private calendar link (.ics)
@@ -154,8 +157,11 @@ harbor project "NAME" --program P [--lead me|NAME] [--starts DATE]
154
157
  harbor add "TITLE" --project P [--assignee me|NAME] [--due DATE|pilot]
155
158
  [--priority low|medium|high|urgent] [--status S] [--comment TEXT]
156
159
  [--over-limit] no TITLE: one title a line from stdin
157
- --due pilot takes the date the owner's queue suggests;
158
- a date that won't hold says so
160
+ --due pilot takes the earliest day the owner's queue
161
+ suggests (their open work due first, weekly pace, time
162
+ away, what it waits on, how long their similar tasks
163
+ took); it needs an owner, and an empty queue gives
164
+ their next working day. A date that won't hold says so
159
165
  harbor start TASK | submit TASK | done TASK [--confirm] [--over-limit]
160
166
  harbor edit TASK [--title T] [--assignee me|none|NAME] [--due DATE|pilot|none]
161
167
  [--priority P] [--status S] [--notes TEXT]
@@ -223,7 +229,7 @@ harbor agent report TASK "WHAT YOU DID" [--start] [--agent NAME]
223
229
 
224
230
  `harbor fog` lists the open tasks nobody has opened, moved, edited or commented on for three weeks, longest first. `harbor fog --sweep` walks through them one at a time in a terminal: `k` still needed (clears the fog), `d` a new date, `x` drop it, `s` skip, `q` stop. On the board, in the app and on the website, those cards show dimmed.
225
231
 
226
- `harbor watch` turns a narrow split or a tmux pane into a live ticker of your harbor: cards moving, comments, health changes, a line each as they happen, over the same live updates the website uses. Lines about your own work (your tasks, mentions of you, what you lead) are bright and the rest dim; `--bell` rings the terminal's bell when something of yours changes, and `--project` keeps to one project. It starts with the latest few changes and the time it began listening; `--since 14:30` (or a date) starts from then instead. `q` stops; piped or with `--json` (one JSON object a line) it runs until it's killed, and `--once` prints what's there and returns, for scripts.
232
+ `harbor watch` turns a narrow split or a tmux pane into a live ticker of your harbor: cards moving, comments, health changes, a line each as they happen, over the same live updates the website uses. Lines about your own work (your tasks, mentions of you, what you lead) are bright and the rest dim; `--bell` rings the terminal's bell when something of yours changes, and `--project` keeps to one project. It starts with the latest few changes and the time it began listening; `--since 14:30` (or a date) starts from then instead, on this computer's clock (`14:30Z` or `14:30 UTC` for UTC); the header names the time zone the ticker's times are in, and Harbor says "Nothing since 14:30 BST." when nothing has. A change the GitHub link made reads "Finished RR-4 … via GitHub", as History does. `q` stops; piped or with `--json` (one JSON object a line) it runs until it's killed, and `--once` prints what's there and returns, for scripts.
227
233
 
228
234
  `harbor lifeboats` lists the work only one person has touched (a project, their corner of one, a task others wait on, their serious risks), numbered, each with someone who has room this week and already works nearby. `harbor lifeboats 2` puts that person on line 2 as a second pair of hands (`--person NAME` for someone else); `harbor follow RR-4 --person Sam` does it for one task. Following doesn't take the work over. `harbor raid "DECISION" --still-stands "WHY"` says a decision that came back for a look still stands.
229
235
 
package/dist/app.mjs CHANGED
@@ -36,15 +36,15 @@ var __exportAll = (all, no_symbols) => {
36
36
  //#region src/lib/clock.ts
37
37
  /** A date's local wall-clock time, with no zone: "2026-10-05T23:10:00". */
38
38
  function wallClock(d = /* @__PURE__ */ new Date()) {
39
- return `${d.getFullYear()}-${pad$1(d.getMonth() + 1)}-${pad$1(d.getDate())}T${pad$1(d.getHours())}:${pad$1(d.getMinutes())}:${pad$1(d.getSeconds())}`;
39
+ return `${d.getFullYear()}-${pad$2(d.getMonth() + 1)}-${pad$2(d.getDate())}T${pad$2(d.getHours())}:${pad$2(d.getMinutes())}:${pad$2(d.getSeconds())}`;
40
40
  }
41
41
  function clockNow() {
42
42
  return frozen ? new Date(frozen) : /* @__PURE__ */ new Date();
43
43
  }
44
- var frozen, pad$1;
44
+ var frozen, pad$2;
45
45
  var init_clock = __esmMin((() => {
46
46
  frozen = null;
47
- pad$1 = (n) => String(n).padStart(2, "0");
47
+ pad$2 = (n) => String(n).padStart(2, "0");
48
48
  }));
49
49
 
50
50
  //#endregion
@@ -130,7 +130,7 @@ function timeAgo(date, now = clockNow()) {
130
130
  const words = `${n} ${unit}${n === 1 ? "" : "s"}`;
131
131
  return diff > 0 ? `in ${words}` : `${words} ago`;
132
132
  }
133
- var MONTHS, DAYS, pad, TOKENS, TOKEN, ISO, MINUTE;
133
+ var MONTHS, DAYS$1, pad$1, TOKENS, TOKEN, ISO, MINUTE;
134
134
  var init_date_text = __esmMin((() => {
135
135
  init_clock();
136
136
  MONTHS = [
@@ -147,7 +147,7 @@ var init_date_text = __esmMin((() => {
147
147
  "November",
148
148
  "December"
149
149
  ];
150
- DAYS = [
150
+ DAYS$1 = [
151
151
  "Sunday",
152
152
  "Monday",
153
153
  "Tuesday",
@@ -156,16 +156,16 @@ var init_date_text = __esmMin((() => {
156
156
  "Friday",
157
157
  "Saturday"
158
158
  ];
159
- pad = (n, width = 2) => String(n).padStart(width, "0");
159
+ pad$1 = (n, width = 2) => String(n).padStart(width, "0");
160
160
  TOKENS = {
161
- yyyy: (d) => pad(d.getFullYear(), 4),
161
+ yyyy: (d) => pad$1(d.getFullYear(), 4),
162
162
  MMMM: (d) => MONTHS[d.getMonth()],
163
163
  MMM: (d) => MONTHS[d.getMonth()].slice(0, 3),
164
- MM: (d) => pad(d.getMonth() + 1),
165
- dd: (d) => pad(d.getDate()),
164
+ MM: (d) => pad$1(d.getMonth() + 1),
165
+ dd: (d) => pad$1(d.getDate()),
166
166
  d: (d) => String(d.getDate()),
167
- EEEE: (d) => DAYS[d.getDay()],
168
- EEE: (d) => DAYS[d.getDay()].slice(0, 3)
167
+ EEEE: (d) => DAYS$1[d.getDay()],
168
+ EEE: (d) => DAYS$1[d.getDay()].slice(0, 3)
169
169
  };
170
170
  TOKEN = /yyyy|MMMM|MMM|MM|dd|d|EEEE|EEE/g;
171
171
  ISO = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:[.,](\d{1,9}))?)?(Z|[+-]\d{2}(?::?\d{2})?)?)?$/;
@@ -2008,11 +2008,39 @@ var init_format = __esmMin((() => {
2008
2008
 
2009
2009
  //#endregion
2010
2010
  //#region src/lib/dates.ts
2011
+ init_date_fns();
2011
2012
  /** A sensible default end for a new child: the usual horizon, but never past the parent's end when that is still ahead. */
2012
2013
  function clampEndToParent(proposedEnd, parentEnd, today) {
2013
2014
  if (!parentEnd || parentEnd <= today) return proposedEnd;
2014
2015
  return proposedEnd > parentEnd ? parentEnd : proposedEnd;
2015
2016
  }
2017
+ const noonOf = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
2018
+ const isoOf = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
2019
+ /** Monday to Friday. */
2020
+ function isWorkingDay(iso) {
2021
+ const day = noonOf(iso).getDay();
2022
+ return day !== 0 && day !== 6;
2023
+ }
2024
+ /** The day `n` working days after `iso` (not counting `iso` itself); 0 gives `iso` back. */
2025
+ function addWorkingDays(iso, n) {
2026
+ let d = noonOf(iso);
2027
+ let left = Math.max(0, Math.round(n));
2028
+ while (left > 0) {
2029
+ d = addDays(d, 1);
2030
+ if (isWorkingDay(isoOf(d))) left -= 1;
2031
+ }
2032
+ return isoOf(d);
2033
+ }
2034
+ /** Working days from `from` to `to`, counting `to` but not `from`; 0 when `to` isn't after it. */
2035
+ function workingDaysBetween(from, to) {
2036
+ const start = noonOf(from);
2037
+ const days = differenceInCalendarDays(noonOf(to), start);
2038
+ if (days <= 0) return 0;
2039
+ const weeks = Math.floor(days / 7);
2040
+ let count = weeks * 5;
2041
+ for (let i = weeks * 7 + 1; i <= days; i++) if (isWorkingDay(isoOf(addDays(start, i)))) count += 1;
2042
+ return count;
2043
+ }
2016
2044
 
2017
2045
  //#endregion
2018
2046
  //#region src/lib/blockers.ts
@@ -3278,31 +3306,31 @@ function barnaclesHeadline(b) {
3278
3306
  if (b.added === 0) return null;
3279
3307
  const when = b.from === "baseline" ? "since the baseline" : "since its first week";
3280
3308
  const pct = b.share != null ? `, ${Math.round(b.share * 100)}% more than planned` : "";
3281
- return `+${plural$6(b.added, "task")} ${when}${pct}`;
3309
+ return `+${plural$7(b.added, "task")} ${when}${pct}`;
3282
3310
  }
3283
3311
  /** What the added work costs in days, and how much of the slip it explains. */
3284
3312
  function barnaclesDaysLine(b) {
3285
3313
  if (b.added === 0 || b.days == null || b.perWeek == null) return null;
3286
- const work = `At the crew's pace (about ${b.perWeek} a week) that's about ${plural$6(b.days, "day")} of work`;
3314
+ const work = `At the crew's pace (about ${b.perWeek} a week) that's about ${plural$7(b.days, "day")} of work`;
3287
3315
  if (b.slip == null || b.slip <= 0) return `${work}.`;
3288
- if (b.days >= b.slip) return `${work}: enough to explain all ${plural$6(b.slip, "day")} of the slip.`;
3289
- return `${work}: about ${b.days} of the ${plural$6(b.slip, "day")} it has slipped.`;
3316
+ if (b.days >= b.slip) return `${work}: enough to explain all ${plural$7(b.slip, "day")} of the slip.`;
3317
+ return `${work}: about ${b.days} of the ${plural$7(b.slip, "day")} it has slipped.`;
3290
3318
  }
3291
3319
  /** One line for the brief or a list: "+14 tasks (38%) since the baseline, 3 this week, about 65 days of work". */
3292
3320
  function barnaclesShort(b) {
3293
3321
  const pct = b.share != null ? ` (${Math.round(b.share * 100)}%)` : "";
3294
3322
  const when = b.from === "baseline" ? "since the baseline" : "since its first week";
3295
3323
  const week = b.thisWeek ? `, ${b.thisWeek} this week` : "";
3296
- const days = b.days != null ? `, about ${plural$6(b.days, "day")} of work` : "";
3324
+ const days = b.days != null ? `, about ${plural$7(b.days, "day")} of work` : "";
3297
3325
  const top = b.adders.find((a) => a.personId);
3298
3326
  const who = top && top.added * 2 > b.added ? `, mostly from ${top.name}` : "";
3299
- return `+${plural$6(b.added, "task")}${pct} ${when}${week}${days}${who}`;
3327
+ return `+${plural$7(b.added, "task")}${pct} ${when}${week}${days}${who}`;
3300
3328
  }
3301
3329
  /** Whether the brief names it: measured from a baseline, enough added, and enough of the plan. */
3302
3330
  function briefWorthy(b) {
3303
3331
  return b.from === "baseline" && b.added >= 3 && (b.share == null || b.share >= .15);
3304
3332
  }
3305
- var SETTLE_DAYS, BRIEF_MIN_ADDED, BRIEF_MIN_SHARE, noon$2, isoDay$3, plural$6;
3333
+ var SETTLE_DAYS, BRIEF_MIN_ADDED, BRIEF_MIN_SHARE, noon$2, isoDay$3, plural$7;
3306
3334
  var init_barnacles = __esmMin((() => {
3307
3335
  init_date_fns();
3308
3336
  init_format();
@@ -3312,7 +3340,7 @@ var init_barnacles = __esmMin((() => {
3312
3340
  BRIEF_MIN_SHARE = .15;
3313
3341
  noon$2 = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
3314
3342
  isoDay$3 = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
3315
- plural$6 = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
3343
+ plural$7 = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
3316
3344
  }));
3317
3345
 
3318
3346
  //#endregion
@@ -3718,8 +3746,8 @@ init_date_fns();
3718
3746
  init_blockers();
3719
3747
  init_date_text();
3720
3748
  init_premortem();
3721
- const day$6 = (iso) => parseIso(iso.slice(0, 10));
3722
- const between = (a, b) => differenceInCalendarDays(day$6(b), day$6(a));
3749
+ const day$7 = (iso) => parseIso(iso.slice(0, 10));
3750
+ const between = (a, b) => differenceInCalendarDays(day$7(b), day$7(a));
3723
3751
  /** When the project was marked Done: its last move to Done in its health history. */
3724
3752
  function finishedOn(project, tasks) {
3725
3753
  const marked = [...project.healthHistory ?? []].reverse().find((h) => h.health === "done");
@@ -3791,8 +3819,8 @@ function voyageLog(data, projectId) {
3791
3819
  thanks
3792
3820
  };
3793
3821
  }
3794
- const plural$5 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
3795
- const md$2 = (iso) => format(day$6(iso), "MMM d");
3822
+ const plural$6 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
3823
+ const md$2 = (iso) => format(day$7(iso), "MMM d");
3796
3824
  /** The three numbers at the top: how late, how much work, how many risks came true. */
3797
3825
  function voyageStats(log) {
3798
3826
  const happened = log.risks.filter((r) => r.happened).length;
@@ -3806,10 +3834,10 @@ function voyageStats(log) {
3806
3834
  value: "On time",
3807
3835
  label: "on the planned day"
3808
3836
  } : days > 0 ? {
3809
- value: `+${plural$5(days, "day")}`,
3837
+ value: `+${plural$6(days, "day")}`,
3810
3838
  label: `past the ${plan}`
3811
3839
  } : {
3812
- value: plural$5(-days, "day"),
3840
+ value: plural$6(-days, "day"),
3813
3841
  label: `ahead of the ${plan}`
3814
3842
  },
3815
3843
  {
@@ -3835,7 +3863,7 @@ function voyageStory(log) {
3835
3863
  out.push(`Barnacles: ${grew}${by}.${days ? ` ${days}` : ""}`);
3836
3864
  }
3837
3865
  for (const s of log.slips) {
3838
- const late = `“${s.task.title}” finished ${plural$5(s.daysLate, "day")} late`;
3866
+ const late = `“${s.task.title}” finished ${plural$6(s.daysLate, "day")} late`;
3839
3867
  out.push(s.waitedOn ? `${late}; it waited on “${s.waitedOn.title}”, done ${md$2(s.waitedOn.completedAt)}.` : `${late}.`);
3840
3868
  }
3841
3869
  for (const r of log.risks.filter((x) => x.happened)) out.push(`The risk “${r.risk.title}” came true${r.guessed ? " (an issue names it)" : ""}.`);
@@ -3843,11 +3871,11 @@ function voyageStory(log) {
3843
3871
  if (against) out.push(against);
3844
3872
  if (log.decisions.length) {
3845
3873
  const changed = log.decisions.filter((d) => d.status === "superseded").length;
3846
- out.push(`${plural$5(log.decisions.length, "decision was", "decisions were")} written down${changed ? `; ${changed === 1 ? "one was" : `${changed} were`} changed later` : ""}.`);
3874
+ out.push(`${plural$6(log.decisions.length, "decision was", "decisions were")} written down${changed ? `; ${changed === 1 ? "one was" : `${changed} were`} changed later` : ""}.`);
3847
3875
  }
3848
3876
  const top = log.carriers[0];
3849
- if (top && log.carriers.length > 1) out.push(`${top.person.name} closed ${plural$5(top.closed, "task")}, the most on the crew.`);
3850
- else if (top) out.push(`${top.person.name} closed ${plural$5(top.closed, "task")}.`);
3877
+ if (top && log.carriers.length > 1) out.push(`${top.person.name} closed ${plural$6(top.closed, "task")}, the most on the crew.`);
3878
+ else if (top) out.push(`${top.person.name} closed ${plural$6(top.closed, "task")}.`);
3851
3879
  if (log.budget > 0) {
3852
3880
  const pct = Math.round(log.spent / log.budget * 100);
3853
3881
  out.push(`Spent ${pct}% of the budget.`);
@@ -4886,7 +4914,7 @@ var init_cascade = __esmMin((() => {
4886
4914
  //#region src/lib/forecast.ts
4887
4915
  init_cascade();
4888
4916
  init_date_fns();
4889
- const day$5 = (iso) => /* @__PURE__ */ new Date(`${iso}T12:00:00`);
4917
+ const day$6 = (iso) => /* @__PURE__ */ new Date(`${iso}T12:00:00`);
4890
4918
  /**
4891
4919
  * A straight-line estimate at completion from the burn so far: spend divided
4892
4920
  * by the share of the schedule elapsed. Null before anything is spent or
@@ -4895,8 +4923,8 @@ const day$5 = (iso) => /* @__PURE__ */ new Date(`${iso}T12:00:00`);
4895
4923
  */
4896
4924
  function forecastAtCompletion(item, now) {
4897
4925
  if (item.spent <= 0) return null;
4898
- const total = differenceInCalendarDays(day$5(item.endDate), day$5(item.startDate)) + 1;
4899
- const gone = differenceInCalendarDays(now, day$5(item.startDate)) + 1;
4926
+ const total = differenceInCalendarDays(day$6(item.endDate), day$6(item.startDate)) + 1;
4927
+ const gone = differenceInCalendarDays(now, day$6(item.startDate)) + 1;
4900
4928
  if (total <= 0 || gone <= 0) return null;
4901
4929
  const elapsed = Math.min(1, gone / total);
4902
4930
  const eac = Math.round(item.spent / elapsed);
@@ -5019,7 +5047,7 @@ var init_flow = __esmMin((() => {
5019
5047
  //#region src/lib/phasing.ts
5020
5048
  init_flow();
5021
5049
  init_date_fns();
5022
- const day$4 = (iso) => /* @__PURE__ */ new Date(`${iso}T12:00:00`);
5050
+ const day$5 = (iso) => /* @__PURE__ */ new Date(`${iso}T12:00:00`);
5023
5051
  function monthKey(iso) {
5024
5052
  return iso.slice(0, 7);
5025
5053
  }
@@ -5035,7 +5063,7 @@ function monthsBetween(from, to) {
5035
5063
  return out;
5036
5064
  }
5037
5065
  function daysIn(key) {
5038
- return getDaysInMonth(day$4(`${key}-01`));
5066
+ return getDaysInMonth(day$5(`${key}-01`));
5039
5067
  }
5040
5068
  /** How many of the schedule's days fall in `key`. */
5041
5069
  function overlapDays(key, start, end) {
@@ -5043,7 +5071,7 @@ function overlapDays(key, start, end) {
5043
5071
  const lastDay = `${key}-${String(daysIn(key)).padStart(2, "0")}`;
5044
5072
  const from = start > first ? start : first;
5045
5073
  const to = end < lastDay ? end : lastDay;
5046
- return to < from ? 0 : differenceInCalendarDays(day$4(to), day$4(from)) + 1;
5074
+ return to < from ? 0 : differenceInCalendarDays(day$5(to), day$5(from)) + 1;
5047
5075
  }
5048
5076
  /**
5049
5077
  * The budget spread straight across the schedule: each month gets the share
@@ -5053,7 +5081,7 @@ function overlapDays(key, start, end) {
5053
5081
  function evenPhasing(project) {
5054
5082
  const end = project.endDate < project.startDate ? project.startDate : project.endDate;
5055
5083
  const months = monthsBetween(project.startDate, end);
5056
- const total = differenceInCalendarDays(day$4(end), day$4(project.startDate)) + 1;
5084
+ const total = differenceInCalendarDays(day$5(end), day$5(project.startDate)) + 1;
5057
5085
  const out = {};
5058
5086
  let placed = 0;
5059
5087
  months.forEach((k, i) => {
@@ -5163,7 +5191,7 @@ function hasBaseline(p) {
5163
5191
  /** Days the end has moved past the baseline end; negative when it came in. Null without a baseline. */
5164
5192
  function slipDays(p) {
5165
5193
  if (!p.baselineEnd) return null;
5166
- return differenceInCalendarDays(day$3(p.endDate), day$3(p.baselineEnd));
5194
+ return differenceInCalendarDays(day$4(p.endDate), day$4(p.baselineEnd));
5167
5195
  }
5168
5196
  /**
5169
5197
  * The projects with the current dates committed as their baseline; ones that
@@ -5185,10 +5213,10 @@ function slipLabel(p) {
5185
5213
  const n = Math.abs(slip);
5186
5214
  return slip > 0 ? `slipped ${n} day${n === 1 ? "" : "s"}` : `${n} day${n === 1 ? "" : "s"} early`;
5187
5215
  }
5188
- var day$3;
5216
+ var day$4;
5189
5217
  var init_baseline = __esmMin((() => {
5190
5218
  init_date_fns();
5191
- day$3 = (iso) => /* @__PURE__ */ new Date(`${iso}T12:00:00`);
5219
+ day$4 = (iso) => /* @__PURE__ */ new Date(`${iso}T12:00:00`);
5192
5220
  }));
5193
5221
 
5194
5222
  //#endregion
@@ -5217,7 +5245,7 @@ const ZOOMS = [
5217
5245
  step: 3
5218
5246
  }
5219
5247
  ];
5220
- const day$2 = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
5248
+ const day$3 = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
5221
5249
  /** Names in the order people expect: "Phase 2" before "Phase 10". */
5222
5250
  const byName = (a, b) => a.localeCompare(b, void 0, { numeric: true });
5223
5251
  const isoDay$2 = (d) => format(d, "yyyy-MM-dd");
@@ -5227,7 +5255,7 @@ const isoDay$2 = (d) => format(d, "yyyy-MM-dd");
5227
5255
  */
5228
5256
  function roadmapWindow(zoom, today, shift = 0) {
5229
5257
  const z = ZOOMS.find((x) => x.id === zoom) ?? ZOOMS[1];
5230
- const first = addMonths$1(startOfMonth(day$2(today)), -1 + shift * z.step);
5258
+ const first = addMonths$1(startOfMonth(day$3(today)), -1 + shift * z.step);
5231
5259
  const after = addMonths$1(first, z.months);
5232
5260
  const days = differenceInCalendarDays(after, first);
5233
5261
  const months = Array.from({ length: z.months }, (_, i) => {
@@ -5251,15 +5279,15 @@ function roadmapWindow(zoom, today, shift = 0) {
5251
5279
  function at$1(iso, w) {
5252
5280
  if (!iso) return null;
5253
5281
  if (iso < w.start || iso >= w.end) return null;
5254
- return differenceInCalendarDays(day$2(iso), day$2(w.start)) / w.days;
5282
+ return differenceInCalendarDays(day$3(iso), day$3(w.start)) / w.days;
5255
5283
  }
5256
5284
  /** A bar for a start–end span, clipped to the window; null when it's wholly outside. */
5257
5285
  function span(start, end, w) {
5258
5286
  if (!start || !end || end < w.start || start >= w.end) return null;
5259
5287
  const from = start < w.start ? w.start : start;
5260
- const until = end >= w.end ? w.end : isoDay$2(new Date(day$2(end).getTime() + 864e5));
5261
- const left = differenceInCalendarDays(day$2(from), day$2(w.start)) / w.days;
5262
- const right = differenceInCalendarDays(day$2(until), day$2(w.start)) / w.days;
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;
5263
5291
  return {
5264
5292
  left,
5265
5293
  width: Math.max(right - left, 1 / w.days),
@@ -6981,7 +7009,7 @@ function bottleState(note, ctx) {
6981
7009
  /** "opens on Nov 14", "opens when Factory buy-off is reached", "opens if Ridge Runner launch turns Off track" */
6982
7010
  function opensText(opens, ctx) {
6983
7011
  if (!opens) return "open now";
6984
- if (opens.on === "date") return `opens on ${day$1(opens.date)}`;
7012
+ if (opens.on === "date") return `opens on ${day$2(opens.date)}`;
6985
7013
  if (opens.on === "milestone") {
6986
7014
  const m = ctx.milestones.find((x) => x.id === opens.milestoneId);
6987
7015
  return m ? `opens when ${m.name} is reached` : "opens when its milestone is reached";
@@ -7015,7 +7043,7 @@ function freshlyOpened(data, projects, now) {
7015
7043
  }
7016
7044
  return out.sort((a, b) => (b.state.openedOn ?? "").localeCompare(a.state.openedOn ?? ""));
7017
7045
  }
7018
- var BOTTLE_FRESH_DAYS, isBottle, sealed, day$1;
7046
+ var BOTTLE_FRESH_DAYS, isBottle, sealed, day$2;
7019
7047
  var init_bottle = __esmMin((() => {
7020
7048
  init_date_fns();
7021
7049
  init_date_text();
@@ -7027,7 +7055,7 @@ var init_bottle = __esmMin((() => {
7027
7055
  open: false,
7028
7056
  openedOn: null
7029
7057
  };
7030
- day$1 = (iso) => format(parseIso(iso.slice(0, 10)), "MMM d");
7058
+ day$2 = (iso) => format(parseIso(iso.slice(0, 10)), "MMM d");
7031
7059
  }));
7032
7060
 
7033
7061
  //#endregion
@@ -7307,7 +7335,7 @@ function composeBriefBody(opts) {
7307
7335
  }
7308
7336
  return lines.join("\n").trim();
7309
7337
  }
7310
- function firstName$2(person) {
7338
+ function firstName$4(person) {
7311
7339
  return person?.name.split(/\s+/)[0] ?? "Someone";
7312
7340
  }
7313
7341
  function similar(a, b) {
@@ -7345,7 +7373,7 @@ function headlineOf(drums, flow) {
7345
7373
  }
7346
7374
  if (idle.length === 1) {
7347
7375
  const d = idle[0];
7348
- return d.owner ? `${firstName$2(d.owner)} is in play elsewhere. ${d.constraint.title} is starving.` : `${d.constraint.title} is idle. Protect it or retire it.`;
7376
+ return d.owner ? `${firstName$4(d.owner)} is in play elsewhere. ${d.constraint.title} is starving.` : `${d.constraint.title} is idle. Protect it or retire it.`;
7349
7377
  }
7350
7378
  if (idle.length > 1) return `${idle.length} constraints are idle. The work is not at the constraint.`;
7351
7379
  if (system && flow.impliedDays != null && flow.impliedDays > 21) return `At this load, ${formatWait(flow.impliedDays)}. ${system.constraint.title}.`;
@@ -7409,7 +7437,7 @@ function briefOf(data, opts = {}) {
7409
7437
  for (const d of drums.filter((x) => x.idle)) push({
7410
7438
  id: `idle-${d.constraint.id}`,
7411
7439
  title: `${d.constraint.title} is idle`,
7412
- detail: d.owner ? `${firstName$2(d.owner)} is in play elsewhere. ${d.program.name} is waiting.` : `${d.program.name} has a named constraint and nobody protecting it.`,
7440
+ detail: d.owner ? `${firstName$4(d.owner)} is in play elsewhere. ${d.program.name} is waiting.` : `${d.program.name} has a named constraint and nobody protecting it.`,
7413
7441
  href: `/programs/${d.program.id}`
7414
7442
  });
7415
7443
  for (const d of drums.filter((x) => x.constraint.kind === "collision" && !x.idle)) push({
@@ -9049,7 +9077,7 @@ function dollars(value) {
9049
9077
  const n = Number(value ?? 0);
9050
9078
  return whole.format(Number.isFinite(n) ? n : 0);
9051
9079
  }
9052
- function day(value) {
9080
+ function day$1(value) {
9053
9081
  return value ? shortDate(value) : "no date";
9054
9082
  }
9055
9083
  /** Whole days from one ISO day to another. */
@@ -9129,15 +9157,15 @@ function describeEvent(e, names) {
9129
9157
  };
9130
9158
  case "dueDate": return {
9131
9159
  action: "moved the due date of",
9132
- detail: `from ${day(from)} to ${day(to)}${slip(from, to)}`
9160
+ detail: `from ${day$1(from)} to ${day$1(to)}${slip(from, to)}`
9133
9161
  };
9134
9162
  case "startDate": return {
9135
9163
  action: "moved the start of",
9136
- detail: `from ${day(from)} to ${day(to)}${slip(from, to)}`
9164
+ detail: `from ${day$1(from)} to ${day$1(to)}${slip(from, to)}`
9137
9165
  };
9138
9166
  case "endDate": return {
9139
9167
  action: "moved the end of",
9140
- detail: `from ${day(from)} to ${day(to)}${slip(from, to)}`
9168
+ detail: `from ${day$1(from)} to ${day$1(to)}${slip(from, to)}`
9141
9169
  };
9142
9170
  case "budget": return {
9143
9171
  action: "changed the budget of",
@@ -9441,7 +9469,7 @@ const PING_KIND_LABEL = {
9441
9469
  idle: "Quiet blockers",
9442
9470
  mention: "Mentions"
9443
9471
  };
9444
- function firstName$1(name) {
9472
+ function firstName$3(name) {
9445
9473
  return name.split(/\s+/)[0] ?? name;
9446
9474
  }
9447
9475
  /** FNV-1a over the text, as eight hex digits: a short, stable name for a set of ids. */
@@ -9480,7 +9508,7 @@ function inboxOf(data, personId, now = clockNow()) {
9480
9508
  pings.push({
9481
9509
  id: `assigned:${task.id}:${task.assignedAt}`,
9482
9510
  kind: "assigned",
9483
- title: by ? `${firstName$1(by.name)} gave you a task` : "You have a new task",
9511
+ title: by ? `${firstName$3(by.name)} gave you a task` : "You have a new task",
9484
9512
  detail: `“${task.title}”${project ? ` · ${project.name}` : ""}`,
9485
9513
  at: task.assignedAt,
9486
9514
  taskId: task.id,
@@ -9494,7 +9522,7 @@ function inboxOf(data, personId, now = clockNow()) {
9494
9522
  const project = projectOf(data, blocker);
9495
9523
  const names = [...new Set(waiting.map((t) => {
9496
9524
  const who = data.people.find((p) => p.id === t.assigneeId);
9497
- return who ? firstName$1(who.name) : "Someone";
9525
+ return who ? firstName$3(who.name) : "Someone";
9498
9526
  }))];
9499
9527
  const blocked = waiting.length === 1 ? `“${waiting[0].title}”` : `${waiting.length} tasks`;
9500
9528
  const upstream = openBlockerNames(blocker, data.tasks);
@@ -9529,7 +9557,7 @@ function inboxOf(data, personId, now = clockNow()) {
9529
9557
  pings.push({
9530
9558
  id: `changes:${m.id}:${shortHash(note)}`,
9531
9559
  kind: "approval",
9532
- title: `${approver ? firstName$1(approver.name) : "The approver"} asked for changes to ${m.name}`,
9560
+ title: `${approver ? firstName$3(approver.name) : "The approver"} asked for changes to ${m.name}`,
9533
9561
  detail: note ? `${where}${excerpt(note)}` : program?.name ?? "",
9534
9562
  at: m.dueDate,
9535
9563
  programId: m.programId,
@@ -9575,7 +9603,7 @@ function inboxOf(data, personId, now = clockNow()) {
9575
9603
  pings.push({
9576
9604
  id: `sounding:${task.id}:${s.askedAt}`,
9577
9605
  kind: "sounding",
9578
- title: `${by ? firstName$1(by.name) : "Someone"} asked how long a task will take`,
9606
+ title: `${by ? firstName$3(by.name) : "Someone"} asked how long a task will take`,
9579
9607
  detail: `“${task.title}”${project ? ` · ${project.name}` : ""} · answer alone; the guesses show together`,
9580
9608
  at: s.askedAt,
9581
9609
  taskId: task.id,
@@ -9592,7 +9620,7 @@ function inboxOf(data, personId, now = clockNow()) {
9592
9620
  pings.push({
9593
9621
  id: `mention:${comment.id}`,
9594
9622
  kind: "mention",
9595
- title: author ? `${firstName$1(author.name)} mentioned you` : "You were mentioned",
9623
+ title: author ? `${firstName$3(author.name)} mentioned you` : "You were mentioned",
9596
9624
  detail: snippet ? `on “${task.title}”${project ? ` · ${project.name}` : ""} — ${snippet}` : `on “${task.title}”${project ? ` · ${project.name}` : ""}`,
9597
9625
  at: comment.createdAt,
9598
9626
  taskId: task.id,
@@ -10019,7 +10047,7 @@ function byTask(rows, who) {
10019
10047
  }
10020
10048
  return out;
10021
10049
  }
10022
- const plural$4 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
10050
+ const plural$5 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
10023
10051
  /** Work only one person has touched, soonest-needed first. `scope` narrows it to some programs. */
10024
10052
  function lifeboats(data, now, opts = {}) {
10025
10053
  const today = isoDay(now);
@@ -10057,7 +10085,7 @@ function lifeboats(data, now, opts = {}) {
10057
10085
  kind: "project",
10058
10086
  id: project.id,
10059
10087
  label: project.name,
10060
- detail: `${plural$4(open.length, "open task")}, nobody else on them`,
10088
+ detail: `${plural$5(open.length, "open task")}, nobody else on them`,
10061
10089
  soloId: solo[0],
10062
10090
  projectId: project.id,
10063
10091
  programId: project.programId,
@@ -10079,7 +10107,7 @@ function lifeboats(data, now, opts = {}) {
10079
10107
  out.push({
10080
10108
  kind: "tasks",
10081
10109
  id: `tasks:${project.id}:${soloId}`,
10082
- label: `${plural$4(list.length, "task")} on ${project.name}`,
10110
+ label: `${plural$5(list.length, "task")} on ${project.name}`,
10083
10111
  detail: `only ${first} has touched them`,
10084
10112
  soloId,
10085
10113
  projectId: project.id,
@@ -10099,7 +10127,7 @@ function lifeboats(data, now, opts = {}) {
10099
10127
  kind: "task",
10100
10128
  id: t.id,
10101
10129
  label: key ? `${key} ${t.title}` : t.title,
10102
- detail: [d < today ? `late since ${md(d)}` : `due ${md(d)}`, holds ? `${plural$4(holds, "task")} ${holds === 1 ? "waits" : "wait"} on it` : "urgent"].join(", "),
10130
+ detail: [d < today ? `late since ${md(d)}` : `due ${md(d)}`, holds ? `${plural$5(holds, "task")} ${holds === 1 ? "waits" : "wait"} on it` : "urgent"].join(", "),
10103
10131
  soloId,
10104
10132
  projectId: project.id,
10105
10133
  programId: project.programId,
@@ -10126,7 +10154,7 @@ function lifeboats(data, now, opts = {}) {
10126
10154
  const programId = first.programId ?? project?.programId ?? null;
10127
10155
  const where = project?.name ?? (programId ? programs.get(programId)?.name : null) ?? null;
10128
10156
  const issues = items.filter((r) => r.kind === "issue").length;
10129
- const noun = issues === 0 ? plural$4(items.length, "risk") : issues === items.length ? plural$4(items.length, "issue") : `${plural$4(items.length - issues, "risk")} and ${plural$4(issues, "issue")}`;
10157
+ const noun = issues === 0 ? plural$5(items.length, "risk") : issues === items.length ? plural$5(items.length, "issue") : `${plural$5(items.length - issues, "risk")} and ${plural$5(issues, "issue")}`;
10130
10158
  out.push({
10131
10159
  kind: "risks",
10132
10160
  id: key,
@@ -18619,13 +18647,13 @@ function milestonePole(milestone, data, now) {
18619
18647
  undated: first.pole.kind === "chain" ? first.pole.undated : 0
18620
18648
  };
18621
18649
  }
18622
- const plural$3 = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
18650
+ const plural$4 = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
18623
18651
  /** A link's slack in a few words: "4 days spare", "no slack", "3 days late for what's next". */
18624
18652
  function slackText(slack, last = false) {
18625
18653
  if (slack == null) return last ? "no date to hold" : "next has no due date";
18626
- if (slack > 0) return `${plural$3(slack, "day")} spare`;
18654
+ if (slack > 0) return `${plural$4(slack, "day")} spare`;
18627
18655
  if (slack === 0) return "no slack";
18628
- return last ? `runs ${plural$3(-slack, "day")} over` : `pushes the next ${plural$3(-slack, "day")}`;
18656
+ return last ? `runs ${plural$4(-slack, "day")} over` : `pushes the next ${plural$4(-slack, "day")}`;
18629
18657
  }
18630
18658
  /**
18631
18659
  * The sentence above the line: what sets the date and how it compares with
@@ -18637,9 +18665,9 @@ function poleHeadline(pole, name, held) {
18637
18665
  const lead = pole.via ? `${pole.via.project.name} finishes ${shortDate(pole.finish)}` : `${last ? name(last.task) : "It"} can finish ${shortDate(pole.finish)}`;
18638
18666
  const tail = pole.via && last && last.finish < pole.finish ? ` Its tasks can all be done by ${shortDate(last.finish)}; its end date sets it.` : "";
18639
18667
  if (pole.over == null || !pole.target) return `${lead}.${tail}`;
18640
- if (pole.over > 0) return `${lead}, ${plural$3(pole.over, "day")} after ${held} (${shortDate(pole.target)}).${tail}`;
18668
+ if (pole.over > 0) return `${lead}, ${plural$4(pole.over, "day")} after ${held} (${shortDate(pole.target)}).${tail}`;
18641
18669
  if (pole.over === 0) return `${lead}, right on ${held}.${tail}`;
18642
- return `${lead}, ${plural$3(-pole.over, "day")} before ${held}.${tail}`;
18670
+ return `${lead}, ${plural$4(-pole.over, "day")} before ${held}.${tail}`;
18643
18671
  }
18644
18672
  /** "13 open tasks have no due date, so they can't hold the date."; null when none. */
18645
18673
  function undatedText(pole) {
@@ -18784,6 +18812,288 @@ function reviewCsv(data, programs, now) {
18784
18812
  ]));
18785
18813
  }
18786
18814
 
18815
+ //#endregion
18816
+ //#region src/lib/crew-clocks.ts
18817
+ /** Working hours when only a time zone is set. */
18818
+ const DEFAULT_HOURS$1 = {
18819
+ start: "09:00",
18820
+ end: "17:00"
18821
+ };
18822
+ const HHMM = /^([01]\d|2[0-3]):([0-5]\d)$/;
18823
+ const pad = (n) => String(n).padStart(2, "0");
18824
+ /** "9:00", "17:30": a time as the crew reads it. */
18825
+ const timeText = (minutes) => `${Math.floor(minutes / 60)}:${pad(minutes % 60)}`;
18826
+ /** "09:00" as minutes since midnight, or null. */
18827
+ function minutesOf(hhmm) {
18828
+ const m = hhmm ? HHMM.exec(hhmm) : null;
18829
+ return m ? Number(m[1]) * 60 + Number(m[2]) : null;
18830
+ }
18831
+ const formats = /* @__PURE__ */ new Map();
18832
+ function formatFor(zone) {
18833
+ let f = formats.get(zone);
18834
+ if (!f) {
18835
+ f = new Intl.DateTimeFormat("en-US", {
18836
+ timeZone: zone,
18837
+ year: "numeric",
18838
+ month: "2-digit",
18839
+ day: "2-digit",
18840
+ weekday: "short",
18841
+ hour: "2-digit",
18842
+ minute: "2-digit",
18843
+ hourCycle: "h23"
18844
+ });
18845
+ formats.set(zone, f);
18846
+ }
18847
+ return f;
18848
+ }
18849
+ /** A time zone this runtime knows ("Europe/London"). */
18850
+ function isZone(zone) {
18851
+ if (!zone || zone.length > 64) return false;
18852
+ try {
18853
+ formatFor(zone);
18854
+ return true;
18855
+ } catch {
18856
+ return false;
18857
+ }
18858
+ }
18859
+ const DAYS = [
18860
+ "Sun",
18861
+ "Mon",
18862
+ "Tue",
18863
+ "Wed",
18864
+ "Thu",
18865
+ "Fri",
18866
+ "Sat"
18867
+ ];
18868
+ /** The wall clock in a zone at an instant: its date (ISO), weekday (0 Sunday) and minutes since midnight. */
18869
+ function wallIn(zone, at) {
18870
+ const parts = {};
18871
+ for (const p of formatFor(zone).formatToParts(at)) parts[p.type] = p.value;
18872
+ return {
18873
+ date: `${parts.year}-${parts.month}-${parts.day}`,
18874
+ weekday: DAYS.indexOf(parts.weekday ?? ""),
18875
+ minutes: Number(parts.hour) % 24 * 60 + Number(parts.minute)
18876
+ };
18877
+ }
18878
+ const LONG_DAYS = [
18879
+ "Sunday",
18880
+ "Monday",
18881
+ "Tuesday",
18882
+ "Wednesday",
18883
+ "Thursday",
18884
+ "Friday",
18885
+ "Saturday"
18886
+ ];
18887
+ /** Someone's clock now, or null with no time zone set. */
18888
+ function clockOf(person, at) {
18889
+ const zone = person?.timeZone;
18890
+ if (!isZone(zone)) return null;
18891
+ const wall = wallIn(zone, at);
18892
+ const start = minutesOf(person?.workStart) ?? minutesOf(DEFAULT_HOURS$1.start);
18893
+ let end = minutesOf(person?.workEnd) ?? minutesOf(DEFAULT_HOURS$1.end);
18894
+ if (end <= start) end = Math.min(1440, start + 480);
18895
+ const weekday = wall.weekday >= 1 && wall.weekday <= 5;
18896
+ const working = weekday && wall.minutes >= start && wall.minutes < end;
18897
+ let untilStart = 0;
18898
+ let backDay = null;
18899
+ if (!working) {
18900
+ let offset = wall.minutes < start && weekday ? 0 : 1;
18901
+ let day = (wall.weekday + offset) % 7;
18902
+ while (day === 0 || day === 6) {
18903
+ offset += 1;
18904
+ day = (wall.weekday + offset) % 7;
18905
+ }
18906
+ untilStart = offset * 24 * 60 + start - wall.minutes;
18907
+ if (offset > 1) backDay = LONG_DAYS[day];
18908
+ }
18909
+ return {
18910
+ zone,
18911
+ date: wall.date,
18912
+ minutes: wall.minutes,
18913
+ start,
18914
+ end,
18915
+ working,
18916
+ untilStart,
18917
+ backDay
18918
+ };
18919
+ }
18920
+ /** "in 9 hours", "in 40 minutes", "in 2 days". */
18921
+ function inText(minutes) {
18922
+ if (minutes < 60) return `in ${Math.max(1, Math.round(minutes))} minute${Math.round(minutes) === 1 ? "" : "s"}`;
18923
+ const hours = Math.round(minutes / 60);
18924
+ if (hours < 36) return `in ${hours} hour${hours === 1 ? "" : "s"}`;
18925
+ return `in ${Math.round(minutes / 1440)} days`;
18926
+ }
18927
+ const firstName$2 = (name) => name.trim().split(/\s+/)[0] || name;
18928
+ /**
18929
+ * When someone off for the day will pick up work: "Priya is off for the day
18930
+ * (23:40 there); picks it up at 9:00 their time, in 9 hours." Null while
18931
+ * they're working or with no time zone set.
18932
+ */
18933
+ function picksUpLine(person, at, you = false) {
18934
+ const clock = clockOf(person, at);
18935
+ if (!person || !clock || clock.working) return null;
18936
+ const who = you ? "You're" : `${firstName$2(person.name)} is`;
18937
+ const when = `${clock.backDay ? `${clock.backDay} ` : ""}at ${timeText(clock.start)}`;
18938
+ return `${who} ${clock.backDay ? `off until ${clock.backDay}` : "off for the day"} (${timeText(clock.minutes)} there); picks it up ${when} ${you ? "your" : "their"} time, ${inText(clock.untilStart)}.`;
18939
+ }
18940
+ /** Today where someone is: their date when they've set a time zone, otherwise `fallback`. */
18941
+ function todayFor(person, at, fallback) {
18942
+ return isZone(person?.timeZone) ? wallIn(person.timeZone, at).date : fallback;
18943
+ }
18944
+
18945
+ //#endregion
18946
+ //#region src/lib/pilot.ts
18947
+ init_load();
18948
+ init_format();
18949
+ init_handover();
18950
+ init_indexes();
18951
+ init_task_keys();
18952
+ /** How many of the owner's finished tasks in the project are read for how long one takes. */
18953
+ const SIMILAR = 20;
18954
+ /** Fewer finished tasks than this and the owner's history isn't used. */
18955
+ const SIMILAR_MIN = 3;
18956
+ /** A finished task that took longer than this sat somewhere; it counts as this long. */
18957
+ const SIMILAR_CAP = 15;
18958
+ const firstName$1 = (name) => name.trim().split(/\s+/)[0] || name;
18959
+ const day = (iso) => iso ? iso.slice(0, 10) : null;
18960
+ function quantile(sorted, q) {
18961
+ const at = (sorted.length - 1) * q;
18962
+ const lo = Math.floor(at);
18963
+ const hi = Math.ceil(at);
18964
+ return sorted[lo] + (sorted[hi] - sorted[lo]) * (at - lo);
18965
+ }
18966
+ /**
18967
+ * The suggestion for a task with an owner, or null when there's nothing to
18968
+ * say: no owner, an agent owns it, or it's done.
18969
+ */
18970
+ function pilotDate(task, data, localToday, at) {
18971
+ if (!task.assigneeId || task.status === "done") return null;
18972
+ const owner = byId(data.people).get(task.assigneeId);
18973
+ if (!owner || owner.kind === "agent") return null;
18974
+ const today = at ? todayFor(owner, at, localToday) : localToday;
18975
+ const chosen = day(task.dueDate);
18976
+ let underWay = 0;
18977
+ const dated = [];
18978
+ const finished = [];
18979
+ for (const t of data.tasks) {
18980
+ if (t.assigneeId !== owner.id || t.id === task.id) continue;
18981
+ if (t.status === "done") {
18982
+ if (t.projectId === task.projectId && t.completedAt) finished.push(t);
18983
+ continue;
18984
+ }
18985
+ const due = day(t.dueDate);
18986
+ if (t.status === "doing" || t.status === "review") underWay += 1;
18987
+ else if (due) dated.push(due);
18988
+ }
18989
+ const perDay = Math.max(.2, (owner.weeklyCapacity ?? 5) / 5);
18990
+ let similar = null;
18991
+ 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);
18993
+ similar = {
18994
+ low: Math.round(quantile(took, .25)),
18995
+ high: Math.round(quantile(took, .75)),
18996
+ count: took.length
18997
+ };
18998
+ }
18999
+ const ownDays = Math.min(5, Math.max(1, similar?.low ?? 1));
19000
+ const backOn = isAway(owner, today) ? day(owner.away.until) : null;
19001
+ const start = backOn && backOn > today ? dayBefore(backOn) : today;
19002
+ const tasks = byId(data.tasks);
19003
+ const projects = byId(data.projects);
19004
+ let waitsOn = null;
19005
+ let waitEnd = null;
19006
+ const blockers = /* @__PURE__ */ new Set([...task.blockedByIds ?? [], ...task.blockedById ? [task.blockedById] : []]);
19007
+ let undated = null;
19008
+ for (const id of blockers) {
19009
+ const b = tasks.get(id);
19010
+ if (!b || b.status === "done") continue;
19011
+ const due = day(b.dueDate);
19012
+ if (!due) {
19013
+ undated ??= b;
19014
+ continue;
19015
+ }
19016
+ const end = due > today ? due : today;
19017
+ if (!waitEnd || end > waitEnd) {
19018
+ waitEnd = end;
19019
+ waitsOn = {
19020
+ key: taskKey(b, projects.get(b.projectId)) ?? b.title,
19021
+ title: b.title,
19022
+ due
19023
+ };
19024
+ }
19025
+ }
19026
+ if (!waitsOn && undated) waitsOn = {
19027
+ key: taskKey(undated, projects.get(undated.projectId)) ?? undated.title,
19028
+ title: undated.title,
19029
+ due: null
19030
+ };
19031
+ const afterWait = waitEnd ? addWorkingDays(waitEnd, ownDays) : null;
19032
+ const by = (limit) => {
19033
+ const ahead = underWay + (limit ? dated.filter((d) => d <= limit).length : dated.length);
19034
+ const queueDays = Math.ceil(ahead / perDay);
19035
+ let earliest = addWorkingDays(start, queueDays + ownDays);
19036
+ if (afterWait && afterWait > earliest) earliest = afterWait;
19037
+ return {
19038
+ ahead,
19039
+ queueDays,
19040
+ earliest
19041
+ };
19042
+ };
19043
+ let read = by(chosen);
19044
+ const fits = Boolean(chosen && chosen >= read.earliest);
19045
+ if (chosen && !fits) for (let i = 0; i < 100; i++) {
19046
+ const next = by(read.earliest);
19047
+ if (next.earliest <= read.earliest) {
19048
+ read = {
19049
+ ...next,
19050
+ earliest: read.earliest
19051
+ };
19052
+ break;
19053
+ }
19054
+ read = next;
19055
+ }
19056
+ return {
19057
+ earliest: read.earliest,
19058
+ fits,
19059
+ owner: {
19060
+ id: owner.id,
19061
+ name: owner.name
19062
+ },
19063
+ ahead: read.ahead,
19064
+ queueDays: read.queueDays,
19065
+ backOn: backOn && backOn > today ? backOn : null,
19066
+ waitsOn,
19067
+ similar,
19068
+ ownDays
19069
+ };
19070
+ }
19071
+ function dayBefore(iso) {
19072
+ const d = /* @__PURE__ */ new Date(`${iso}T12:00:00`);
19073
+ d.setDate(d.getDate() - 1);
19074
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
19075
+ }
19076
+ const plural$3 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
19077
+ /** 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
+ function pilotHeadline(p, chosen, you = false) {
19079
+ const who = you ? "You" : firstName$1(p.owner.name);
19080
+ if (!day(chosen)) return `${who} could likely have this by ${shortDate(p.earliest)}`;
19081
+ return `${you ? "Your" : `${who}'s`} queue says ${shortDate(p.earliest)} at the earliest`;
19082
+ }
19083
+ /** Why: the queue, time away, what it waits on, and how long similar tasks took. */
19084
+ function pilotReasons(p, you = false) {
19085
+ const out = [];
19086
+ if (p.ahead > 0) out.push(`${plural$3(p.ahead, "open task")} ahead of this one, about ${plural$3(p.queueDays, "working day")}`);
19087
+ else out.push("Nothing else in the queue ahead of it");
19088
+ if (p.backOn) out.push(`${you ? "You're" : "Away,"} back on ${shortDate(p.backOn)}`);
19089
+ if (p.waitsOn) out.push(p.waitsOn.due ? `Waits on ${p.waitsOn.key}, due ${shortDate(p.waitsOn.due)}` : `Waits on ${p.waitsOn.key}, which has no date yet`);
19090
+ if (p.similar) {
19091
+ const range = p.similar.low === p.similar.high ? plural$3(p.similar.low, "day") : `${p.similar.low} to ${p.similar.high} days`;
19092
+ out.push(`Similar tasks here took ${you ? "you" : firstName$1(p.owner.name)} ${range}`);
19093
+ }
19094
+ return out;
19095
+ }
19096
+
18787
19097
  //#endregion
18788
19098
  //#region src/lib/agents.ts
18789
19099
  const isAgent = (p) => p?.kind === "agent";
@@ -19742,7 +20052,7 @@ const HARBOR_TOOLS = [
19742
20052
  tool({
19743
20053
  name: "get_task",
19744
20054
  title: "Task details",
19745
- description: "One task: notes, checklist, what it waits on, what waits on it, its latest comments, its place in its board column (`position`, from 1 at the top), and its url on the website." + DATA_NOTE,
20055
+ description: "One task: notes, checklist, what it waits on, what waits on it, its latest comments, the earliest due date its owner's queue suggests (pilotDue: fits says whether the due date holds; absent with no owner, an agent owner, or once done), its place in its board column (`position`, from 1 at the top), and its url on the website." + DATA_NOTE,
19746
20056
  input: object({
19747
20057
  harbor: harborArg,
19748
20058
  task: string().min(1).max(100).describe("Task id or key (\"RR-42\"), from search_tasks or my_work.")
@@ -19769,6 +20079,8 @@ const HARBOR_TOOLS = [
19769
20079
  body: c.body
19770
20080
  }));
19771
20081
  const key = taskKey(t, project);
20082
+ const pilot = pilotDate(t, data, todayISO(ctx.now), ctx.now);
20083
+ const you = Boolean(pilot && pilot.owner.id === me(ctx)?.id);
19772
20084
  return {
19773
20085
  id: t.id,
19774
20086
  ...key ? { key } : {},
@@ -19791,6 +20103,12 @@ const HARBOR_TOOLS = [
19791
20103
  waitingOn: blockersOf(t, data.tasks).map(brief),
19792
20104
  waitedOnBy: waiters.map(brief),
19793
20105
  ...t.sounding ? { sounding: soundingOut(data, t, me(ctx)?.id ?? null, ctx.now) } : {},
20106
+ ...pilot ? { pilotDue: {
20107
+ earliest: pilot.earliest,
20108
+ fits: pilot.fits,
20109
+ says: pilotHeadline(pilot, t.dueDate, you),
20110
+ why: pilotReasons(pilot, you)
20111
+ } } : {},
19794
20112
  comments,
19795
20113
  ...webLink(ctx, { task: t.id })
19796
20114
  };
@@ -19811,7 +20129,7 @@ const HARBOR_TOOLS = [
19811
20129
  you: null,
19812
20130
  note: "You aren't one of this harbor's people yet, so nothing is yours."
19813
20131
  };
19814
- const today = todayISO(ctx.now);
20132
+ const today = todayFor(mine, ctx.now, todayISO(ctx.now));
19815
20133
  const people = byId(data.people);
19816
20134
  const projects = byId(data.projects);
19817
20135
  const open = data.tasks.filter((t) => t.assigneeId === mine.id && isOpen(t)).sort((a, b) => (a.dueDate ?? "9999").localeCompare(b.dueDate ?? "9999"));
@@ -20554,6 +20872,83 @@ const HARBOR_TOOLS = [
20554
20872
  };
20555
20873
  }
20556
20874
  }),
20875
+ tool({
20876
+ name: "suggest_due",
20877
+ title: "Suggested due dates for new tasks",
20878
+ description: "The due date the owner's queue suggests for new tasks in a project (Pilot): their open work due first, their weekly pace, time away and how long their similar tasks took. With count, one date for each of that many new tasks, each counting the ones before it. Changes nothing; create_task with suggestedDue sets it." + DATA_NOTE,
20879
+ input: object({
20880
+ harbor: harborArg,
20881
+ project: string().min(1).max(200).describe("Project id or name."),
20882
+ assignee: string().min(1).max(200).default("me").describe("\"me\", or a person's id or name."),
20883
+ count: number().int().min(1).max(100).default(1)
20884
+ }),
20885
+ run: (ctx, input) => {
20886
+ const { data } = ctx;
20887
+ const project = findOne(data.projects, input.project, "project");
20888
+ const owner = input.assignee.trim().toLowerCase() === "me" ? me(ctx) : findOne(data.people, input.assignee, "person");
20889
+ if (!owner) throw new ToolError("You aren't one of this harbor's people, so say whose: assignee.");
20890
+ const you = owner.id === me(ctx)?.id;
20891
+ const today = todayISO(ctx.now);
20892
+ let tasks = data.tasks;
20893
+ const dates = [];
20894
+ for (let i = 0; i < input.count; i++) {
20895
+ const next = {
20896
+ id: `new-${i}`,
20897
+ projectId: project.id,
20898
+ assigneeId: owner.id,
20899
+ dueDate: null
20900
+ };
20901
+ const pilot = pilotDate(next, {
20902
+ ...data,
20903
+ tasks
20904
+ }, today, ctx.now);
20905
+ if (!pilot) throw new ToolError(`${owner.name} is an agent: Pilot reads a person's queue.`);
20906
+ dates.push({
20907
+ earliest: pilot.earliest,
20908
+ says: pilotHeadline(pilot, null, you),
20909
+ why: pilotReasons(pilot, you)
20910
+ });
20911
+ const added = {
20912
+ ...next,
20913
+ status: "todo",
20914
+ dueDate: pilot.earliest
20915
+ };
20916
+ tasks = [...tasks, added];
20917
+ }
20918
+ return {
20919
+ owner: owner.name,
20920
+ project: project.name,
20921
+ dates
20922
+ };
20923
+ }
20924
+ }),
20925
+ tool({
20926
+ name: "crew_clocks",
20927
+ title: "Who's working now",
20928
+ description: "Each person's time zone, local time and working hours (Monday to Friday, in their own zone), whether they're working now, and for someone off, when they'll pick up work (picksUp). People with no time zone set are listed under noTimeZone." + DATA_NOTE,
20929
+ input: object({ harbor: harborArg }),
20930
+ run: (ctx) => {
20931
+ const { data } = ctx;
20932
+ const mine = me(ctx);
20933
+ const crew = data.people.filter((p) => !isAgent(p));
20934
+ return {
20935
+ clocks: crew.flatMap((p) => {
20936
+ const c = clockOf(p, ctx.now);
20937
+ if (!c) return [];
20938
+ return [{
20939
+ who: p.name,
20940
+ zone: c.zone,
20941
+ localTime: timeText(c.minutes),
20942
+ date: c.date,
20943
+ hours: `${timeText(c.start)}-${timeText(c.end)}`,
20944
+ working: c.working,
20945
+ ...c.working ? {} : { picksUp: picksUpLine(p, ctx.now, p.id === mine?.id) }
20946
+ }];
20947
+ }),
20948
+ noTimeZone: crew.filter((p) => !clockOf(p, ctx.now)).map((p) => p.name)
20949
+ };
20950
+ }
20951
+ }),
20557
20952
  tool({
20558
20953
  name: "handover_plan",
20559
20954
  title: "What handing someone's work over would move",
@@ -29857,7 +30252,9 @@ function fogSummary(t) {
29857
30252
 
29858
30253
  //#endregion
29859
30254
  //#region src/tui/watch.ts
30255
+ init_clock();
29860
30256
  init_date_text();
30257
+ init_format();
29861
30258
  init_indexes();
29862
30259
  init_swatches();
29863
30260
  /** Most lines one change prints; a whole reload says how many more there were. */
@@ -29877,14 +30274,17 @@ function watchLines(prev, next, meId) {
29877
30274
  const label = (t) => `${keyOf$1(next, t)} ${t.title}`;
29878
30275
  const seen = new Set(prev.activity.map((a) => a.id));
29879
30276
  const fresh = next.activity.filter((a) => !seen.has(a.id));
30277
+ const logged = (t) => fresh.find((a) => a.text.includes(`“${t.title}”`));
29880
30278
  const who = (t) => {
29881
- const hit = fresh.find((a) => a.text.includes(`“${t.title}”`));
30279
+ const hit = logged(t);
29882
30280
  if (!hit) return null;
29883
30281
  return hit.personId === meId ? "You" : firstName(people.get(hit.personId));
29884
30282
  };
29885
30283
  const said = (t, phrase) => {
29886
- const name = who(t);
29887
- return name ? `${name} ${phrase}` : phrase.charAt(0).toUpperCase() + phrase.slice(1);
30284
+ const via = logged(t)?.via;
30285
+ const name = via ? null : who(t);
30286
+ const text = name ? `${name} ${phrase}` : phrase.charAt(0).toUpperCase() + phrase.slice(1);
30287
+ return via ? `${text} via ${via}` : text;
29888
30288
  };
29889
30289
  for (const t of next.tasks) {
29890
30290
  const was = before.get(t.id);
@@ -29988,7 +30388,7 @@ function recentLines(doc, meId, opts = {}) {
29988
30388
  return (opts.since ? rows : rows.slice(-(opts.limit ?? 5))).map((a) => ({
29989
30389
  at: a.at,
29990
30390
  line: {
29991
- text: `${a.personId === meId ? "You" : firstName(people.get(a.personId))} ${a.text}`,
30391
+ text: a.via ? `${a.text.charAt(0).toUpperCase()}${a.text.slice(1)} via ${a.via}` : `${a.personId === meId ? "You" : firstName(people.get(a.personId))} ${a.text}`,
29992
30392
  mine: false,
29993
30393
  tone: "plain"
29994
30394
  }
@@ -29997,20 +30397,36 @@ function recentLines(doc, meId, opts = {}) {
29997
30397
  /**
29998
30398
  * `--since`: "14:30" (today, on this computer's clock) or a date or moment
29999
30399
  * ("2026-10-21", "2026-10-21T14:30"), as an ISO moment; null when it isn't one.
30400
+ * A trailing "Z" or "UTC" reads it in UTC instead ("14:30Z", "2026-10-21 14:30 UTC").
30000
30401
  */
30001
30402
  function watchSince(text, now) {
30002
- const clock = /^(\d{1,2}):(\d{2})$/.exec(text.trim());
30403
+ const t = text.trim();
30404
+ const utc = /(?:\s*utc|z)$/i.test(t);
30405
+ const bare = utc ? t.replace(/(?:\s*utc|z)$/i, "") : t;
30406
+ const clock = /^(\d{1,2}):(\d{2})$/.exec(bare);
30003
30407
  if (clock) {
30004
30408
  const [h, m] = [Number(clock[1]), Number(clock[2])];
30005
30409
  if (h > 23 || m > 59) return null;
30006
30410
  const at = new Date(now);
30007
- at.setHours(h, m, 0, 0);
30411
+ if (utc) at.setUTCHours(h, m, 0, 0);
30412
+ else at.setHours(h, m, 0, 0);
30008
30413
  return at.toISOString();
30009
30414
  }
30010
- if (!/^\d{4}-\d{2}-\d{2}/.test(text.trim())) return null;
30011
- const at = parseIso(text);
30415
+ if (!/^\d{4}-\d{2}-\d{2}/.test(bare)) return null;
30416
+ const at = parseIso(utc ? `${bare.replace(" ", "T")}${bare.length > 10 ? "Z" : "T00:00Z"}` : bare);
30012
30417
  return Number.isNaN(at.getTime()) ? null : at.toISOString();
30013
30418
  }
30419
+ /** This computer's zone at a moment, short: "BST", "EDT", "UTC", "GMT+5:30". */
30420
+ function zoneName(at) {
30421
+ return new Intl.DateTimeFormat("en-US", { timeZoneName: "short" }).formatToParts(at).find((p) => p.type === "timeZoneName")?.value ?? "";
30422
+ }
30423
+ /** A --since moment as the ticker says it: "14:30 BST" today, "Oct 20, 14:30 BST" before. */
30424
+ function sinceText(iso, now) {
30425
+ const at = new Date(iso);
30426
+ const wall = wallClock(at);
30427
+ const time = `${wall.slice(11, 16)} ${zoneName(at)}`.trim();
30428
+ return wall.slice(0, 10) === wallClock(now).slice(0, 10) ? time : `${shortDate(wall.slice(0, 10))}, ${time}`;
30429
+ }
30014
30430
 
30015
30431
  //#endregion
30016
30432
  //#region src/tui/watch-run.ts
@@ -30075,9 +30491,10 @@ async function watchOn(opts) {
30075
30491
  projectId: project?.id ?? null
30076
30492
  });
30077
30493
  if (!opts.json) {
30078
- out(dim(opts.once ? `⚓ ${plain(opts.harbor.name)}${project ? ` · ${plain(project.name)}` : ""}` : `⚓ ${plain(opts.harbor.name)}${project ? ` · ${plain(project.name)}` : ""} · on watch since ${wallClock(started).slice(11, 16)}${opts.bell ? " · bell on" : ""} · q to stop`));
30079
- if (earlier.length === 0) out(dim(opts.since ? "Nothing changed since then." : "Nothing logged yet."));
30080
- else out(dim(opts.since ? "Since then:" : "Lately:"));
30494
+ out(dim(opts.once ? `⚓ ${plain(opts.harbor.name)}${project ? ` · ${plain(project.name)}` : ""} · times in ${zoneName(started)}` : `⚓ ${plain(opts.harbor.name)}${project ? ` · ${plain(project.name)}` : ""} · on watch since ${wallClock(started).slice(11, 16)} ${zoneName(started)}${opts.bell ? " · bell on" : ""} · q to stop`));
30495
+ const since = opts.since ? sinceText(opts.since, started) : null;
30496
+ if (earlier.length === 0) out(dim(since ? `Nothing since ${since}.` : "Nothing logged yet."));
30497
+ else out(dim(since ? `Since ${since}:` : "Lately:"));
30081
30498
  }
30082
30499
  for (const e of earlier) print(e.line, new Date(e.at), true);
30083
30500
  if (opts.once) return;
package/lib/cli.mjs CHANGED
@@ -107,11 +107,16 @@ export const HELP = `harbor: Harbor from the terminal
107
107
  harbor hill PROJECT --at 0-100|top|done move it: up to 50 is figuring it
108
108
  out, past 50 is getting it done
109
109
  harbor mine what's on you, late first
110
+ harbor clock [NAME] [--zone ZONE|here|none] [--hours 9-17]
111
+ who's working now, or set your
112
+ (or NAME's) time zone and hours
110
113
  harbor tasks [words] [--project P] [--assignee me|none|NAME]
111
114
  [--late] [--status S,S] [--all]
112
115
  the first 20 (--all: every one);
113
116
  done ones only with --status done
114
- harbor show TASK one task (TASK: a key like RR-42)
117
+ harbor show TASK one task (TASK: a key like RR-42),
118
+ with the date its owner's queue
119
+ suggests
115
120
  harbor open [TASK|PROJECT] open it on the website (or Today)
116
121
  harbor invite EMAIL [--viewer] invite someone to your harbor
117
122
  harbor prompt [--shell SHELL] [--refresh] due today and late, for your
@@ -566,9 +571,14 @@ const DETAIL = {
566
571
  "",
567
572
  " --assignee me|none|NAME, --due DATE|pilot, --priority low|medium|high|urgent,",
568
573
  " --status S, --comment TEXT and --over-limit apply to every task added, so",
569
- " ten lines with --comment add the same comment to all ten. --due pilot",
570
- " takes the date the owner's queue suggests. More than 100 lines is harbor",
571
- " import's job.",
574
+ " ten lines with --comment add the same comment to all ten. More than 100",
575
+ " lines is harbor import's job.",
576
+ "",
577
+ " --due pilot takes the earliest day the owner's queue suggests: their open",
578
+ " work due first, their weekly pace, time away, what it waits on and how",
579
+ " long their similar tasks took. It needs an owner (--assignee none is",
580
+ " refused), and an empty queue gives their next working day. Several lines",
581
+ " each count the ones before them; with --dry-run it shows each line's date.",
572
582
  ],
573
583
  week: [
574
584
  " harbor week [--next|--last|--week DATE] reads: your week, the days and",
@@ -859,7 +869,9 @@ const DETAIL = {
859
869
  " --project P one project's changes only",
860
870
  " --bell ring the terminal's bell when something of yours changes",
861
871
  " --since TIME start with every change since TIME (14:30 today, or",
862
- " 2026-10-21, or 2026-10-21T14:30) instead of the last 5",
872
+ " 2026-10-21, or 2026-10-21T14:30) instead of the last 5;",
873
+ " times are this computer's, or UTC with Z or UTC after",
874
+ " (14:30Z), and the header names the zone",
863
875
  " --once print those and stop instead of listening",
864
876
  " --json one JSON object a line (at, text, quote, mine, tone;",
865
877
  " earlier: true for the ones from before it started)",
@@ -1900,6 +1912,8 @@ export async function run(argv, io = {}) {
1900
1912
  ),
1901
1913
  ];
1902
1914
  if (t.repeats) lines.push(`Repeats ${REPEAT_WORD[t.repeats] ?? t.repeats}.`);
1915
+ if (t.pilotDue?.fits) lines.push(`${t.pilotDue.says}, so the due date holds.`);
1916
+ else if (t.pilotDue) lines.push(...pilotLines({ pilot: t.pilotDue }, t));
1903
1917
  if (t.description) lines.push("", t.description);
1904
1918
  if (t.checklist.length)
1905
1919
  lines.push("", ...t.checklist.map((c) => ` [${c.done ? "x" : " "}] ${c.step}`));
@@ -2160,11 +2174,38 @@ export async function run(argv, io = {}) {
2160
2174
  );
2161
2175
  if (values["dry-run"]) {
2162
2176
  const n = titles.length === 1 ? "1 task" : `${titles.length} tasks`;
2163
- if (asJson) out(jsonText({ dryRun: true, project: values.project, titles }));
2177
+ // --due pilot: the date each line would take, each counting the ones before it.
2178
+ const pilot = duePilot(values.due)
2179
+ ? (
2180
+ await client.call("suggest_due", {
2181
+ harbor,
2182
+ project: values.project,
2183
+ assignee: values.assignee ?? "me",
2184
+ count: titles.length,
2185
+ })
2186
+ ).dates
2187
+ : null;
2188
+ if (asJson)
2189
+ out(
2190
+ jsonText({
2191
+ dryRun: true,
2192
+ project: values.project,
2193
+ titles,
2194
+ ...(pilot ? { due: pilot.map((d) => d.earliest) } : {}),
2195
+ }),
2196
+ );
2164
2197
  else
2165
2198
  out(
2166
2199
  plain(
2167
- [`Would add ${n} to ${values.project}:`, ...titles.map((t) => ` ${t}`)].join("\n"),
2200
+ [
2201
+ `Would add ${n} to ${values.project}:`,
2202
+ ...titles.map((t, i) => ` ${t}${pilot ? ` (due ${pilot[i].earliest})` : ""}`),
2203
+ ...(pilot
2204
+ ? [
2205
+ `--due pilot: ${pilot[0].says}; ${pilot[0].why.map((w) => w.charAt(0).toLowerCase() + w.slice(1)).join("; ")}.`,
2206
+ ]
2207
+ : []),
2208
+ ].join("\n"),
2168
2209
  ),
2169
2210
  );
2170
2211
  return 0;
@@ -2393,6 +2434,51 @@ export async function run(argv, io = {}) {
2393
2434
  `Comment added to ${r.commented.task}${r.mentioned?.length ? `; told ${r.mentioned.join(", ")}` : ""}.`,
2394
2435
  );
2395
2436
  }
2437
+ case "clock": {
2438
+ const { positionals, values, harbor } = parse(spec([], ["zone", "hours"]));
2439
+ const who = positionals.join(" ").trim();
2440
+ if (values.zone === undefined && values.hours === undefined) {
2441
+ return call("crew_clocks", { harbor }, (r) => {
2442
+ const rows = who ? r.clocks.filter((c) => nameHas(c.who, who)) : r.clocks;
2443
+ const quiet = who ? r.noTimeZone.filter((n) => nameHas(n, who)) : r.noTimeZone;
2444
+ if (rows.length === 0 && quiet.length === 0) {
2445
+ return who ? `Nobody on the crew matches "${who}".` : "Nobody on the crew yet.";
2446
+ }
2447
+ const width = Math.max(0, ...rows.map((c) => c.who.length));
2448
+ return [
2449
+ ...rows.map(
2450
+ (c) =>
2451
+ `${c.who.padEnd(width)} ${c.localTime.padStart(5)} ${c.zone.replace(/_/g, " ")} ${c.working ? `working (${c.hours})` : (c.picksUp ?? "off")}`,
2452
+ ),
2453
+ ...(quiet.length
2454
+ ? [
2455
+ `${rows.length ? "\n" : ""}No time zone yet: ${quiet.join(", ")}. harbor clock --zone here sets yours.`,
2456
+ ]
2457
+ : []),
2458
+ ].join("\n");
2459
+ });
2460
+ }
2461
+ let zone = values.zone;
2462
+ if (zone?.toLowerCase() === "here") {
2463
+ zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
2464
+ if (!zone) throw new HarborError("This machine doesn't say its time zone. Give one, like --zone Europe/London.");
2465
+ }
2466
+ let starts;
2467
+ let ends;
2468
+ if (values.hours !== undefined) {
2469
+ const m = /^\s*([^-–]+?)\s*[-–]\s*(.+?)\s*$/.exec(values.hours);
2470
+ if (!m) throw new HarborError("--hours is a start and an end, like 9-17 or 8:30-16:30.");
2471
+ [, starts, ends] = m;
2472
+ }
2473
+ return call(
2474
+ "set_clock",
2475
+ { harbor, person: who || "me", zone, starts, ends },
2476
+ (r) =>
2477
+ r.zone
2478
+ ? `${r.person}: ${r.zone.replace(/_/g, " ")}, ${r.hours} on weekdays. ${r.working ? `Working now (${r.localTime} there).` : (r.picksUp ?? "")}`.trim()
2479
+ : `${r.person} has no time zone now.`,
2480
+ );
2481
+ }
2396
2482
  case "checkin": {
2397
2483
  const { positionals, values, harbor } = parse(spec(["on-blocker"]));
2398
2484
  return call(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.62.0",
3
+ "version": "0.63.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",