@harborppm/cli 0.62.0 → 0.64.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/app.mjs CHANGED
@@ -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
@@ -3247,6 +3275,13 @@ function barnaclesOf(project, tasks, people, now, slip = null) {
3247
3275
  const weekly = weeklyFinished(tasks, project.startDate, now);
3248
3276
  const finished = weekly.reduce((a, b) => a + b, 0);
3249
3277
  const perWeek = finished > 0 ? Math.round(finished / weekly.length * 10) / 10 : null;
3278
+ const byDay = /* @__PURE__ */ new Map();
3279
+ for (const t of added) byDay.set(t.createdAt.slice(0, 10), (byDay.get(t.createdAt.slice(0, 10)) ?? 0) + 1);
3280
+ const [bulkDay, bulk] = [...byDay].sort((a, b) => b[1] - a[1])[0] ?? [null, 0];
3281
+ const age = differenceInCalendarDays(noon$2(today), addDays(noon$2(since), -7)) / 7;
3282
+ let caveat = null;
3283
+ if (added.length && bulkDay && bulk >= 5 && bulk >= added.length * .5) caveat = `${bulk} of the ${added.length} came in on ${shortDate(bulkDay)}, which looks like the plan being loaded rather than growth, so treat this as rough. Setting a baseline counts from then on.`;
3284
+ else if (added.length && from === "start" && (planned < 5 || age < 3)) caveat = "There's no baseline yet and the plan is young, so its first weeks may be the plan being written: treat this as rough. Setting a baseline counts from then on.";
3250
3285
  return {
3251
3286
  since,
3252
3287
  from,
@@ -3258,7 +3293,8 @@ function barnaclesOf(project, tasks, people, now, slip = null) {
3258
3293
  adders,
3259
3294
  perWeek,
3260
3295
  days: perWeek ? Math.round(added.length / perWeek * 7) : null,
3261
- slip
3296
+ slip,
3297
+ caveat
3262
3298
  };
3263
3299
  }
3264
3300
  /**
@@ -3277,42 +3313,46 @@ function slipAgainstPlan(project, tasks, now, tide = tideForecast(project, tasks
3277
3313
  function barnaclesHeadline(b) {
3278
3314
  if (b.added === 0) return null;
3279
3315
  const when = b.from === "baseline" ? "since the baseline" : "since its first week";
3280
- const pct = b.share != null ? `, ${Math.round(b.share * 100)}% more than planned` : "";
3281
- return `+${plural$6(b.added, "task")} ${when}${pct}`;
3316
+ const pct = b.share != null && !b.caveat ? `, ${Math.round(b.share * 100)}% more than planned` : "";
3317
+ return `+${plural$7(b.added, "task")} ${when}${pct}`;
3282
3318
  }
3283
3319
  /** What the added work costs in days, and how much of the slip it explains. */
3284
3320
  function barnaclesDaysLine(b) {
3285
- 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`;
3321
+ if (b.added === 0 || b.days == null || b.perWeek == null || b.caveat) return null;
3322
+ const work = `At the crew's pace (about ${b.perWeek} a week) that's about ${plural$7(b.days, "day")} of work`;
3287
3323
  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.`;
3324
+ if (b.days >= b.slip) return `${work}: enough to explain all ${plural$7(b.slip, "day")} of the slip.`;
3325
+ return `${work}: about ${b.days} of the ${plural$7(b.slip, "day")} it has slipped.`;
3290
3326
  }
3291
3327
  /** One line for the brief or a list: "+14 tasks (38%) since the baseline, 3 this week, about 65 days of work". */
3292
3328
  function barnaclesShort(b) {
3293
- const pct = b.share != null ? ` (${Math.round(b.share * 100)}%)` : "";
3329
+ const pct = b.share != null && !b.caveat ? ` (${Math.round(b.share * 100)}%)` : "";
3294
3330
  const when = b.from === "baseline" ? "since the baseline" : "since its first week";
3295
3331
  const week = b.thisWeek ? `, ${b.thisWeek} this week` : "";
3296
- const days = b.days != null ? `, about ${plural$6(b.days, "day")} of work` : "";
3332
+ const days = b.days != null && !b.caveat ? `, about ${plural$7(b.days, "day")} of work` : "";
3297
3333
  const top = b.adders.find((a) => a.personId);
3298
3334
  const who = top && top.added * 2 > b.added ? `, mostly from ${top.name}` : "";
3299
- return `+${plural$6(b.added, "task")}${pct} ${when}${week}${days}${who}`;
3335
+ return `+${plural$7(b.added, "task")}${pct} ${when}${week}${days}${who}`;
3300
3336
  }
3301
3337
  /** Whether the brief names it: measured from a baseline, enough added, and enough of the plan. */
3302
3338
  function briefWorthy(b) {
3303
- return b.from === "baseline" && b.added >= 3 && (b.share == null || b.share >= .15);
3339
+ return b.from === "baseline" && !b.caveat && b.added >= 3 && (b.share == null || b.share >= .15);
3304
3340
  }
3305
- var SETTLE_DAYS, BRIEF_MIN_ADDED, BRIEF_MIN_SHARE, noon$2, isoDay$3, plural$6;
3341
+ var SETTLE_DAYS, THIN_PLANNED, THIN_WEEKS, BULK_SHARE, BULK_MIN, BRIEF_MIN_ADDED, BRIEF_MIN_SHARE, noon$2, isoDay$3, plural$7;
3306
3342
  var init_barnacles = __esmMin((() => {
3307
3343
  init_date_fns();
3308
3344
  init_format();
3309
3345
  init_tide();
3310
3346
  SETTLE_DAYS = 7;
3347
+ THIN_PLANNED = 5;
3348
+ THIN_WEEKS = 3;
3349
+ BULK_SHARE = .5;
3350
+ BULK_MIN = 5;
3311
3351
  BRIEF_MIN_ADDED = 3;
3312
3352
  BRIEF_MIN_SHARE = .15;
3313
3353
  noon$2 = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
3314
3354
  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"}`;
3355
+ plural$7 = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
3316
3356
  }));
3317
3357
 
3318
3358
  //#endregion
@@ -3330,7 +3370,7 @@ function projectKeyFor(name, taken) {
3330
3370
  for (let n = 2;; n++) if (!taken.has(`${base}${n}`)) return `${base}${n}`;
3331
3371
  }
3332
3372
  /** Whether `a` has the better claim to the number both hold. */
3333
- function first$2(a, b, stored) {
3373
+ function first$3(a, b, stored) {
3334
3374
  const ka = kept(a, stored);
3335
3375
  if (ka !== kept(b, stored)) return ka;
3336
3376
  return made(a, b) <= 0;
@@ -3378,7 +3418,7 @@ function settleKeys(data, stored) {
3378
3418
  }
3379
3419
  const other = holder.get(n);
3380
3420
  if (other) {
3381
- const [keep, move] = first$2(other, t, stored) ? [other, t] : [t, other];
3421
+ const [keep, move] = first$3(other, t, stored) ? [other, t] : [t, other];
3382
3422
  holder.set(n, keep);
3383
3423
  loose.push(move);
3384
3424
  } else holder.set(n, t);
@@ -3542,8 +3582,8 @@ function thanksSince(doc, since, keep = () => true) {
3542
3582
  if (!target || !keep(target)) continue;
3543
3583
  out.push({
3544
3584
  thanks: r,
3545
- from: first$1(doc, r.authorId),
3546
- to: first$1(doc, r.toId),
3585
+ from: first$2(doc, r.authorId),
3586
+ to: first$2(doc, r.toId),
3547
3587
  for: target.title
3548
3588
  });
3549
3589
  }
@@ -3553,12 +3593,12 @@ function thanksSince(doc, since, keep = () => true) {
3553
3593
  function thanksText(line) {
3554
3594
  return `${line.from} to ${line.to} for ${line.for}: “${line.thanks.body.trim()}”`;
3555
3595
  }
3556
- var isThanks, first$1;
3596
+ var isThanks, first$2;
3557
3597
  var init_thanks = __esmMin((() => {
3558
3598
  init_indexes();
3559
3599
  init_task_keys();
3560
3600
  isThanks = (r) => r.kind === "thanks" && !!r.thanksFor && !!r.toId;
3561
- first$1 = (doc, id) => doc.people.find((p) => p.id === id)?.name.split(" ")[0] ?? "Someone";
3601
+ first$2 = (doc, id) => doc.people.find((p) => p.id === id)?.name.split(" ")[0] ?? "Someone";
3562
3602
  }));
3563
3603
 
3564
3604
  //#endregion
@@ -3644,9 +3684,13 @@ function charterOverdue(project, today) {
3644
3684
  const days = differenceInCalendarDays(parseIso(today), parseIso(project.startDate.slice(0, 10)));
3645
3685
  return days >= 7 ? days : null;
3646
3686
  }
3647
- /** The items of an In or Out line: split on commas, semicolons, "and" lists and new lines. */
3687
+ /**
3688
+ * The items of an In or Out line: split on commas, semicolons, "and" lists and
3689
+ * new lines. A line that starts with a bullet (-, * or •) is one item whole,
3690
+ * commas and all, which is how `harbor charter --in A --in B` writes them.
3691
+ */
3648
3692
  function charterItems(text) {
3649
- return (text ?? "").split(/[\n,;•]+/).map((s) => s.replace(/^\s*(?:and|or|-|\*)\s+/i, "").trim()).filter((s) => contentWords(s).size > 0);
3693
+ return (text ?? "").split(/\n+/).flatMap((line) => /^\s*[-*•]\s+/.test(line) ? [line] : line.split(/[,;•]+/)).map((s) => s.replace(/^\s*(?:and|or|-|\*|•)\s+/i, "").trim()).filter((s) => contentWords(s).size > 0);
3650
3694
  }
3651
3695
  /** True when a task's title names an item: every meaningful word of a short item, or two of a longer one. */
3652
3696
  function names(item, title) {
@@ -3718,8 +3762,8 @@ init_date_fns();
3718
3762
  init_blockers();
3719
3763
  init_date_text();
3720
3764
  init_premortem();
3721
- const day$6 = (iso) => parseIso(iso.slice(0, 10));
3722
- const between = (a, b) => differenceInCalendarDays(day$6(b), day$6(a));
3765
+ const day$8 = (iso) => parseIso(iso.slice(0, 10));
3766
+ const between = (a, b) => differenceInCalendarDays(day$8(b), day$8(a));
3723
3767
  /** When the project was marked Done: its last move to Done in its health history. */
3724
3768
  function finishedOn(project, tasks) {
3725
3769
  const marked = [...project.healthHistory ?? []].reverse().find((h) => h.health === "done");
@@ -3791,8 +3835,8 @@ function voyageLog(data, projectId) {
3791
3835
  thanks
3792
3836
  };
3793
3837
  }
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");
3838
+ const plural$6 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
3839
+ const md$2 = (iso) => format(day$8(iso), "MMM d");
3796
3840
  /** The three numbers at the top: how late, how much work, how many risks came true. */
3797
3841
  function voyageStats(log) {
3798
3842
  const happened = log.risks.filter((r) => r.happened).length;
@@ -3806,10 +3850,10 @@ function voyageStats(log) {
3806
3850
  value: "On time",
3807
3851
  label: "on the planned day"
3808
3852
  } : days > 0 ? {
3809
- value: `+${plural$5(days, "day")}`,
3853
+ value: `+${plural$6(days, "day")}`,
3810
3854
  label: `past the ${plan}`
3811
3855
  } : {
3812
- value: plural$5(-days, "day"),
3856
+ value: plural$6(-days, "day"),
3813
3857
  label: `ahead of the ${plan}`
3814
3858
  },
3815
3859
  {
@@ -3835,7 +3879,7 @@ function voyageStory(log) {
3835
3879
  out.push(`Barnacles: ${grew}${by}.${days ? ` ${days}` : ""}`);
3836
3880
  }
3837
3881
  for (const s of log.slips) {
3838
- const late = `“${s.task.title}” finished ${plural$5(s.daysLate, "day")} late`;
3882
+ const late = `“${s.task.title}” finished ${plural$6(s.daysLate, "day")} late`;
3839
3883
  out.push(s.waitedOn ? `${late}; it waited on “${s.waitedOn.title}”, done ${md$2(s.waitedOn.completedAt)}.` : `${late}.`);
3840
3884
  }
3841
3885
  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 +3887,11 @@ function voyageStory(log) {
3843
3887
  if (against) out.push(against);
3844
3888
  if (log.decisions.length) {
3845
3889
  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` : ""}.`);
3890
+ out.push(`${plural$6(log.decisions.length, "decision was", "decisions were")} written down${changed ? `; ${changed === 1 ? "one was" : `${changed} were`} changed later` : ""}.`);
3847
3891
  }
3848
3892
  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")}.`);
3893
+ if (top && log.carriers.length > 1) out.push(`${top.person.name} closed ${plural$6(top.closed, "task")}, the most on the crew.`);
3894
+ else if (top) out.push(`${top.person.name} closed ${plural$6(top.closed, "task")}.`);
3851
3895
  if (log.budget > 0) {
3852
3896
  const pct = Math.round(log.spent / log.budget * 100);
3853
3897
  out.push(`Spent ${pct}% of the budget.`);
@@ -4886,7 +4930,7 @@ var init_cascade = __esmMin((() => {
4886
4930
  //#region src/lib/forecast.ts
4887
4931
  init_cascade();
4888
4932
  init_date_fns();
4889
- const day$5 = (iso) => /* @__PURE__ */ new Date(`${iso}T12:00:00`);
4933
+ const day$7 = (iso) => /* @__PURE__ */ new Date(`${iso}T12:00:00`);
4890
4934
  /**
4891
4935
  * A straight-line estimate at completion from the burn so far: spend divided
4892
4936
  * by the share of the schedule elapsed. Null before anything is spent or
@@ -4895,8 +4939,8 @@ const day$5 = (iso) => /* @__PURE__ */ new Date(`${iso}T12:00:00`);
4895
4939
  */
4896
4940
  function forecastAtCompletion(item, now) {
4897
4941
  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;
4942
+ const total = differenceInCalendarDays(day$7(item.endDate), day$7(item.startDate)) + 1;
4943
+ const gone = differenceInCalendarDays(now, day$7(item.startDate)) + 1;
4900
4944
  if (total <= 0 || gone <= 0) return null;
4901
4945
  const elapsed = Math.min(1, gone / total);
4902
4946
  const eac = Math.round(item.spent / elapsed);
@@ -5019,7 +5063,7 @@ var init_flow = __esmMin((() => {
5019
5063
  //#region src/lib/phasing.ts
5020
5064
  init_flow();
5021
5065
  init_date_fns();
5022
- const day$4 = (iso) => /* @__PURE__ */ new Date(`${iso}T12:00:00`);
5066
+ const day$6 = (iso) => /* @__PURE__ */ new Date(`${iso}T12:00:00`);
5023
5067
  function monthKey(iso) {
5024
5068
  return iso.slice(0, 7);
5025
5069
  }
@@ -5035,7 +5079,7 @@ function monthsBetween(from, to) {
5035
5079
  return out;
5036
5080
  }
5037
5081
  function daysIn(key) {
5038
- return getDaysInMonth(day$4(`${key}-01`));
5082
+ return getDaysInMonth(day$6(`${key}-01`));
5039
5083
  }
5040
5084
  /** How many of the schedule's days fall in `key`. */
5041
5085
  function overlapDays(key, start, end) {
@@ -5043,7 +5087,7 @@ function overlapDays(key, start, end) {
5043
5087
  const lastDay = `${key}-${String(daysIn(key)).padStart(2, "0")}`;
5044
5088
  const from = start > first ? start : first;
5045
5089
  const to = end < lastDay ? end : lastDay;
5046
- return to < from ? 0 : differenceInCalendarDays(day$4(to), day$4(from)) + 1;
5090
+ return to < from ? 0 : differenceInCalendarDays(day$6(to), day$6(from)) + 1;
5047
5091
  }
5048
5092
  /**
5049
5093
  * The budget spread straight across the schedule: each month gets the share
@@ -5053,7 +5097,7 @@ function overlapDays(key, start, end) {
5053
5097
  function evenPhasing(project) {
5054
5098
  const end = project.endDate < project.startDate ? project.startDate : project.endDate;
5055
5099
  const months = monthsBetween(project.startDate, end);
5056
- const total = differenceInCalendarDays(day$4(end), day$4(project.startDate)) + 1;
5100
+ const total = differenceInCalendarDays(day$6(end), day$6(project.startDate)) + 1;
5057
5101
  const out = {};
5058
5102
  let placed = 0;
5059
5103
  months.forEach((k, i) => {
@@ -5163,7 +5207,7 @@ function hasBaseline(p) {
5163
5207
  /** Days the end has moved past the baseline end; negative when it came in. Null without a baseline. */
5164
5208
  function slipDays(p) {
5165
5209
  if (!p.baselineEnd) return null;
5166
- return differenceInCalendarDays(day$3(p.endDate), day$3(p.baselineEnd));
5210
+ return differenceInCalendarDays(day$5(p.endDate), day$5(p.baselineEnd));
5167
5211
  }
5168
5212
  /**
5169
5213
  * The projects with the current dates committed as their baseline; ones that
@@ -5185,10 +5229,10 @@ function slipLabel(p) {
5185
5229
  const n = Math.abs(slip);
5186
5230
  return slip > 0 ? `slipped ${n} day${n === 1 ? "" : "s"}` : `${n} day${n === 1 ? "" : "s"} early`;
5187
5231
  }
5188
- var day$3;
5232
+ var day$5;
5189
5233
  var init_baseline = __esmMin((() => {
5190
5234
  init_date_fns();
5191
- day$3 = (iso) => /* @__PURE__ */ new Date(`${iso}T12:00:00`);
5235
+ day$5 = (iso) => /* @__PURE__ */ new Date(`${iso}T12:00:00`);
5192
5236
  }));
5193
5237
 
5194
5238
  //#endregion
@@ -5217,7 +5261,7 @@ const ZOOMS = [
5217
5261
  step: 3
5218
5262
  }
5219
5263
  ];
5220
- const day$2 = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
5264
+ const day$4 = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
5221
5265
  /** Names in the order people expect: "Phase 2" before "Phase 10". */
5222
5266
  const byName = (a, b) => a.localeCompare(b, void 0, { numeric: true });
5223
5267
  const isoDay$2 = (d) => format(d, "yyyy-MM-dd");
@@ -5227,7 +5271,7 @@ const isoDay$2 = (d) => format(d, "yyyy-MM-dd");
5227
5271
  */
5228
5272
  function roadmapWindow(zoom, today, shift = 0) {
5229
5273
  const z = ZOOMS.find((x) => x.id === zoom) ?? ZOOMS[1];
5230
- const first = addMonths$1(startOfMonth(day$2(today)), -1 + shift * z.step);
5274
+ const first = addMonths$1(startOfMonth(day$4(today)), -1 + shift * z.step);
5231
5275
  const after = addMonths$1(first, z.months);
5232
5276
  const days = differenceInCalendarDays(after, first);
5233
5277
  const months = Array.from({ length: z.months }, (_, i) => {
@@ -5251,15 +5295,15 @@ function roadmapWindow(zoom, today, shift = 0) {
5251
5295
  function at$1(iso, w) {
5252
5296
  if (!iso) return null;
5253
5297
  if (iso < w.start || iso >= w.end) return null;
5254
- return differenceInCalendarDays(day$2(iso), day$2(w.start)) / w.days;
5298
+ return differenceInCalendarDays(day$4(iso), day$4(w.start)) / w.days;
5255
5299
  }
5256
5300
  /** A bar for a start–end span, clipped to the window; null when it's wholly outside. */
5257
5301
  function span(start, end, w) {
5258
5302
  if (!start || !end || end < w.start || start >= w.end) return null;
5259
5303
  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;
5304
+ const until = end >= w.end ? w.end : isoDay$2(new Date(day$4(end).getTime() + 864e5));
5305
+ const left = differenceInCalendarDays(day$4(from), day$4(w.start)) / w.days;
5306
+ const right = differenceInCalendarDays(day$4(until), day$4(w.start)) / w.days;
5263
5307
  return {
5264
5308
  left,
5265
5309
  width: Math.max(right - left, 1 / w.days),
@@ -6981,7 +7025,7 @@ function bottleState(note, ctx) {
6981
7025
  /** "opens on Nov 14", "opens when Factory buy-off is reached", "opens if Ridge Runner launch turns Off track" */
6982
7026
  function opensText(opens, ctx) {
6983
7027
  if (!opens) return "open now";
6984
- if (opens.on === "date") return `opens on ${day$1(opens.date)}`;
7028
+ if (opens.on === "date") return `opens on ${day$3(opens.date)}`;
6985
7029
  if (opens.on === "milestone") {
6986
7030
  const m = ctx.milestones.find((x) => x.id === opens.milestoneId);
6987
7031
  return m ? `opens when ${m.name} is reached` : "opens when its milestone is reached";
@@ -7015,7 +7059,7 @@ function freshlyOpened(data, projects, now) {
7015
7059
  }
7016
7060
  return out.sort((a, b) => (b.state.openedOn ?? "").localeCompare(a.state.openedOn ?? ""));
7017
7061
  }
7018
- var BOTTLE_FRESH_DAYS, isBottle, sealed, day$1;
7062
+ var BOTTLE_FRESH_DAYS, isBottle, sealed, day$3;
7019
7063
  var init_bottle = __esmMin((() => {
7020
7064
  init_date_fns();
7021
7065
  init_date_text();
@@ -7027,7 +7071,7 @@ var init_bottle = __esmMin((() => {
7027
7071
  open: false,
7028
7072
  openedOn: null
7029
7073
  };
7030
- day$1 = (iso) => format(parseIso(iso.slice(0, 10)), "MMM d");
7074
+ day$3 = (iso) => format(parseIso(iso.slice(0, 10)), "MMM d");
7031
7075
  }));
7032
7076
 
7033
7077
  //#endregion
@@ -7307,7 +7351,7 @@ function composeBriefBody(opts) {
7307
7351
  }
7308
7352
  return lines.join("\n").trim();
7309
7353
  }
7310
- function firstName$2(person) {
7354
+ function firstName$4(person) {
7311
7355
  return person?.name.split(/\s+/)[0] ?? "Someone";
7312
7356
  }
7313
7357
  function similar(a, b) {
@@ -7345,7 +7389,7 @@ function headlineOf(drums, flow) {
7345
7389
  }
7346
7390
  if (idle.length === 1) {
7347
7391
  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.`;
7392
+ 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
7393
  }
7350
7394
  if (idle.length > 1) return `${idle.length} constraints are idle. The work is not at the constraint.`;
7351
7395
  if (system && flow.impliedDays != null && flow.impliedDays > 21) return `At this load, ${formatWait(flow.impliedDays)}. ${system.constraint.title}.`;
@@ -7409,7 +7453,7 @@ function briefOf(data, opts = {}) {
7409
7453
  for (const d of drums.filter((x) => x.idle)) push({
7410
7454
  id: `idle-${d.constraint.id}`,
7411
7455
  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.`,
7456
+ 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
7457
  href: `/programs/${d.program.id}`
7414
7458
  });
7415
7459
  for (const d of drums.filter((x) => x.constraint.kind === "collision" && !x.idle)) push({
@@ -7953,11 +7997,11 @@ function bridgeSince(program, now) {
7953
7997
  return addDays(now, -7).toISOString();
7954
7998
  }
7955
7999
  const after = (at, since) => !!at && !Number.isNaN(Date.parse(at)) && Date.parse(at) > Date.parse(since);
7956
- const first = (name) => name?.split(" ")[0] ?? null;
8000
+ const first$1 = (name) => name?.split(" ")[0] ?? null;
7957
8001
  /** "RR-4 Draft the copy (Sam)", with the key when it has one. */
7958
8002
  function taskLine$1(t, project, doc) {
7959
8003
  const key = taskKey(t, project);
7960
- const who = t.assigneeId ? first(byId(doc.people).get(t.assigneeId)?.name) : null;
8004
+ const who = t.assigneeId ? first$1(byId(doc.people).get(t.assigneeId)?.name) : null;
7961
8005
  return `${key ? `${key} ` : ""}${t.title}${who ? ` (${who})` : ""}`;
7962
8006
  }
7963
8007
  function listed(prefix, items) {
@@ -8113,7 +8157,7 @@ function meetingSummary(doc, slides, logged) {
8113
8157
  const project = t ? projects.get(t.projectId) : void 0;
8114
8158
  const program = project ? doc.programs.find((p) => p.id === project.programId) : void 0;
8115
8159
  if (!t || !project || !program) continue;
8116
- const who = t.assigneeId ? first(people.get(t.assigneeId)?.name) : null;
8160
+ const who = t.assigneeId ? first$1(people.get(t.assigneeId)?.name) : null;
8117
8161
  const key = taskKey(t, project);
8118
8162
  partOf(program).lines.push({
8119
8163
  kind: "action",
@@ -9049,11 +9093,11 @@ function dollars(value) {
9049
9093
  const n = Number(value ?? 0);
9050
9094
  return whole.format(Number.isFinite(n) ? n : 0);
9051
9095
  }
9052
- function day(value) {
9096
+ function day$2(value) {
9053
9097
  return value ? shortDate(value) : "no date";
9054
9098
  }
9055
9099
  /** Whole days from one ISO day to another. */
9056
- function daysBetween(from, to) {
9100
+ function daysBetween$1(from, to) {
9057
9101
  const a = Date.parse(`${from.slice(0, 10)}T12:00:00Z`);
9058
9102
  const b = Date.parse(`${to.slice(0, 10)}T12:00:00Z`);
9059
9103
  if (Number.isNaN(a) || Number.isNaN(b)) return 0;
@@ -9061,7 +9105,7 @@ function daysBetween(from, to) {
9061
9105
  }
9062
9106
  function slip(from, to) {
9063
9107
  if (!from || !to) return "";
9064
- const d = daysBetween(from, to);
9108
+ const d = daysBetween$1(from, to);
9065
9109
  if (d === 0) return "";
9066
9110
  const n = Math.abs(d);
9067
9111
  return ` (${n} day${n === 1 ? "" : "s"} ${d > 0 ? "later" : "earlier"})`;
@@ -9129,15 +9173,15 @@ function describeEvent(e, names) {
9129
9173
  };
9130
9174
  case "dueDate": return {
9131
9175
  action: "moved the due date of",
9132
- detail: `from ${day(from)} to ${day(to)}${slip(from, to)}`
9176
+ detail: `from ${day$2(from)} to ${day$2(to)}${slip(from, to)}`
9133
9177
  };
9134
9178
  case "startDate": return {
9135
9179
  action: "moved the start of",
9136
- detail: `from ${day(from)} to ${day(to)}${slip(from, to)}`
9180
+ detail: `from ${day$2(from)} to ${day$2(to)}${slip(from, to)}`
9137
9181
  };
9138
9182
  case "endDate": return {
9139
9183
  action: "moved the end of",
9140
- detail: `from ${day(from)} to ${day(to)}${slip(from, to)}`
9184
+ detail: `from ${day$2(from)} to ${day$2(to)}${slip(from, to)}`
9141
9185
  };
9142
9186
  case "budget": return {
9143
9187
  action: "changed the budget of",
@@ -9441,7 +9485,7 @@ const PING_KIND_LABEL = {
9441
9485
  idle: "Quiet blockers",
9442
9486
  mention: "Mentions"
9443
9487
  };
9444
- function firstName$1(name) {
9488
+ function firstName$3(name) {
9445
9489
  return name.split(/\s+/)[0] ?? name;
9446
9490
  }
9447
9491
  /** FNV-1a over the text, as eight hex digits: a short, stable name for a set of ids. */
@@ -9480,7 +9524,7 @@ function inboxOf(data, personId, now = clockNow()) {
9480
9524
  pings.push({
9481
9525
  id: `assigned:${task.id}:${task.assignedAt}`,
9482
9526
  kind: "assigned",
9483
- title: by ? `${firstName$1(by.name)} gave you a task` : "You have a new task",
9527
+ title: by ? `${firstName$3(by.name)} gave you a task` : "You have a new task",
9484
9528
  detail: `“${task.title}”${project ? ` · ${project.name}` : ""}`,
9485
9529
  at: task.assignedAt,
9486
9530
  taskId: task.id,
@@ -9494,7 +9538,7 @@ function inboxOf(data, personId, now = clockNow()) {
9494
9538
  const project = projectOf(data, blocker);
9495
9539
  const names = [...new Set(waiting.map((t) => {
9496
9540
  const who = data.people.find((p) => p.id === t.assigneeId);
9497
- return who ? firstName$1(who.name) : "Someone";
9541
+ return who ? firstName$3(who.name) : "Someone";
9498
9542
  }))];
9499
9543
  const blocked = waiting.length === 1 ? `“${waiting[0].title}”` : `${waiting.length} tasks`;
9500
9544
  const upstream = openBlockerNames(blocker, data.tasks);
@@ -9529,7 +9573,7 @@ function inboxOf(data, personId, now = clockNow()) {
9529
9573
  pings.push({
9530
9574
  id: `changes:${m.id}:${shortHash(note)}`,
9531
9575
  kind: "approval",
9532
- title: `${approver ? firstName$1(approver.name) : "The approver"} asked for changes to ${m.name}`,
9576
+ title: `${approver ? firstName$3(approver.name) : "The approver"} asked for changes to ${m.name}`,
9533
9577
  detail: note ? `${where}${excerpt(note)}` : program?.name ?? "",
9534
9578
  at: m.dueDate,
9535
9579
  programId: m.programId,
@@ -9575,7 +9619,7 @@ function inboxOf(data, personId, now = clockNow()) {
9575
9619
  pings.push({
9576
9620
  id: `sounding:${task.id}:${s.askedAt}`,
9577
9621
  kind: "sounding",
9578
- title: `${by ? firstName$1(by.name) : "Someone"} asked how long a task will take`,
9622
+ title: `${by ? firstName$3(by.name) : "Someone"} asked how long a task will take`,
9579
9623
  detail: `“${task.title}”${project ? ` · ${project.name}` : ""} · answer alone; the guesses show together`,
9580
9624
  at: s.askedAt,
9581
9625
  taskId: task.id,
@@ -9592,7 +9636,7 @@ function inboxOf(data, personId, now = clockNow()) {
9592
9636
  pings.push({
9593
9637
  id: `mention:${comment.id}`,
9594
9638
  kind: "mention",
9595
- title: author ? `${firstName$1(author.name)} mentioned you` : "You were mentioned",
9639
+ title: author ? `${firstName$3(author.name)} mentioned you` : "You were mentioned",
9596
9640
  detail: snippet ? `on “${task.title}”${project ? ` · ${project.name}` : ""} — ${snippet}` : `on “${task.title}”${project ? ` · ${project.name}` : ""}`,
9597
9641
  at: comment.createdAt,
9598
9642
  taskId: task.id,
@@ -10019,7 +10063,7 @@ function byTask(rows, who) {
10019
10063
  }
10020
10064
  return out;
10021
10065
  }
10022
- const plural$4 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
10066
+ const plural$5 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
10023
10067
  /** Work only one person has touched, soonest-needed first. `scope` narrows it to some programs. */
10024
10068
  function lifeboats(data, now, opts = {}) {
10025
10069
  const today = isoDay(now);
@@ -10057,7 +10101,7 @@ function lifeboats(data, now, opts = {}) {
10057
10101
  kind: "project",
10058
10102
  id: project.id,
10059
10103
  label: project.name,
10060
- detail: `${plural$4(open.length, "open task")}, nobody else on them`,
10104
+ detail: `${plural$5(open.length, "open task")}, nobody else on them`,
10061
10105
  soloId: solo[0],
10062
10106
  projectId: project.id,
10063
10107
  programId: project.programId,
@@ -10079,7 +10123,7 @@ function lifeboats(data, now, opts = {}) {
10079
10123
  out.push({
10080
10124
  kind: "tasks",
10081
10125
  id: `tasks:${project.id}:${soloId}`,
10082
- label: `${plural$4(list.length, "task")} on ${project.name}`,
10126
+ label: `${plural$5(list.length, "task")} on ${project.name}`,
10083
10127
  detail: `only ${first} has touched them`,
10084
10128
  soloId,
10085
10129
  projectId: project.id,
@@ -10099,7 +10143,7 @@ function lifeboats(data, now, opts = {}) {
10099
10143
  kind: "task",
10100
10144
  id: t.id,
10101
10145
  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(", "),
10146
+ detail: [d < today ? `late since ${md(d)}` : `due ${md(d)}`, holds ? `${plural$5(holds, "task")} ${holds === 1 ? "waits" : "wait"} on it` : "urgent"].join(", "),
10103
10147
  soloId,
10104
10148
  projectId: project.id,
10105
10149
  programId: project.programId,
@@ -10126,7 +10170,7 @@ function lifeboats(data, now, opts = {}) {
10126
10170
  const programId = first.programId ?? project?.programId ?? null;
10127
10171
  const where = project?.name ?? (programId ? programs.get(programId)?.name : null) ?? null;
10128
10172
  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")}`;
10173
+ 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
10174
  out.push({
10131
10175
  kind: "risks",
10132
10176
  id: key,
@@ -18619,13 +18663,13 @@ function milestonePole(milestone, data, now) {
18619
18663
  undated: first.pole.kind === "chain" ? first.pole.undated : 0
18620
18664
  };
18621
18665
  }
18622
- const plural$3 = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
18666
+ const plural$4 = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
18623
18667
  /** A link's slack in a few words: "4 days spare", "no slack", "3 days late for what's next". */
18624
18668
  function slackText(slack, last = false) {
18625
18669
  if (slack == null) return last ? "no date to hold" : "next has no due date";
18626
- if (slack > 0) return `${plural$3(slack, "day")} spare`;
18670
+ if (slack > 0) return `${plural$4(slack, "day")} spare`;
18627
18671
  if (slack === 0) return "no slack";
18628
- return last ? `runs ${plural$3(-slack, "day")} over` : `pushes the next ${plural$3(-slack, "day")}`;
18672
+ return last ? `runs ${plural$4(-slack, "day")} over` : `pushes the next ${plural$4(-slack, "day")}`;
18629
18673
  }
18630
18674
  /**
18631
18675
  * The sentence above the line: what sets the date and how it compares with
@@ -18637,9 +18681,9 @@ function poleHeadline(pole, name, held) {
18637
18681
  const lead = pole.via ? `${pole.via.project.name} finishes ${shortDate(pole.finish)}` : `${last ? name(last.task) : "It"} can finish ${shortDate(pole.finish)}`;
18638
18682
  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
18683
  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}`;
18684
+ if (pole.over > 0) return `${lead}, ${plural$4(pole.over, "day")} after ${held} (${shortDate(pole.target)}).${tail}`;
18641
18685
  if (pole.over === 0) return `${lead}, right on ${held}.${tail}`;
18642
- return `${lead}, ${plural$3(-pole.over, "day")} before ${held}.${tail}`;
18686
+ return `${lead}, ${plural$4(-pole.over, "day")} before ${held}.${tail}`;
18643
18687
  }
18644
18688
  /** "13 open tasks have no due date, so they can't hold the date."; null when none. */
18645
18689
  function undatedText(pole) {
@@ -18784,6 +18828,288 @@ function reviewCsv(data, programs, now) {
18784
18828
  ]));
18785
18829
  }
18786
18830
 
18831
+ //#endregion
18832
+ //#region src/lib/crew-clocks.ts
18833
+ /** Working hours when only a time zone is set. */
18834
+ const DEFAULT_HOURS$1 = {
18835
+ start: "09:00",
18836
+ end: "17:00"
18837
+ };
18838
+ const HHMM = /^([01]\d|2[0-3]):([0-5]\d)$/;
18839
+ const pad = (n) => String(n).padStart(2, "0");
18840
+ /** "9:00", "17:30": a time as the crew reads it. */
18841
+ const timeText = (minutes) => `${Math.floor(minutes / 60)}:${pad(minutes % 60)}`;
18842
+ /** "09:00" as minutes since midnight, or null. */
18843
+ function minutesOf(hhmm) {
18844
+ const m = hhmm ? HHMM.exec(hhmm) : null;
18845
+ return m ? Number(m[1]) * 60 + Number(m[2]) : null;
18846
+ }
18847
+ const formats = /* @__PURE__ */ new Map();
18848
+ function formatFor(zone) {
18849
+ let f = formats.get(zone);
18850
+ if (!f) {
18851
+ f = new Intl.DateTimeFormat("en-US", {
18852
+ timeZone: zone,
18853
+ year: "numeric",
18854
+ month: "2-digit",
18855
+ day: "2-digit",
18856
+ weekday: "short",
18857
+ hour: "2-digit",
18858
+ minute: "2-digit",
18859
+ hourCycle: "h23"
18860
+ });
18861
+ formats.set(zone, f);
18862
+ }
18863
+ return f;
18864
+ }
18865
+ /** A time zone this runtime knows ("Europe/London"). */
18866
+ function isZone(zone) {
18867
+ if (!zone || zone.length > 64) return false;
18868
+ try {
18869
+ formatFor(zone);
18870
+ return true;
18871
+ } catch {
18872
+ return false;
18873
+ }
18874
+ }
18875
+ const DAYS = [
18876
+ "Sun",
18877
+ "Mon",
18878
+ "Tue",
18879
+ "Wed",
18880
+ "Thu",
18881
+ "Fri",
18882
+ "Sat"
18883
+ ];
18884
+ /** The wall clock in a zone at an instant: its date (ISO), weekday (0 Sunday) and minutes since midnight. */
18885
+ function wallIn(zone, at) {
18886
+ const parts = {};
18887
+ for (const p of formatFor(zone).formatToParts(at)) parts[p.type] = p.value;
18888
+ return {
18889
+ date: `${parts.year}-${parts.month}-${parts.day}`,
18890
+ weekday: DAYS.indexOf(parts.weekday ?? ""),
18891
+ minutes: Number(parts.hour) % 24 * 60 + Number(parts.minute)
18892
+ };
18893
+ }
18894
+ const LONG_DAYS = [
18895
+ "Sunday",
18896
+ "Monday",
18897
+ "Tuesday",
18898
+ "Wednesday",
18899
+ "Thursday",
18900
+ "Friday",
18901
+ "Saturday"
18902
+ ];
18903
+ /** Someone's clock now, or null with no time zone set. */
18904
+ function clockOf(person, at) {
18905
+ const zone = person?.timeZone;
18906
+ if (!isZone(zone)) return null;
18907
+ const wall = wallIn(zone, at);
18908
+ const start = minutesOf(person?.workStart) ?? minutesOf(DEFAULT_HOURS$1.start);
18909
+ let end = minutesOf(person?.workEnd) ?? minutesOf(DEFAULT_HOURS$1.end);
18910
+ if (end <= start) end = Math.min(1440, start + 480);
18911
+ const weekday = wall.weekday >= 1 && wall.weekday <= 5;
18912
+ const working = weekday && wall.minutes >= start && wall.minutes < end;
18913
+ let untilStart = 0;
18914
+ let backDay = null;
18915
+ if (!working) {
18916
+ let offset = wall.minutes < start && weekday ? 0 : 1;
18917
+ let day = (wall.weekday + offset) % 7;
18918
+ while (day === 0 || day === 6) {
18919
+ offset += 1;
18920
+ day = (wall.weekday + offset) % 7;
18921
+ }
18922
+ untilStart = offset * 24 * 60 + start - wall.minutes;
18923
+ if (offset > 1) backDay = LONG_DAYS[day];
18924
+ }
18925
+ return {
18926
+ zone,
18927
+ date: wall.date,
18928
+ minutes: wall.minutes,
18929
+ start,
18930
+ end,
18931
+ working,
18932
+ untilStart,
18933
+ backDay
18934
+ };
18935
+ }
18936
+ /** "in 9 hours", "in 40 minutes", "in 2 days". */
18937
+ function inText(minutes) {
18938
+ if (minutes < 60) return `in ${Math.max(1, Math.round(minutes))} minute${Math.round(minutes) === 1 ? "" : "s"}`;
18939
+ const hours = Math.round(minutes / 60);
18940
+ if (hours < 36) return `in ${hours} hour${hours === 1 ? "" : "s"}`;
18941
+ return `in ${Math.round(minutes / 1440)} days`;
18942
+ }
18943
+ const firstName$2 = (name) => name.trim().split(/\s+/)[0] || name;
18944
+ /**
18945
+ * When someone off for the day will pick up work: "Priya is off for the day
18946
+ * (23:40 there); picks it up at 9:00 their time, in 9 hours." Null while
18947
+ * they're working or with no time zone set.
18948
+ */
18949
+ function picksUpLine(person, at, you = false) {
18950
+ const clock = clockOf(person, at);
18951
+ if (!person || !clock || clock.working) return null;
18952
+ const who = you ? "You're" : `${firstName$2(person.name)} is`;
18953
+ const when = `${clock.backDay ? `${clock.backDay} ` : ""}at ${timeText(clock.start)}`;
18954
+ 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)}.`;
18955
+ }
18956
+ /** Today where someone is: their date when they've set a time zone, otherwise `fallback`. */
18957
+ function todayFor(person, at, fallback) {
18958
+ return isZone(person?.timeZone) ? wallIn(person.timeZone, at).date : fallback;
18959
+ }
18960
+
18961
+ //#endregion
18962
+ //#region src/lib/pilot.ts
18963
+ init_load();
18964
+ init_format();
18965
+ init_handover();
18966
+ init_indexes();
18967
+ init_task_keys();
18968
+ /** How many of the owner's finished tasks in the project are read for how long one takes. */
18969
+ const SIMILAR = 20;
18970
+ /** Fewer finished tasks than this and the owner's history isn't used. */
18971
+ const SIMILAR_MIN = 3;
18972
+ /** A finished task that took longer than this sat somewhere; it counts as this long. */
18973
+ const SIMILAR_CAP = 15;
18974
+ const firstName$1 = (name) => name.trim().split(/\s+/)[0] || name;
18975
+ const day$1 = (iso) => iso ? iso.slice(0, 10) : null;
18976
+ function quantile(sorted, q) {
18977
+ const at = (sorted.length - 1) * q;
18978
+ const lo = Math.floor(at);
18979
+ const hi = Math.ceil(at);
18980
+ return sorted[lo] + (sorted[hi] - sorted[lo]) * (at - lo);
18981
+ }
18982
+ /**
18983
+ * The suggestion for a task with an owner, or null when there's nothing to
18984
+ * say: no owner, an agent owns it, or it's done.
18985
+ */
18986
+ function pilotDate(task, data, localToday, at) {
18987
+ if (!task.assigneeId || task.status === "done") return null;
18988
+ const owner = byId(data.people).get(task.assigneeId);
18989
+ if (!owner || owner.kind === "agent") return null;
18990
+ const today = at ? todayFor(owner, at, localToday) : localToday;
18991
+ const chosen = day$1(task.dueDate);
18992
+ let underWay = 0;
18993
+ const dated = [];
18994
+ const finished = [];
18995
+ for (const t of data.tasks) {
18996
+ if (t.assigneeId !== owner.id || t.id === task.id) continue;
18997
+ if (t.status === "done") {
18998
+ if (t.projectId === task.projectId && t.completedAt) finished.push(t);
18999
+ continue;
19000
+ }
19001
+ const due = day$1(t.dueDate);
19002
+ if (t.status === "doing" || t.status === "review") underWay += 1;
19003
+ else if (due) dated.push(due);
19004
+ }
19005
+ const perDay = Math.max(.2, (owner.weeklyCapacity ?? 5) / 5);
19006
+ let similar = null;
19007
+ if (finished.length >= SIMILAR_MIN) {
19008
+ const took = finished.sort((a, b) => b.completedAt < a.completedAt ? -1 : 1).slice(0, SIMILAR).map((t) => Math.min(SIMILAR_CAP, Math.max(1, workingDaysBetween(day$1(t.createdAt), day$1(t.completedAt))))).sort((a, b) => a - b);
19009
+ similar = {
19010
+ low: Math.round(quantile(took, .25)),
19011
+ high: Math.round(quantile(took, .75)),
19012
+ count: took.length
19013
+ };
19014
+ }
19015
+ const ownDays = Math.min(5, Math.max(1, similar?.low ?? 1));
19016
+ const backOn = isAway(owner, today) ? day$1(owner.away.until) : null;
19017
+ const start = backOn && backOn > today ? dayBefore(backOn) : today;
19018
+ const tasks = byId(data.tasks);
19019
+ const projects = byId(data.projects);
19020
+ let waitsOn = null;
19021
+ let waitEnd = null;
19022
+ const blockers = /* @__PURE__ */ new Set([...task.blockedByIds ?? [], ...task.blockedById ? [task.blockedById] : []]);
19023
+ let undated = null;
19024
+ for (const id of blockers) {
19025
+ const b = tasks.get(id);
19026
+ if (!b || b.status === "done") continue;
19027
+ const due = day$1(b.dueDate);
19028
+ if (!due) {
19029
+ undated ??= b;
19030
+ continue;
19031
+ }
19032
+ const end = due > today ? due : today;
19033
+ if (!waitEnd || end > waitEnd) {
19034
+ waitEnd = end;
19035
+ waitsOn = {
19036
+ key: taskKey(b, projects.get(b.projectId)) ?? b.title,
19037
+ title: b.title,
19038
+ due
19039
+ };
19040
+ }
19041
+ }
19042
+ if (!waitsOn && undated) waitsOn = {
19043
+ key: taskKey(undated, projects.get(undated.projectId)) ?? undated.title,
19044
+ title: undated.title,
19045
+ due: null
19046
+ };
19047
+ const afterWait = waitEnd ? addWorkingDays(waitEnd, ownDays) : null;
19048
+ const by = (limit) => {
19049
+ const ahead = underWay + (limit ? dated.filter((d) => d <= limit).length : dated.length);
19050
+ const queueDays = Math.ceil(ahead / perDay);
19051
+ let earliest = addWorkingDays(start, queueDays + ownDays);
19052
+ if (afterWait && afterWait > earliest) earliest = afterWait;
19053
+ return {
19054
+ ahead,
19055
+ queueDays,
19056
+ earliest
19057
+ };
19058
+ };
19059
+ let read = by(chosen);
19060
+ const fits = Boolean(chosen && chosen >= read.earliest);
19061
+ if (chosen && !fits) for (let i = 0; i < 100; i++) {
19062
+ const next = by(read.earliest);
19063
+ if (next.earliest <= read.earliest) {
19064
+ read = {
19065
+ ...next,
19066
+ earliest: read.earliest
19067
+ };
19068
+ break;
19069
+ }
19070
+ read = next;
19071
+ }
19072
+ return {
19073
+ earliest: read.earliest,
19074
+ fits,
19075
+ owner: {
19076
+ id: owner.id,
19077
+ name: owner.name
19078
+ },
19079
+ ahead: read.ahead,
19080
+ queueDays: read.queueDays,
19081
+ backOn: backOn && backOn > today ? backOn : null,
19082
+ waitsOn,
19083
+ similar,
19084
+ ownDays
19085
+ };
19086
+ }
19087
+ function dayBefore(iso) {
19088
+ const d = /* @__PURE__ */ new Date(`${iso}T12:00:00`);
19089
+ d.setDate(d.getDate() - 1);
19090
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
19091
+ }
19092
+ const plural$3 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
19093
+ /** The line under the date: "Dana's queue says Nov 12 at the earliest", or, with no date yet, "Dana could likely have this by Nov 12". */
19094
+ function pilotHeadline(p, chosen, you = false) {
19095
+ const who = you ? "You" : firstName$1(p.owner.name);
19096
+ if (!day$1(chosen)) return `${who} could likely have this by ${shortDate(p.earliest)}`;
19097
+ return `${you ? "Your" : `${who}'s`} queue says ${shortDate(p.earliest)} at the earliest`;
19098
+ }
19099
+ /** Why: the queue, time away, what it waits on, and how long similar tasks took. */
19100
+ function pilotReasons(p, you = false) {
19101
+ const out = [];
19102
+ if (p.ahead > 0) out.push(`${plural$3(p.ahead, "open task")} ahead of this one, about ${plural$3(p.queueDays, "working day")}`);
19103
+ else out.push("Nothing else in the queue ahead of it");
19104
+ if (p.backOn) out.push(`${you ? "You're" : "Away,"} back on ${shortDate(p.backOn)}`);
19105
+ 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`);
19106
+ if (p.similar) {
19107
+ const range = p.similar.low === p.similar.high ? plural$3(p.similar.low, "day") : `${p.similar.low} to ${p.similar.high} days`;
19108
+ out.push(`Similar tasks here took ${you ? "you" : firstName$1(p.owner.name)} ${range}`);
19109
+ }
19110
+ return out;
19111
+ }
19112
+
18787
19113
  //#endregion
18788
19114
  //#region src/lib/agents.ts
18789
19115
  const isAgent = (p) => p?.kind === "agent";
@@ -19100,6 +19426,7 @@ function barnaclesData(b) {
19100
19426
  daysOfWork: b.days,
19101
19427
  slipDays: b.slip,
19102
19428
  daysLine: barnaclesDaysLine(b),
19429
+ caveat: b.caveat,
19103
19430
  byWeek: b.weeks.filter((w) => w.added > 0).map((w) => ({
19104
19431
  weekOf: w.week,
19105
19432
  added: w.added
@@ -19742,7 +20069,7 @@ const HARBOR_TOOLS = [
19742
20069
  tool({
19743
20070
  name: "get_task",
19744
20071
  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,
20072
+ 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
20073
  input: object({
19747
20074
  harbor: harborArg,
19748
20075
  task: string().min(1).max(100).describe("Task id or key (\"RR-42\"), from search_tasks or my_work.")
@@ -19769,6 +20096,8 @@ const HARBOR_TOOLS = [
19769
20096
  body: c.body
19770
20097
  }));
19771
20098
  const key = taskKey(t, project);
20099
+ const pilot = pilotDate(t, data, todayISO(ctx.now), ctx.now);
20100
+ const you = Boolean(pilot && pilot.owner.id === me(ctx)?.id);
19772
20101
  return {
19773
20102
  id: t.id,
19774
20103
  ...key ? { key } : {},
@@ -19791,6 +20120,12 @@ const HARBOR_TOOLS = [
19791
20120
  waitingOn: blockersOf(t, data.tasks).map(brief),
19792
20121
  waitedOnBy: waiters.map(brief),
19793
20122
  ...t.sounding ? { sounding: soundingOut(data, t, me(ctx)?.id ?? null, ctx.now) } : {},
20123
+ ...pilot ? { pilotDue: {
20124
+ earliest: pilot.earliest,
20125
+ fits: pilot.fits,
20126
+ says: pilotHeadline(pilot, t.dueDate, you),
20127
+ why: pilotReasons(pilot, you)
20128
+ } } : {},
19794
20129
  comments,
19795
20130
  ...webLink(ctx, { task: t.id })
19796
20131
  };
@@ -19811,7 +20146,7 @@ const HARBOR_TOOLS = [
19811
20146
  you: null,
19812
20147
  note: "You aren't one of this harbor's people yet, so nothing is yours."
19813
20148
  };
19814
- const today = todayISO(ctx.now);
20149
+ const today = todayFor(mine, ctx.now, todayISO(ctx.now));
19815
20150
  const people = byId(data.people);
19816
20151
  const projects = byId(data.projects);
19817
20152
  const open = data.tasks.filter((t) => t.assigneeId === mine.id && isOpen(t)).sort((a, b) => (a.dueDate ?? "9999").localeCompare(b.dueDate ?? "9999"));
@@ -20554,6 +20889,83 @@ const HARBOR_TOOLS = [
20554
20889
  };
20555
20890
  }
20556
20891
  }),
20892
+ tool({
20893
+ name: "suggest_due",
20894
+ title: "Suggested due dates for new tasks",
20895
+ 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,
20896
+ input: object({
20897
+ harbor: harborArg,
20898
+ project: string().min(1).max(200).describe("Project id or name."),
20899
+ assignee: string().min(1).max(200).default("me").describe("\"me\", or a person's id or name."),
20900
+ count: number().int().min(1).max(100).default(1)
20901
+ }),
20902
+ run: (ctx, input) => {
20903
+ const { data } = ctx;
20904
+ const project = findOne(data.projects, input.project, "project");
20905
+ const owner = input.assignee.trim().toLowerCase() === "me" ? me(ctx) : findOne(data.people, input.assignee, "person");
20906
+ if (!owner) throw new ToolError("You aren't one of this harbor's people, so say whose: assignee.");
20907
+ const you = owner.id === me(ctx)?.id;
20908
+ const today = todayISO(ctx.now);
20909
+ let tasks = data.tasks;
20910
+ const dates = [];
20911
+ for (let i = 0; i < input.count; i++) {
20912
+ const next = {
20913
+ id: `new-${i}`,
20914
+ projectId: project.id,
20915
+ assigneeId: owner.id,
20916
+ dueDate: null
20917
+ };
20918
+ const pilot = pilotDate(next, {
20919
+ ...data,
20920
+ tasks
20921
+ }, today, ctx.now);
20922
+ if (!pilot) throw new ToolError(`${owner.name} is an agent: Pilot reads a person's queue.`);
20923
+ dates.push({
20924
+ earliest: pilot.earliest,
20925
+ says: pilotHeadline(pilot, null, you),
20926
+ why: pilotReasons(pilot, you)
20927
+ });
20928
+ const added = {
20929
+ ...next,
20930
+ status: "todo",
20931
+ dueDate: pilot.earliest
20932
+ };
20933
+ tasks = [...tasks, added];
20934
+ }
20935
+ return {
20936
+ owner: owner.name,
20937
+ project: project.name,
20938
+ dates
20939
+ };
20940
+ }
20941
+ }),
20942
+ tool({
20943
+ name: "crew_clocks",
20944
+ title: "Who's working now",
20945
+ 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,
20946
+ input: object({ harbor: harborArg }),
20947
+ run: (ctx) => {
20948
+ const { data } = ctx;
20949
+ const mine = me(ctx);
20950
+ const crew = data.people.filter((p) => !isAgent(p));
20951
+ return {
20952
+ clocks: crew.flatMap((p) => {
20953
+ const c = clockOf(p, ctx.now);
20954
+ if (!c) return [];
20955
+ return [{
20956
+ who: p.name,
20957
+ zone: c.zone,
20958
+ localTime: timeText(c.minutes),
20959
+ date: c.date,
20960
+ hours: `${timeText(c.start)}-${timeText(c.end)}`,
20961
+ working: c.working,
20962
+ ...c.working ? {} : { picksUp: picksUpLine(p, ctx.now, p.id === mine?.id) }
20963
+ }];
20964
+ }),
20965
+ noTimeZone: crew.filter((p) => !clockOf(p, ctx.now)).map((p) => p.name)
20966
+ };
20967
+ }
20968
+ }),
20557
20969
  tool({
20558
20970
  name: "handover_plan",
20559
20971
  title: "What handing someone's work over would move",
@@ -21703,6 +22115,14 @@ function hierarchySlice({ get, write }) {
21703
22115
  const program = get().programs.find((p) => p.id === programId);
21704
22116
  get().log(`${reset ? "reset" : "set"} the baseline on ${program?.name ?? "a program"}`, `/programs/${programId}`);
21705
22117
  },
22118
+ setProjectBaseline: (projectId, reset = false) => {
22119
+ const project = get().projects.find((p) => p.id === projectId);
22120
+ if (!project) return;
22121
+ const [stamped] = withBaseline([project], reset, stamp());
22122
+ if (stamped === project) return;
22123
+ write({ projects: get().projects.map((p) => p.id === projectId ? stamped : p) });
22124
+ get().log(`${reset ? "reset" : "set"} the baseline on ${project.name}`, `/projects/${projectId}`);
22125
+ },
21706
22126
  setPriorityRules: (rules) => {
21707
22127
  write({ priorityRules: rules ? normalizeRules(rules) : null });
21708
22128
  },
@@ -26202,6 +26622,292 @@ function ShorePanel({ id, width, height, go, active, onClose }) {
26202
26622
  });
26203
26623
  }
26204
26624
 
26625
+ //#endregion
26626
+ //#region src/lib/seen-mark.ts
26627
+ /** Days without opening Harbor before the catch-up shows. */
26628
+ const UNSEEN_DAYS = 3;
26629
+ /** Calendar days from one ISO day (or moment) to another, by their dates alone. */
26630
+ function dayGap(from, to) {
26631
+ const utc = (iso) => {
26632
+ const [y, m, d] = iso.slice(0, 10).split("-").map(Number);
26633
+ return Date.UTC(y, m - 1, d);
26634
+ };
26635
+ return Math.round((utc(to) - utc(from)) / 864e5);
26636
+ }
26637
+ /**
26638
+ * Whether to catch someone up, and from when: back from time away they were
26639
+ * marked for (a handover with a return date that has come, until this device
26640
+ * has caught them up once since), or `UNSEEN_DAYS` or more since this device
26641
+ * last saw them. Null otherwise, and the first time a device sees them:
26642
+ * there's nothing to compare with.
26643
+ */
26644
+ function welcomeWhen(person, lastSeen, today) {
26645
+ const away = person?.away;
26646
+ if (away?.since && away.until && away.until.slice(0, 10) <= today) {
26647
+ if (!lastSeen || lastSeen.slice(0, 10) < away.until.slice(0, 10)) return {
26648
+ since: away.since,
26649
+ why: "away",
26650
+ days: dayGap(away.since, today)
26651
+ };
26652
+ }
26653
+ if (!lastSeen) return null;
26654
+ const days = dayGap(lastSeen, today);
26655
+ return days >= 3 ? {
26656
+ since: lastSeen,
26657
+ why: "unseen",
26658
+ days
26659
+ } : null;
26660
+ }
26661
+
26662
+ //#endregion
26663
+ //#region src/lib/welcome-back.ts
26664
+ init_date_text();
26665
+ init_indexes();
26666
+ init_task_keys();
26667
+ const day = (iso) => parseIso(iso.slice(0, 10));
26668
+ const daysBetween = dayGap;
26669
+ /** Their projects: ones they lead, in a program they lead, or with a task of theirs. */
26670
+ function projectsOf(data, personId) {
26671
+ const programs = new Set(data.programs.filter((p) => p.ownerId === personId).map((p) => p.id));
26672
+ return data.projects.filter((p) => p.ownerId === personId || programs.has(p.programId) || tasksOf(data.tasks, p.id).some((t) => t.assigneeId === personId));
26673
+ }
26674
+ const first = (p) => p?.name.split(" ")[0] ?? null;
26675
+ /** "due Thu, Oct 23", "3 days late", "due today". */
26676
+ function dueText(task, today) {
26677
+ if (!task.dueDate) return "no due date";
26678
+ const d = daysBetween(today, task.dueDate);
26679
+ if (d === 0) return "due today";
26680
+ if (d < 0) return `${-d} day${d === -1 ? "" : "s"} late`;
26681
+ if (d === 1) return "due tomorrow";
26682
+ return `due ${format(day(task.dueDate), "EEE, MMM d")}`;
26683
+ }
26684
+ /** What a person needs to hear about since `when.since`, as of `today` (YYYY-MM-DD). */
26685
+ function welcomeBack(data, personId, when, today) {
26686
+ const since = when.since;
26687
+ const people = byId(data.people);
26688
+ const projects = projectsOf(data, personId);
26689
+ const projectIds = new Set(projects.map((p) => p.id));
26690
+ const programIds = new Set(projects.map((p) => p.programId));
26691
+ const projectById = byId(data.projects);
26692
+ const label = (t) => {
26693
+ const p = projectById.get(t.projectId);
26694
+ const key = p ? taskKey(t, p) : null;
26695
+ return key ? `${key} ${t.title}` : t.title;
26696
+ };
26697
+ const mine = data.tasks.filter((t) => t.assigneeId === personId && t.status !== "done");
26698
+ const handed = mine.filter((t) => t.assignedAt && t.assignedAt >= since && t.assignedBy && t.assignedBy !== personId).sort((a, b) => (a.dueDate ?? "9999").localeCompare(b.dueDate ?? "9999")).map((task) => ({
26699
+ task,
26700
+ by: people.get(task.assignedBy) ?? null
26701
+ }));
26702
+ const decisions = data.raidItems.filter((r) => r.kind === "decision" && r.status !== "proposed" && Boolean(r.decidedAt) && r.decidedAt >= since && r.decidedById !== personId && (r.projectId ? projectIds.has(r.projectId) : Boolean(r.programId && programIds.has(r.programId)))).sort((a, b) => b.decidedAt.localeCompare(a.decidedAt)).map((item) => ({
26703
+ item,
26704
+ by: item.decidedById ? people.get(item.decidedById) ?? null : null
26705
+ }));
26706
+ const moved = [];
26707
+ for (const project of projects) {
26708
+ const tasks = tasksOf(data.tasks, project.id);
26709
+ const done = tasks.filter((t) => t.completedAt && t.completedAt >= since).length;
26710
+ const added = tasks.filter((t) => t.createdAt >= since).length;
26711
+ const history = project.healthHistory ?? [];
26712
+ const before = [...history].reverse().find((h) => h.at < since)?.health;
26713
+ const health = history.some((h) => h.at >= since) && before && before !== project.health ? {
26714
+ from: before,
26715
+ to: project.health
26716
+ } : null;
26717
+ if (done || added || health) moved.push({
26718
+ project,
26719
+ done,
26720
+ added,
26721
+ health
26722
+ });
26723
+ }
26724
+ moved.sort((a, b) => Number(Boolean(b.health)) - Number(Boolean(a.health)) || b.done - a.done);
26725
+ const look = [];
26726
+ const seen = /* @__PURE__ */ new Set();
26727
+ const push = (item) => {
26728
+ const id = item.taskId ?? item.raidId ?? item.text;
26729
+ if (look.length >= 3 || seen.has(id)) return;
26730
+ seen.add(id);
26731
+ look.push(item);
26732
+ };
26733
+ for (const h of handed) {
26734
+ const by = first(h.by);
26735
+ push({
26736
+ kind: "handed",
26737
+ taskId: h.task.id,
26738
+ text: `${label(h.task)}: handed to you${by ? ` by ${by}` : ""}, ${dueText(h.task, today)}`
26739
+ });
26740
+ }
26741
+ const dated = mine.filter((t) => t.dueDate).sort((a, b) => a.dueDate.localeCompare(b.dueDate));
26742
+ for (const t of dated) if (t.dueDate.slice(0, 10) < today) push({
26743
+ kind: "late",
26744
+ taskId: t.id,
26745
+ text: `${label(t)}: ${dueText(t, today)}`
26746
+ });
26747
+ for (const t of dated) {
26748
+ const d = daysBetween(today, t.dueDate);
26749
+ if (d >= 0 && d <= 3) push({
26750
+ kind: "due",
26751
+ taskId: t.id,
26752
+ text: `${label(t)}: ${dueText(t, today)}`
26753
+ });
26754
+ }
26755
+ for (const d of decisions) {
26756
+ const by = first(d.by);
26757
+ push({
26758
+ kind: "decision",
26759
+ raidId: d.item.id,
26760
+ text: `Decided without you: ${d.item.title}${by ? ` (${by})` : ""}`
26761
+ });
26762
+ }
26763
+ return {
26764
+ when,
26765
+ handed,
26766
+ decisions,
26767
+ moved: moved.slice(0, 6),
26768
+ lookFirst: look,
26769
+ quiet: !handed.length && !decisions.length && !moved.length && !look.length
26770
+ };
26771
+ }
26772
+ /** "Welcome back, Priya · away Oct 3 to Oct 17", or "· 5 days since you last looked". */
26773
+ function welcomeTitle(w, name, today) {
26774
+ const hi = name ? `Welcome back, ${name}` : "Welcome back";
26775
+ if (w.when.why === "away") return `${hi} · away ${format(day(w.when.since), "MMM d")} to ${format(day(today), "MMM d")}`;
26776
+ return `${hi} · ${w.when.days} days since you last looked`;
26777
+ }
26778
+ /** "Checkout v3: 4 done, 2 added, now At risk (was On track)". */
26779
+ function movedText(m, healthLabel) {
26780
+ const bits = [
26781
+ m.done ? `${m.done} done` : null,
26782
+ m.added ? `${m.added} added` : null,
26783
+ m.health ? `now ${healthLabel(m.health.to)} (was ${healthLabel(m.health.from)})` : null
26784
+ ].filter(Boolean);
26785
+ return `${m.project.name}: ${bits.join(", ")}`;
26786
+ }
26787
+
26788
+ //#endregion
26789
+ //#region src/tui/views/welcome.tsx
26790
+ init_swatches();
26791
+ const TAG = {
26792
+ handed: "Handed to you",
26793
+ late: "Late",
26794
+ due: "Due soon",
26795
+ decision: "Decided"
26796
+ };
26797
+ /**
26798
+ * While you were away, the app's first screen after time off or days
26799
+ * unseen: the three to look at first (Enter opens one), the calls made
26800
+ * without you and what moved. g or Esc says got it and goes on to Today.
26801
+ */
26802
+ function WelcomeView({ when, active, width, height, go, onDone }) {
26803
+ const { today } = useDeps();
26804
+ const tasks = useHarbor((s) => s.tasks);
26805
+ const projects = useHarbor((s) => s.projects);
26806
+ const programs = useHarbor((s) => s.programs);
26807
+ const people = useHarbor((s) => s.people);
26808
+ const raidItems = useHarbor((s) => s.raidItems);
26809
+ const me = useHarbor((s) => s.currentUserId);
26810
+ const day = today();
26811
+ const w = useMemo(() => welcomeBack({
26812
+ tasks,
26813
+ projects,
26814
+ programs,
26815
+ people,
26816
+ raidItems
26817
+ }, me, when, day), [
26818
+ tasks,
26819
+ projects,
26820
+ programs,
26821
+ people,
26822
+ raidItems,
26823
+ me,
26824
+ when,
26825
+ day
26826
+ ]);
26827
+ const name = people.find((p) => p.id === me)?.name.split(" ")[0] ?? null;
26828
+ const [at, setAt] = useState(0);
26829
+ const look = w.lookFirst;
26830
+ useInput((input, key) => {
26831
+ if (key.escape || input === "g") return onDone();
26832
+ if (key.upArrow || input === "k") setAt((i) => step(i, -1, look.length));
26833
+ else if (key.downArrow || input === "j") setAt((i) => step(i, 1, look.length));
26834
+ else if (key.return) {
26835
+ const l = look[at];
26836
+ if (!l) return onDone();
26837
+ if (l.taskId) go.openTask(l.taskId);
26838
+ else if (l.raidId) go.openPanel("raid", l.raidId);
26839
+ }
26840
+ }, { isActive: active });
26841
+ const [hello, ...rest] = welcomeTitle(w, name, day).split(" · ");
26842
+ const label = (h) => HEALTH_LABEL$1[h];
26843
+ const room = Math.max(0, height - 6 - look.length);
26844
+ const lines = [
26845
+ ...w.decisions.length ? [{
26846
+ head: true,
26847
+ text: "Decided without you"
26848
+ }] : [],
26849
+ ...w.decisions.slice(0, 3).map(({ item, by }) => ({
26850
+ head: false,
26851
+ text: `${item.title}${by ? ` · ${by.name.split(" ")[0]}` : ""}`
26852
+ })),
26853
+ ...w.moved.length ? [{
26854
+ head: true,
26855
+ text: "What moved"
26856
+ }] : [],
26857
+ ...w.moved.map((m) => ({
26858
+ head: false,
26859
+ text: movedText(m, label)
26860
+ }))
26861
+ ].slice(0, room);
26862
+ return /* @__PURE__ */ jsxs(Box, {
26863
+ flexDirection: "column",
26864
+ width,
26865
+ height,
26866
+ paddingX: 1,
26867
+ children: [
26868
+ /* @__PURE__ */ jsx(Heading, {
26869
+ right: rest.join(" · "),
26870
+ children: hello
26871
+ }),
26872
+ w.quiet ? /* @__PURE__ */ jsx(Text, {
26873
+ color: theme.muted,
26874
+ children: "Nothing moved on your projects while you were gone."
26875
+ }) : null,
26876
+ look.map((l, i) => {
26877
+ const on = active && i === at;
26878
+ return /* @__PURE__ */ jsxs(Text, {
26879
+ color: on ? theme.selected : void 0,
26880
+ bold: on,
26881
+ wrap: "truncate-end",
26882
+ children: [
26883
+ on ? "› " : " ",
26884
+ /* @__PURE__ */ jsx(Text, {
26885
+ color: l.kind === "due" || l.kind === "decision" ? theme.muted : theme.warn,
26886
+ children: TAG[l.kind].padEnd(14)
26887
+ }),
26888
+ l.text
26889
+ ]
26890
+ }, l.taskId ?? l.raidId ?? l.text);
26891
+ }),
26892
+ lines.map((l, i) => l.head ? /* @__PURE__ */ jsx(Box, {
26893
+ marginTop: 1,
26894
+ children: /* @__PURE__ */ jsx(Text, {
26895
+ color: theme.muted,
26896
+ children: l.text
26897
+ })
26898
+ }, i) : /* @__PURE__ */ jsxs(Text, {
26899
+ wrap: "truncate-end",
26900
+ children: [" ", l.text]
26901
+ }, i)),
26902
+ /* @__PURE__ */ jsx(Box, { flexGrow: 1 }),
26903
+ /* @__PURE__ */ jsxs(Text, {
26904
+ color: theme.muted,
26905
+ children: [look.length ? "↑↓ pick · Enter opens · " : "", "g or Esc: got it, on to Today"]
26906
+ })
26907
+ ]
26908
+ });
26909
+ }
26910
+
26205
26911
  //#endregion
26206
26912
  //#region src/tui/views/bands.tsx
26207
26913
  /**
@@ -29074,7 +29780,7 @@ const SAVE_LABEL = {
29074
29780
  offline: ["● offline, will save", "bad"]
29075
29781
  };
29076
29782
  function App({ ui, startIn }) {
29077
- const { store, sync, account, harbors, today, remember } = useDeps();
29783
+ const { store, sync, account, harbors, today, remember, seen } = useDeps();
29078
29784
  const { exit } = useApp();
29079
29785
  const { columns, rows } = useWindowSize();
29080
29786
  const harborName = useHarbor((s) => s.harborName);
@@ -29095,6 +29801,21 @@ function App({ ui, startIn }) {
29095
29801
  const [overlay, setOverlay] = useState(null);
29096
29802
  const [focus, setFocus] = useState("main");
29097
29803
  const [quitting, setQuitting] = useState(false);
29804
+ const [welcome, setWelcome] = useState(() => {
29805
+ const s = store.getState();
29806
+ const me = s.people.find((p) => p.id === s.currentUserId);
29807
+ if (!seen || !me || !s.workspaceId || s.workspaceId === "local") return null;
29808
+ return welcomeWhen(me, seen.last(s.workspaceId), today());
29809
+ });
29810
+ useEffect(() => {
29811
+ const id = store.getState().workspaceId;
29812
+ if (!welcome && seen && id && id !== "local") seen.mark(id, (/* @__PURE__ */ new Date()).toISOString());
29813
+ }, []);
29814
+ const doneWelcome = () => {
29815
+ const id = store.getState().workspaceId;
29816
+ if (seen && id) seen.mark(id, (/* @__PURE__ */ new Date()).toISOString());
29817
+ setWelcome(null);
29818
+ };
29098
29819
  useEffect(() => {
29099
29820
  if (!notice) return;
29100
29821
  const t = setTimeout(() => ui.setState({ notice: null }), 5e3);
@@ -29544,6 +30265,11 @@ function App({ ui, startIn }) {
29544
30265
  portfolioId: page.id,
29545
30266
  onBack: back
29546
30267
  }, page.id);
30268
+ else if (screen === "today" && welcome) screenNode = /* @__PURE__ */ jsx(WelcomeView, {
30269
+ ...viewProps,
30270
+ when: welcome,
30271
+ onDone: doneWelcome
30272
+ });
29547
30273
  else if (screen === "today") screenNode = /* @__PURE__ */ jsx(TodayView, { ...viewProps });
29548
30274
  else if (screen === "inbox") screenNode = /* @__PURE__ */ jsx(InboxView, { ...viewProps });
29549
30275
  else if (screen === "projects") screenNode = /* @__PURE__ */ jsx(ProjectsView, { ...viewProps });
@@ -29857,7 +30583,9 @@ function fogSummary(t) {
29857
30583
 
29858
30584
  //#endregion
29859
30585
  //#region src/tui/watch.ts
30586
+ init_clock();
29860
30587
  init_date_text();
30588
+ init_format();
29861
30589
  init_indexes();
29862
30590
  init_swatches();
29863
30591
  /** Most lines one change prints; a whole reload says how many more there were. */
@@ -29877,14 +30605,17 @@ function watchLines(prev, next, meId) {
29877
30605
  const label = (t) => `${keyOf$1(next, t)} ${t.title}`;
29878
30606
  const seen = new Set(prev.activity.map((a) => a.id));
29879
30607
  const fresh = next.activity.filter((a) => !seen.has(a.id));
30608
+ const logged = (t) => fresh.find((a) => a.text.includes(`“${t.title}”`));
29880
30609
  const who = (t) => {
29881
- const hit = fresh.find((a) => a.text.includes(`“${t.title}”`));
30610
+ const hit = logged(t);
29882
30611
  if (!hit) return null;
29883
30612
  return hit.personId === meId ? "You" : firstName(people.get(hit.personId));
29884
30613
  };
29885
30614
  const said = (t, phrase) => {
29886
- const name = who(t);
29887
- return name ? `${name} ${phrase}` : phrase.charAt(0).toUpperCase() + phrase.slice(1);
30615
+ const via = logged(t)?.via;
30616
+ const name = via ? null : who(t);
30617
+ const text = name ? `${name} ${phrase}` : phrase.charAt(0).toUpperCase() + phrase.slice(1);
30618
+ return via ? `${text} via ${via}` : text;
29888
30619
  };
29889
30620
  for (const t of next.tasks) {
29890
30621
  const was = before.get(t.id);
@@ -29988,7 +30719,7 @@ function recentLines(doc, meId, opts = {}) {
29988
30719
  return (opts.since ? rows : rows.slice(-(opts.limit ?? 5))).map((a) => ({
29989
30720
  at: a.at,
29990
30721
  line: {
29991
- text: `${a.personId === meId ? "You" : firstName(people.get(a.personId))} ${a.text}`,
30722
+ 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
30723
  mine: false,
29993
30724
  tone: "plain"
29994
30725
  }
@@ -29997,20 +30728,36 @@ function recentLines(doc, meId, opts = {}) {
29997
30728
  /**
29998
30729
  * `--since`: "14:30" (today, on this computer's clock) or a date or moment
29999
30730
  * ("2026-10-21", "2026-10-21T14:30"), as an ISO moment; null when it isn't one.
30731
+ * A trailing "Z" or "UTC" reads it in UTC instead ("14:30Z", "2026-10-21 14:30 UTC").
30000
30732
  */
30001
30733
  function watchSince(text, now) {
30002
- const clock = /^(\d{1,2}):(\d{2})$/.exec(text.trim());
30734
+ const t = text.trim();
30735
+ const utc = /(?:\s*utc|z)$/i.test(t);
30736
+ const bare = utc ? t.replace(/(?:\s*utc|z)$/i, "") : t;
30737
+ const clock = /^(\d{1,2}):(\d{2})$/.exec(bare);
30003
30738
  if (clock) {
30004
30739
  const [h, m] = [Number(clock[1]), Number(clock[2])];
30005
30740
  if (h > 23 || m > 59) return null;
30006
30741
  const at = new Date(now);
30007
- at.setHours(h, m, 0, 0);
30742
+ if (utc) at.setUTCHours(h, m, 0, 0);
30743
+ else at.setHours(h, m, 0, 0);
30008
30744
  return at.toISOString();
30009
30745
  }
30010
- if (!/^\d{4}-\d{2}-\d{2}/.test(text.trim())) return null;
30011
- const at = parseIso(text);
30746
+ if (!/^\d{4}-\d{2}-\d{2}/.test(bare)) return null;
30747
+ const at = parseIso(utc ? `${bare.replace(" ", "T")}${bare.length > 10 ? "Z" : "T00:00Z"}` : bare);
30012
30748
  return Number.isNaN(at.getTime()) ? null : at.toISOString();
30013
30749
  }
30750
+ /** This computer's zone at a moment, short: "BST", "EDT", "UTC", "GMT+5:30". */
30751
+ function zoneName(at) {
30752
+ return new Intl.DateTimeFormat("en-US", { timeZoneName: "short" }).formatToParts(at).find((p) => p.type === "timeZoneName")?.value ?? "";
30753
+ }
30754
+ /** A --since moment as the ticker says it: "14:30 BST" today, "Oct 20, 14:30 BST" before. */
30755
+ function sinceText(iso, now) {
30756
+ const at = new Date(iso);
30757
+ const wall = wallClock(at);
30758
+ const time = `${wall.slice(11, 16)} ${zoneName(at)}`.trim();
30759
+ return wall.slice(0, 10) === wallClock(now).slice(0, 10) ? time : `${shortDate(wall.slice(0, 10))}, ${time}`;
30760
+ }
30014
30761
 
30015
30762
  //#endregion
30016
30763
  //#region src/tui/watch-run.ts
@@ -30075,9 +30822,10 @@ async function watchOn(opts) {
30075
30822
  projectId: project?.id ?? null
30076
30823
  });
30077
30824
  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:"));
30825
+ 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`));
30826
+ const since = opts.since ? sinceText(opts.since, started) : null;
30827
+ if (earlier.length === 0) out(dim(since ? `Nothing since ${since}.` : "Nothing logged yet."));
30828
+ else out(dim(since ? `Since ${since}:` : "Lately:"));
30081
30829
  }
30082
30830
  for (const e of earlier) print(e.line, new Date(e.at), true);
30083
30831
  if (opts.once) return;
@@ -30390,7 +31138,8 @@ async function startApp(deps, options = {}) {
30390
31138
  harbors,
30391
31139
  today,
30392
31140
  remember: deps.remember,
30393
- browse: deps.browse
31141
+ browse: deps.browse,
31142
+ seen: deps.seen
30394
31143
  },
30395
31144
  children: /* @__PURE__ */ jsx(App, {
30396
31145
  ui,
@@ -30474,7 +31223,8 @@ async function runApp(opts) {
30474
31223
  firstRun,
30475
31224
  remember: opts.remember,
30476
31225
  browse: opts.browse,
30477
- startIn: opts.startIn
31226
+ startIn: opts.startIn,
31227
+ seen: opts.seen
30478
31228
  }, { alternateScreen: true });
30479
31229
  await instance.waitUntilExit();
30480
31230
  }