@harborppm/cli 0.61.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/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$5(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$5(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$5(b.slip, "day")} of the slip.`;
3289
- return `${work}: about ${b.days} of the ${plural$5(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$5(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$5(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$5;
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$5 = (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$4 = (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$4(days, "day")}`,
3837
+ value: `+${plural$6(days, "day")}`,
3810
3838
  label: `past the ${plan}`
3811
3839
  } : {
3812
- value: plural$4(-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$4(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$4(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$4(top.closed, "task")}, the most on the crew.`);
3850
- else if (top) out.push(`${top.person.name} closed ${plural$4(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),
@@ -5639,7 +5667,7 @@ function projectSummary(data, project) {
5639
5667
  * A due date typed in the terminal: 2026-10-14, today, tomorrow, +3 (days
5640
5668
  * from today), or none. Null when it can't be read; "" for none.
5641
5669
  */
5642
- function readDue(text, today) {
5670
+ function readDue$1(text, today) {
5643
5671
  const t = text.trim().toLowerCase();
5644
5672
  if (t === "" || t === "none" || t === "-") return "";
5645
5673
  if (t === "today") return today;
@@ -6443,7 +6471,7 @@ function Editor({ label, edit, done }) {
6443
6471
  hint: error ?? "A date, today, tomorrow, +3 for three days on, or none",
6444
6472
  onCancel: done,
6445
6473
  onSubmit: (text) => {
6446
- const due = readDue(text, today());
6474
+ const due = readDue$1(text, today());
6447
6475
  if (due == null) return setError(`“${text}” isn't a date Harbor can read.`);
6448
6476
  done();
6449
6477
  edit.onSave(due || null);
@@ -6610,7 +6638,7 @@ function Stepper({ title, steps, width, onDone, onCancel }) {
6610
6638
  const t = text.trim();
6611
6639
  if (!t && !step.optional) return setError(`${step.label} is needed.`);
6612
6640
  if (step.kind === "date") {
6613
- const due = t ? readDue(t, today()) : "";
6641
+ const due = t ? readDue$1(t, today()) : "";
6614
6642
  if (due == null) return setError(`“${text}” isn't a date Harbor can read.`);
6615
6643
  return next(due || null);
6616
6644
  }
@@ -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({
@@ -8495,7 +8523,7 @@ function roomFor(store, projectId, status) {
8495
8523
  //#endregion
8496
8524
  //#region src/tui/views/common.tsx
8497
8525
  /** One task on one line: key, title, due, priority, and where it lives. */
8498
- function TaskLine({ task, projects, people, today, selected, width, showProject = true, showAssignee = false, blocked = false }) {
8526
+ function TaskLine({ task, projects, people, today, selected, picked = false, width, showProject = true, showAssignee = false, blocked = false }) {
8499
8527
  const late = isLate(task, today);
8500
8528
  const project = showProject ? projects.find((p) => p.id === task.projectId) : null;
8501
8529
  const who = showAssignee ? people?.find((p) => p.id === task.assigneeId) : null;
@@ -8506,7 +8534,11 @@ function TaskLine({ task, projects, people, today, selected, width, showProject
8506
8534
  bold: selected,
8507
8535
  wrap: "truncate-end",
8508
8536
  children: [
8509
- selected ? "› " : " ",
8537
+ selected ? "›" : " ",
8538
+ picked ? /* @__PURE__ */ jsx(Text, {
8539
+ color: theme.accent,
8540
+ children: "✓"
8541
+ }) : " ",
8510
8542
  /* @__PURE__ */ jsx(Text, {
8511
8543
  color: theme.muted,
8512
8544
  children: keyOf$1({ projects }, task).padEnd(7)
@@ -9045,7 +9077,7 @@ function dollars(value) {
9045
9077
  const n = Number(value ?? 0);
9046
9078
  return whole.format(Number.isFinite(n) ? n : 0);
9047
9079
  }
9048
- function day(value) {
9080
+ function day$1(value) {
9049
9081
  return value ? shortDate(value) : "no date";
9050
9082
  }
9051
9083
  /** Whole days from one ISO day to another. */
@@ -9125,15 +9157,15 @@ function describeEvent(e, names) {
9125
9157
  };
9126
9158
  case "dueDate": return {
9127
9159
  action: "moved the due date of",
9128
- detail: `from ${day(from)} to ${day(to)}${slip(from, to)}`
9160
+ detail: `from ${day$1(from)} to ${day$1(to)}${slip(from, to)}`
9129
9161
  };
9130
9162
  case "startDate": return {
9131
9163
  action: "moved the start of",
9132
- detail: `from ${day(from)} to ${day(to)}${slip(from, to)}`
9164
+ detail: `from ${day$1(from)} to ${day$1(to)}${slip(from, to)}`
9133
9165
  };
9134
9166
  case "endDate": return {
9135
9167
  action: "moved the end of",
9136
- detail: `from ${day(from)} to ${day(to)}${slip(from, to)}`
9168
+ detail: `from ${day$1(from)} to ${day$1(to)}${slip(from, to)}`
9137
9169
  };
9138
9170
  case "budget": return {
9139
9171
  action: "changed the budget of",
@@ -9437,7 +9469,7 @@ const PING_KIND_LABEL = {
9437
9469
  idle: "Quiet blockers",
9438
9470
  mention: "Mentions"
9439
9471
  };
9440
- function firstName$1(name) {
9472
+ function firstName$3(name) {
9441
9473
  return name.split(/\s+/)[0] ?? name;
9442
9474
  }
9443
9475
  /** FNV-1a over the text, as eight hex digits: a short, stable name for a set of ids. */
@@ -9476,7 +9508,7 @@ function inboxOf(data, personId, now = clockNow()) {
9476
9508
  pings.push({
9477
9509
  id: `assigned:${task.id}:${task.assignedAt}`,
9478
9510
  kind: "assigned",
9479
- 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",
9480
9512
  detail: `“${task.title}”${project ? ` · ${project.name}` : ""}`,
9481
9513
  at: task.assignedAt,
9482
9514
  taskId: task.id,
@@ -9490,7 +9522,7 @@ function inboxOf(data, personId, now = clockNow()) {
9490
9522
  const project = projectOf(data, blocker);
9491
9523
  const names = [...new Set(waiting.map((t) => {
9492
9524
  const who = data.people.find((p) => p.id === t.assigneeId);
9493
- return who ? firstName$1(who.name) : "Someone";
9525
+ return who ? firstName$3(who.name) : "Someone";
9494
9526
  }))];
9495
9527
  const blocked = waiting.length === 1 ? `“${waiting[0].title}”` : `${waiting.length} tasks`;
9496
9528
  const upstream = openBlockerNames(blocker, data.tasks);
@@ -9525,7 +9557,7 @@ function inboxOf(data, personId, now = clockNow()) {
9525
9557
  pings.push({
9526
9558
  id: `changes:${m.id}:${shortHash(note)}`,
9527
9559
  kind: "approval",
9528
- 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}`,
9529
9561
  detail: note ? `${where}${excerpt(note)}` : program?.name ?? "",
9530
9562
  at: m.dueDate,
9531
9563
  programId: m.programId,
@@ -9571,7 +9603,7 @@ function inboxOf(data, personId, now = clockNow()) {
9571
9603
  pings.push({
9572
9604
  id: `sounding:${task.id}:${s.askedAt}`,
9573
9605
  kind: "sounding",
9574
- 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`,
9575
9607
  detail: `“${task.title}”${project ? ` · ${project.name}` : ""} · answer alone; the guesses show together`,
9576
9608
  at: s.askedAt,
9577
9609
  taskId: task.id,
@@ -9588,7 +9620,7 @@ function inboxOf(data, personId, now = clockNow()) {
9588
9620
  pings.push({
9589
9621
  id: `mention:${comment.id}`,
9590
9622
  kind: "mention",
9591
- title: author ? `${firstName$1(author.name)} mentioned you` : "You were mentioned",
9623
+ title: author ? `${firstName$3(author.name)} mentioned you` : "You were mentioned",
9592
9624
  detail: snippet ? `on “${task.title}”${project ? ` · ${project.name}` : ""} — ${snippet}` : `on “${task.title}”${project ? ` · ${project.name}` : ""}`,
9593
9625
  at: comment.createdAt,
9594
9626
  taskId: task.id,
@@ -10015,7 +10047,7 @@ function byTask(rows, who) {
10015
10047
  }
10016
10048
  return out;
10017
10049
  }
10018
- const plural$3 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
10050
+ const plural$5 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
10019
10051
  /** Work only one person has touched, soonest-needed first. `scope` narrows it to some programs. */
10020
10052
  function lifeboats(data, now, opts = {}) {
10021
10053
  const today = isoDay(now);
@@ -10053,7 +10085,7 @@ function lifeboats(data, now, opts = {}) {
10053
10085
  kind: "project",
10054
10086
  id: project.id,
10055
10087
  label: project.name,
10056
- detail: `${plural$3(open.length, "open task")}, nobody else on them`,
10088
+ detail: `${plural$5(open.length, "open task")}, nobody else on them`,
10057
10089
  soloId: solo[0],
10058
10090
  projectId: project.id,
10059
10091
  programId: project.programId,
@@ -10075,7 +10107,7 @@ function lifeboats(data, now, opts = {}) {
10075
10107
  out.push({
10076
10108
  kind: "tasks",
10077
10109
  id: `tasks:${project.id}:${soloId}`,
10078
- label: `${plural$3(list.length, "task")} on ${project.name}`,
10110
+ label: `${plural$5(list.length, "task")} on ${project.name}`,
10079
10111
  detail: `only ${first} has touched them`,
10080
10112
  soloId,
10081
10113
  projectId: project.id,
@@ -10095,7 +10127,7 @@ function lifeboats(data, now, opts = {}) {
10095
10127
  kind: "task",
10096
10128
  id: t.id,
10097
10129
  label: key ? `${key} ${t.title}` : t.title,
10098
- detail: [d < today ? `late since ${md(d)}` : `due ${md(d)}`, holds ? `${plural$3(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(", "),
10099
10131
  soloId,
10100
10132
  projectId: project.id,
10101
10133
  programId: project.programId,
@@ -10122,7 +10154,7 @@ function lifeboats(data, now, opts = {}) {
10122
10154
  const programId = first.programId ?? project?.programId ?? null;
10123
10155
  const where = project?.name ?? (programId ? programs.get(programId)?.name : null) ?? null;
10124
10156
  const issues = items.filter((r) => r.kind === "issue").length;
10125
- const noun = issues === 0 ? plural$3(items.length, "risk") : issues === items.length ? plural$3(items.length, "issue") : `${plural$3(items.length - issues, "risk")} and ${plural$3(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")}`;
10126
10158
  out.push({
10127
10159
  kind: "risks",
10128
10160
  id: key,
@@ -10452,7 +10484,7 @@ function decisionFields(item, store, go, today) {
10452
10484
  if (kind === null) return setRevisit(null);
10453
10485
  if (kind === "date") return go.ask("Look again on (today, fri, +28 or 2026-11-02)", (text) => {
10454
10486
  if (!text.trim()) return;
10455
- const day = readDue(text, today);
10487
+ const day = readDue$1(text, today);
10456
10488
  if (day === null) return go.notify("Can't read that date. Try +28 or 2026-11-02.");
10457
10489
  setRevisit(day ? {
10458
10490
  kind: "date",
@@ -18615,13 +18647,13 @@ function milestonePole(milestone, data, now) {
18615
18647
  undated: first.pole.kind === "chain" ? first.pole.undated : 0
18616
18648
  };
18617
18649
  }
18618
- const plural$2 = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
18650
+ const plural$4 = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
18619
18651
  /** A link's slack in a few words: "4 days spare", "no slack", "3 days late for what's next". */
18620
18652
  function slackText(slack, last = false) {
18621
18653
  if (slack == null) return last ? "no date to hold" : "next has no due date";
18622
- if (slack > 0) return `${plural$2(slack, "day")} spare`;
18654
+ if (slack > 0) return `${plural$4(slack, "day")} spare`;
18623
18655
  if (slack === 0) return "no slack";
18624
- return last ? `runs ${plural$2(-slack, "day")} over` : `pushes the next ${plural$2(-slack, "day")}`;
18656
+ return last ? `runs ${plural$4(-slack, "day")} over` : `pushes the next ${plural$4(-slack, "day")}`;
18625
18657
  }
18626
18658
  /**
18627
18659
  * The sentence above the line: what sets the date and how it compares with
@@ -18633,9 +18665,9 @@ function poleHeadline(pole, name, held) {
18633
18665
  const lead = pole.via ? `${pole.via.project.name} finishes ${shortDate(pole.finish)}` : `${last ? name(last.task) : "It"} can finish ${shortDate(pole.finish)}`;
18634
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.` : "";
18635
18667
  if (pole.over == null || !pole.target) return `${lead}.${tail}`;
18636
- if (pole.over > 0) return `${lead}, ${plural$2(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}`;
18637
18669
  if (pole.over === 0) return `${lead}, right on ${held}.${tail}`;
18638
- return `${lead}, ${plural$2(-pole.over, "day")} before ${held}.${tail}`;
18670
+ return `${lead}, ${plural$4(-pole.over, "day")} before ${held}.${tail}`;
18639
18671
  }
18640
18672
  /** "13 open tasks have no due date, so they can't hold the date."; null when none. */
18641
18673
  function undatedText(pole) {
@@ -18780,6 +18812,288 @@ function reviewCsv(data, programs, now) {
18780
18812
  ]));
18781
18813
  }
18782
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
+
18783
19097
  //#endregion
18784
19098
  //#region src/lib/agents.ts
18785
19099
  const isAgent = (p) => p?.kind === "agent";
@@ -18958,14 +19272,17 @@ function weekPlan(data, personId, monday) {
18958
19272
  };
18959
19273
  });
18960
19274
  const placed = new Set(inWeek.map((r) => r.taskId));
18961
- const unplanned = data.tasks.filter((t) => t.assigneeId === personId && t.status !== "done" && !placed.has(t.id)).sort(byDue);
19275
+ const open = data.tasks.filter((t) => t.assigneeId === personId && t.status !== "done" && !placed.has(t.id));
19276
+ const unplanned = open.filter((t) => t.status !== "review").sort(byDue);
19277
+ const inReview = open.filter((t) => t.status === "review").sort(byDue);
18962
19278
  return {
18963
19279
  monday,
18964
19280
  days,
18965
19281
  dayHours,
18966
19282
  planned: days.reduce((sum, d) => sum + d.hours, 0),
18967
19283
  capacity: dayHours * 5,
18968
- unplanned
19284
+ unplanned,
19285
+ inReview
18969
19286
  };
18970
19287
  }
18971
19288
  /** Planned against done for a week: what was on the plan, what got finished, and what came in besides. */
@@ -19304,6 +19621,8 @@ function taskLine(t, people, projects, all) {
19304
19621
  };
19305
19622
  }
19306
19623
  /** One person's week plan as the agent tools give it (get_week, plan_week). */
19624
+ /** Most of the tasks not on a day that get_week lists; the count says how many there are. */
19625
+ const UNPLANNED_MAX = 200;
19307
19626
  function weekOut(data, personId, monday) {
19308
19627
  const people = byId(data.people);
19309
19628
  const projects = byId(data.projects);
@@ -19328,8 +19647,9 @@ function weekOut(data, personId, monday) {
19328
19647
  ...taskLine(item.task, people, projects, data.tasks)
19329
19648
  }))
19330
19649
  })),
19331
- unplanned: plan.unplanned.slice(0, 25).map((t) => taskLine(t, people, projects, data.tasks)),
19650
+ unplanned: plan.unplanned.slice(0, 200).map((t) => taskLine(t, people, projects, data.tasks)),
19332
19651
  unplannedCount: plan.unplanned.length,
19652
+ inReviewCount: plan.inReview.length,
19333
19653
  review: {
19334
19654
  summary: reviewText(review),
19335
19655
  planned: review.planned.length,
@@ -19732,7 +20052,7 @@ const HARBOR_TOOLS = [
19732
20052
  tool({
19733
20053
  name: "get_task",
19734
20054
  title: "Task details",
19735
- 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,
19736
20056
  input: object({
19737
20057
  harbor: harborArg,
19738
20058
  task: string().min(1).max(100).describe("Task id or key (\"RR-42\"), from search_tasks or my_work.")
@@ -19759,6 +20079,8 @@ const HARBOR_TOOLS = [
19759
20079
  body: c.body
19760
20080
  }));
19761
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);
19762
20084
  return {
19763
20085
  id: t.id,
19764
20086
  ...key ? { key } : {},
@@ -19781,6 +20103,12 @@ const HARBOR_TOOLS = [
19781
20103
  waitingOn: blockersOf(t, data.tasks).map(brief),
19782
20104
  waitedOnBy: waiters.map(brief),
19783
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
+ } } : {},
19784
20112
  comments,
19785
20113
  ...webLink(ctx, { task: t.id })
19786
20114
  };
@@ -19801,7 +20129,7 @@ const HARBOR_TOOLS = [
19801
20129
  you: null,
19802
20130
  note: "You aren't one of this harbor's people yet, so nothing is yours."
19803
20131
  };
19804
- const today = todayISO(ctx.now);
20132
+ const today = todayFor(mine, ctx.now, todayISO(ctx.now));
19805
20133
  const people = byId(data.people);
19806
20134
  const projects = byId(data.projects);
19807
20135
  const open = data.tasks.filter((t) => t.assigneeId === mine.id && isOpen(t)).sort((a, b) => (a.dueDate ?? "9999").localeCompare(b.dueDate ?? "9999"));
@@ -20544,6 +20872,83 @@ const HARBOR_TOOLS = [
20544
20872
  };
20545
20873
  }
20546
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
+ }),
20547
20952
  tool({
20548
20953
  name: "handover_plan",
20549
20954
  title: "What handing someone's work over would move",
@@ -20982,6 +21387,7 @@ function reversalOf(before, after) {
20982
21387
  }
20983
21388
  function labelOf(before, after, forward) {
20984
21389
  const lead = LEAD.map((key) => forward.filter((f) => f.key === key)).find((l) => l.length > 0);
21390
+ if (lead && lead.length > 1 && lead.every((f) => f.key === "tasks" && f.change.kind === "set")) return `your change to ${lead.length} tasks`;
20985
21391
  if (!lead || lead.length !== 1) return "your last change";
20986
21392
  const { key, change } = lead[0];
20987
21393
  const find = (doc, id) => (doc[key] ?? []).find((r) => r.id === id);
@@ -22005,6 +22411,61 @@ function stampAssignment(before, next, by, at) {
22005
22411
  }
22006
22412
  var init_assignment = __esmMin((() => {}));
22007
22413
 
22414
+ //#endregion
22415
+ //#region src/lib/bulk.ts
22416
+ /** A task's due date moved by `days`, or null when it has none. */
22417
+ function shiftedDue(due, days) {
22418
+ if (!due) return null;
22419
+ return todayISO(addDays(parseIso(`${due.slice(0, 10)}T12:00:00`), days));
22420
+ }
22421
+ /** The task as the change leaves it, before automations and stamps; the same task when nothing moves. */
22422
+ function applyBulk(task, change) {
22423
+ if ("status" in change) return task.status === change.status ? task : {
22424
+ ...task,
22425
+ status: change.status
22426
+ };
22427
+ if ("assigneeId" in change) return task.assigneeId === change.assigneeId ? task : {
22428
+ ...task,
22429
+ assigneeId: change.assigneeId
22430
+ };
22431
+ if ("dueDate" in change) return task.dueDate === change.dueDate ? task : {
22432
+ ...task,
22433
+ dueDate: change.dueDate
22434
+ };
22435
+ if ("shiftDays" in change) {
22436
+ const due = shiftedDue(task.dueDate, change.shiftDays);
22437
+ return due === null || change.shiftDays === 0 ? task : {
22438
+ ...task,
22439
+ dueDate: due
22440
+ };
22441
+ }
22442
+ return task.priority === change.priority ? task : {
22443
+ ...task,
22444
+ priority: change.priority
22445
+ };
22446
+ }
22447
+ /** What the change did, for the Activity line and the tray's notice: "moved 4 tasks to In play". */
22448
+ function bulkText(change, n, names = {}) {
22449
+ const tasks = plural$2(n);
22450
+ if ("status" in change) return change.status === "done" ? `finished ${tasks}` : `moved ${tasks} to ${TASK_STATUS_LABEL[change.status]}`;
22451
+ if ("assigneeId" in change) return change.assigneeId ? `gave ${tasks} to ${names.person ?? "someone"}` : `unassigned ${tasks}`;
22452
+ if ("dueDate" in change) return change.dueDate ? `set ${tasks} due ${change.dueDate}` : `cleared the due date of ${tasks}`;
22453
+ if ("shiftDays" in change) {
22454
+ const weeks = Math.abs(change.shiftDays) % 7 === 0 ? Math.abs(change.shiftDays) / 7 : null;
22455
+ const by = weeks ? `${weeks} ${weeks === 1 ? "week" : "weeks"}` : `${Math.abs(change.shiftDays)} days`;
22456
+ return `${change.shiftDays > 0 ? "pushed" : "pulled"} ${tasks} ${by} ${change.shiftDays > 0 ? "later" : "earlier"}`;
22457
+ }
22458
+ return `set ${tasks} to ${PRIORITY_LABEL[change.priority].toLowerCase()} priority`;
22459
+ }
22460
+ var plural$2;
22461
+ var init_bulk = __esmMin((() => {
22462
+ init_date_fns();
22463
+ init_date_text();
22464
+ init_format();
22465
+ init_swatches();
22466
+ plural$2 = (n) => `${n} ${n === 1 ? "task" : "tasks"}`;
22467
+ }));
22468
+
22008
22469
  //#endregion
22009
22470
  //#region src/lib/slices/tasks.ts
22010
22471
  function tasksSlice({ set, get, write }) {
@@ -22326,6 +22787,70 @@ function tasksSlice({ set, get, write }) {
22326
22787
  } : t) });
22327
22788
  get().log(kept.length === 1 ? `kept “${kept[0].title}” out of the fog` : `kept ${kept.length} tasks out of the fog`, kept.length === 1 ? `/projects/${kept[0].projectId}` : void 0);
22328
22789
  },
22790
+ editTasks: (ids, change, opts) => {
22791
+ const s = get();
22792
+ const pick = new Set(ids.slice(0, 500));
22793
+ const moving = s.tasks.filter((t) => pick.has(t.id)).filter((t) => applyBulk(t, change) !== t);
22794
+ if (moving.length === 0) return { changed: 0 };
22795
+ if ("status" in change) {
22796
+ if (change.status === "done" && !opts?.allowBlocked) {
22797
+ const blocked = moving.filter((t) => isBlocked$1(t, s.tasks)).length;
22798
+ if (blocked) return {
22799
+ changed: 0,
22800
+ blocked
22801
+ };
22802
+ }
22803
+ if (!opts?.allowOverCap) {
22804
+ const into = /* @__PURE__ */ new Map();
22805
+ for (const t of moving) into.set(t.projectId, (into.get(t.projectId) ?? 0) + 1);
22806
+ for (const [projectId, n] of into) {
22807
+ const overCap = columnWip(s, projectId, change.status, n);
22808
+ if (overCap) return {
22809
+ changed: 0,
22810
+ overCap
22811
+ };
22812
+ }
22813
+ }
22814
+ }
22815
+ const me = s.currentUserId;
22816
+ const at = stamp();
22817
+ const projects = new Map(s.projects.map((p) => [p.id, p]));
22818
+ const newComments = [];
22819
+ const follows = [];
22820
+ const moved = new Set(moving.map((t) => t.id));
22821
+ const tasks = s.tasks.map((row) => {
22822
+ if (!moved.has(row.id)) return row;
22823
+ let next = applyBulk(row, change);
22824
+ if ("status" in change) {
22825
+ next = withStatus(row, change.status);
22826
+ if (change.status === "done" && isBlocked$1(row, s.tasks)) Object.assign(next, blockersPatch([]));
22827
+ }
22828
+ const { task: automated, comments } = applyAutomations(projects.get(row.projectId), row, next, {
22829
+ created: false,
22830
+ currentUserId: me
22831
+ });
22832
+ newComments.push(...comments.map((c) => ({
22833
+ ...c,
22834
+ id: uid("c")
22835
+ })));
22836
+ const final = stampAssignment(row, automated, me, at);
22837
+ if (final.status === "done" && row.status !== "done") {
22838
+ const follow = nextOccurrence(final, uid("t"), todayISO(), at);
22839
+ if (follow) follows.push(follow);
22840
+ }
22841
+ return final;
22842
+ });
22843
+ write({
22844
+ tasks: follows.length ? [...tasks, ...follows] : tasks,
22845
+ comments: newComments.length ? [...s.comments, ...newComments] : s.comments,
22846
+ wipPending: null,
22847
+ blockPending: null
22848
+ });
22849
+ const person = "assigneeId" in change ? s.people.find((p) => p.id === change.assigneeId)?.name : null;
22850
+ const where = new Set(moving.map((t) => t.projectId));
22851
+ get().log(bulkText(change, moving.length, { person }), where.size === 1 ? `/projects/${[...where][0]}` : void 0);
22852
+ return { changed: moving.length };
22853
+ },
22329
22854
  toggleFocus: (taskId) => {
22330
22855
  const personId = get().currentUserId;
22331
22856
  const date = todayISO();
@@ -22429,6 +22954,7 @@ var init_tasks = __esmMin((() => {
22429
22954
  init_shared();
22430
22955
  init_recurrence();
22431
22956
  init_assignment();
22957
+ init_bulk();
22432
22958
  }));
22433
22959
 
22434
22960
  //#endregion
@@ -26811,6 +27337,117 @@ function PortfolioView({ portfolioId, active, width, height, go, onBack }) {
26811
27337
  });
26812
27338
  }
26813
27339
 
27340
+ //#endregion
27341
+ //#region src/tui/bulk.ts
27342
+ init_bulk();
27343
+ init_board();
27344
+ init_swatches();
27345
+ /** "2026-11-04", "today", "tomorrow", "+3", or "none"; undefined when it's none of those. */
27346
+ function readDue(text, today) {
27347
+ const t = text.trim().toLowerCase();
27348
+ if (t === "none" || t === "no date") return null;
27349
+ if (t === "today") return today;
27350
+ if (t === "tomorrow") return shiftedDue(today, 1);
27351
+ if (/^\+\d{1,3}$/.test(t)) return shiftedDue(today, Number(t.slice(1)));
27352
+ if (/^\d{4}-\d{2}-\d{2}$/.test(t)) return t;
27353
+ }
27354
+ function applyPicked(store, go, ids, change, opts) {
27355
+ const s = store.getState();
27356
+ const r = s.editTasks(ids, change, opts);
27357
+ if (r.blocked) return go.confirm(`${r.blocked === 1 ? "One of them still waits" : `${r.blocked} of them still wait`} on other tasks. Mark them all done anyway?`, () => applyPicked(store, go, ids, change, {
27358
+ ...opts,
27359
+ allowBlocked: true
27360
+ }), "Mark done");
27361
+ if (r.overCap) {
27362
+ if (s.myRole !== "owner") return go.notify(wipFullMessage(r.overCap));
27363
+ return go.confirm(`${wipFullMessage(r.overCap)} Move them past the limit?`, () => applyPicked(store, go, ids, change, {
27364
+ ...opts,
27365
+ allowOverCap: true
27366
+ }), "Move anyway");
27367
+ }
27368
+ if (r.changed === 0) return go.notify("Nothing to change: they're all like that already.");
27369
+ const person = "assigneeId" in change ? s.people.find((p) => p.id === change.assigneeId)?.name : null;
27370
+ const text = bulkText(change, r.changed, { person });
27371
+ go.notify(`${text[0].toUpperCase()}${text.slice(1)}. u undoes it.`);
27372
+ }
27373
+ /** What m offers for the picked tasks; `done` runs after a change so the view can let them go. */
27374
+ function bulkMenu(store, go, ids, today, clear) {
27375
+ const run = (change) => applyPicked(store, go, ids, change);
27376
+ const n = ids.length;
27377
+ go.pick(`${n} picked`, [
27378
+ {
27379
+ label: "Move to…",
27380
+ value: "move"
27381
+ },
27382
+ {
27383
+ label: "Assign to…",
27384
+ value: "assign"
27385
+ },
27386
+ {
27387
+ label: "Due date…",
27388
+ value: "due"
27389
+ },
27390
+ {
27391
+ label: "Push a week later",
27392
+ value: "push",
27393
+ hint: "undated ones stay"
27394
+ },
27395
+ {
27396
+ label: "Priority…",
27397
+ value: "priority"
27398
+ },
27399
+ {
27400
+ label: "Mark done",
27401
+ value: "done"
27402
+ },
27403
+ {
27404
+ label: "Let go of them",
27405
+ value: "clear",
27406
+ hint: "esc"
27407
+ }
27408
+ ], (what) => {
27409
+ if (what === "clear") return clear();
27410
+ if (what === "push") return run({ shiftDays: 7 });
27411
+ if (what === "done") return run({ status: "done" });
27412
+ if (what === "move") return go.pick(`Move ${n} to`, TASK_COLUMNS.map((st) => ({
27413
+ label: TASK_STATUS_LABEL[st],
27414
+ value: st
27415
+ })), (status) => run({ status }));
27416
+ if (what === "priority") return go.pick(`Priority for ${n}`, [
27417
+ "urgent",
27418
+ "high",
27419
+ "medium",
27420
+ "low"
27421
+ ].map((p) => ({
27422
+ label: PRIORITY_LABEL[p],
27423
+ value: p
27424
+ })), (priority) => run({ priority }));
27425
+ if (what === "assign") {
27426
+ const s = store.getState();
27427
+ const crew = [...s.people].filter((p) => p.id !== s.currentUserId).sort((a, b) => a.name.localeCompare(b.name));
27428
+ return go.pick(`Give ${n} to`, [
27429
+ {
27430
+ label: "Me",
27431
+ value: s.currentUserId
27432
+ },
27433
+ {
27434
+ label: "Nobody",
27435
+ value: null
27436
+ },
27437
+ ...crew.map((p) => ({
27438
+ label: p.name,
27439
+ value: p.id
27440
+ }))
27441
+ ], (who) => run({ assigneeId: who }));
27442
+ }
27443
+ if (what === "due") return go.ask("Due (YYYY-MM-DD, today, tomorrow, +7 or none)", (text) => {
27444
+ const due = readDue(text, today);
27445
+ if (due === void 0) return go.notify(`"${text}" isn't a date Harbor reads.`);
27446
+ run({ dueDate: due });
27447
+ });
27448
+ });
27449
+ }
27450
+
26814
27451
  //#endregion
26815
27452
  //#region src/tui/views/project.tsx
26816
27453
  init_swatches();
@@ -26864,6 +27501,9 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
26864
27501
  const [col, setCol] = useState(() => Math.max(0, columns.findIndex((c) => c.tasks.length > 0)));
26865
27502
  const [rowByCol, setRowByCol] = useState({});
26866
27503
  const [listAt, setListAt] = useState(0);
27504
+ const [pickedIds, setPicked] = useState([]);
27505
+ const live = useMemo(() => new Set(tasks.map((t) => t.id)), [tasks]);
27506
+ const picked = pickedIds.filter((id) => live.has(id));
26867
27507
  const day = today();
26868
27508
  const fogWeeks = (t) => {
26869
27509
  if (t.status === "done") return null;
@@ -26893,7 +27533,10 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
26893
27533
  }
26894
27534
  };
26895
27535
  useInput((input, key) => {
27536
+ if (key.escape && picked.length) return setPicked([]);
26896
27537
  if (key.escape || key.backspace) return onBack();
27538
+ if (input === " " && selected && mode !== "budget") return setPicked((ids) => ids.includes(selected.id) ? ids.filter((x) => x !== selected.id) : [...ids, selected.id]);
27539
+ if (input === "m" && picked.length && mode !== "budget") return bulkMenu(store, go, picked, day, () => setPicked([]));
26897
27540
  if (input === "v") return setMode((m) => m === "board" ? "list" : m === "list" ? "budget" : "board");
26898
27541
  if (input === "e") return go.openPanel("project", projectId);
26899
27542
  if (input === "b") return go.openPanel("board", projectId);
@@ -27048,6 +27691,16 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
27048
27691
  ]
27049
27692
  }) : null,
27050
27693
  " ",
27694
+ picked.length && mode !== "budget" ? /* @__PURE__ */ jsxs(Text, {
27695
+ color: theme.accent,
27696
+ children: [
27697
+ " ",
27698
+ "· ",
27699
+ picked.length,
27700
+ " picked: m changes them, esc lets go"
27701
+ ]
27702
+ }) : " · space picks",
27703
+ " ",
27051
27704
  "· v next · f p filter · e details · b setup"
27052
27705
  ]
27053
27706
  })
@@ -27182,7 +27835,8 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
27182
27835
  waits: blocked(data, t),
27183
27836
  priority: t.priority,
27184
27837
  selected: on && view.start + i === r,
27185
- fog: fogWeeks(t)
27838
+ fog: fogWeeks(t),
27839
+ picked: picked.includes(t.id)
27186
27840
  }, t.id)),
27187
27841
  view.start + view.items.length < c.tasks.length ? /* @__PURE__ */ jsxs(Text, {
27188
27842
  color: theme.muted,
@@ -27212,6 +27866,7 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
27212
27866
  people,
27213
27867
  today: day,
27214
27868
  selected: active && start + i === listAt,
27869
+ picked: picked.includes(t.id),
27215
27870
  width: width - 12,
27216
27871
  showProject: false,
27217
27872
  showAssignee: true,
@@ -27223,7 +27878,7 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
27223
27878
  });
27224
27879
  }
27225
27880
  /** One card on the board: its key, due date and owner, then the title on up to two lines. */
27226
- function Card({ taskKey, title, owner, due, late, waits, priority, selected, fog = null }) {
27881
+ function Card({ taskKey, title, owner, due, late, waits, priority, selected, fog = null, picked = false }) {
27227
27882
  return /* @__PURE__ */ jsxs(Box, {
27228
27883
  flexDirection: "column",
27229
27884
  height: 3,
@@ -27232,6 +27887,10 @@ function Card({ taskKey, title, owner, due, late, waits, priority, selected, fog
27232
27887
  inverse: selected,
27233
27888
  bold: selected,
27234
27889
  children: [
27890
+ picked ? /* @__PURE__ */ jsx(Text, {
27891
+ color: selected ? void 0 : theme.accent,
27892
+ children: "✓ "
27893
+ }) : null,
27235
27894
  /* @__PURE__ */ jsx(Text, {
27236
27895
  color: selected ? void 0 : theme.muted,
27237
27896
  children: taskKey
@@ -28487,7 +29146,7 @@ function DueField({ initial, today, onSubmit, onCancel }) {
28487
29146
  hint: error ?? "A date, today, tomorrow, +3 for three days on, or none",
28488
29147
  onCancel,
28489
29148
  onSubmit: (text) => {
28490
- const due = readDue(text, today);
29149
+ const due = readDue$1(text, today);
28491
29150
  if (due == null) setError(`“${text}” isn't a date Harbor can read.`);
28492
29151
  else onSubmit(due);
28493
29152
  }
@@ -29228,6 +29887,8 @@ function App({ ui, startIn }) {
29228
29887
  ["n", "new"],
29229
29888
  ["[ ]", "move card"],
29230
29889
  ["x", "done"],
29890
+ ["space", "pick"],
29891
+ ["m", "change picked"],
29231
29892
  ["d", "delete"],
29232
29893
  ["v", "board/list/budget"],
29233
29894
  ["f p", "filter"],
@@ -29591,7 +30252,9 @@ function fogSummary(t) {
29591
30252
 
29592
30253
  //#endregion
29593
30254
  //#region src/tui/watch.ts
30255
+ init_clock();
29594
30256
  init_date_text();
30257
+ init_format();
29595
30258
  init_indexes();
29596
30259
  init_swatches();
29597
30260
  /** Most lines one change prints; a whole reload says how many more there were. */
@@ -29611,14 +30274,17 @@ function watchLines(prev, next, meId) {
29611
30274
  const label = (t) => `${keyOf$1(next, t)} ${t.title}`;
29612
30275
  const seen = new Set(prev.activity.map((a) => a.id));
29613
30276
  const fresh = next.activity.filter((a) => !seen.has(a.id));
30277
+ const logged = (t) => fresh.find((a) => a.text.includes(`“${t.title}”`));
29614
30278
  const who = (t) => {
29615
- const hit = fresh.find((a) => a.text.includes(`“${t.title}”`));
30279
+ const hit = logged(t);
29616
30280
  if (!hit) return null;
29617
30281
  return hit.personId === meId ? "You" : firstName(people.get(hit.personId));
29618
30282
  };
29619
30283
  const said = (t, phrase) => {
29620
- const name = who(t);
29621
- 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;
29622
30288
  };
29623
30289
  for (const t of next.tasks) {
29624
30290
  const was = before.get(t.id);
@@ -29722,7 +30388,7 @@ function recentLines(doc, meId, opts = {}) {
29722
30388
  return (opts.since ? rows : rows.slice(-(opts.limit ?? 5))).map((a) => ({
29723
30389
  at: a.at,
29724
30390
  line: {
29725
- 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}`,
29726
30392
  mine: false,
29727
30393
  tone: "plain"
29728
30394
  }
@@ -29731,20 +30397,36 @@ function recentLines(doc, meId, opts = {}) {
29731
30397
  /**
29732
30398
  * `--since`: "14:30" (today, on this computer's clock) or a date or moment
29733
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").
29734
30401
  */
29735
30402
  function watchSince(text, now) {
29736
- 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);
29737
30407
  if (clock) {
29738
30408
  const [h, m] = [Number(clock[1]), Number(clock[2])];
29739
30409
  if (h > 23 || m > 59) return null;
29740
30410
  const at = new Date(now);
29741
- at.setHours(h, m, 0, 0);
30411
+ if (utc) at.setUTCHours(h, m, 0, 0);
30412
+ else at.setHours(h, m, 0, 0);
29742
30413
  return at.toISOString();
29743
30414
  }
29744
- if (!/^\d{4}-\d{2}-\d{2}/.test(text.trim())) return null;
29745
- 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);
29746
30417
  return Number.isNaN(at.getTime()) ? null : at.toISOString();
29747
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
+ }
29748
30430
 
29749
30431
  //#endregion
29750
30432
  //#region src/tui/watch-run.ts
@@ -29809,9 +30491,10 @@ async function watchOn(opts) {
29809
30491
  projectId: project?.id ?? null
29810
30492
  });
29811
30493
  if (!opts.json) {
29812
- 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`));
29813
- if (earlier.length === 0) out(dim(opts.since ? "Nothing changed since then." : "Nothing logged yet."));
29814
- 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:"));
29815
30498
  }
29816
30499
  for (const e of earlier) print(e.line, new Date(e.at), true);
29817
30500
  if (opts.once) return;