@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/README.md +14 -8
- package/dist/app.mjs +779 -96
- package/lib/cli.mjs +145 -12
- package/lib/format.mjs +9 -1
- package/package.json +1 -1
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$
|
|
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$
|
|
44
|
+
var frozen, pad$2;
|
|
45
45
|
var init_clock = __esmMin((() => {
|
|
46
46
|
frozen = null;
|
|
47
|
-
pad$
|
|
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$
|
|
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$
|
|
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$
|
|
3289
|
-
return `${work}: about ${b.days} of the ${plural$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
3722
|
-
const between = (a, b) => differenceInCalendarDays(day$
|
|
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$
|
|
3795
|
-
const md$2 = (iso) => format(day$
|
|
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$
|
|
3837
|
+
value: `+${plural$6(days, "day")}`,
|
|
3810
3838
|
label: `past the ${plan}`
|
|
3811
3839
|
} : {
|
|
3812
|
-
value: plural$
|
|
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$
|
|
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$
|
|
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$
|
|
3850
|
-
else if (top) out.push(`${top.person.name} closed ${plural$
|
|
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$
|
|
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$
|
|
4899
|
-
const gone = differenceInCalendarDays(now, day$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
5216
|
+
var day$4;
|
|
5189
5217
|
var init_baseline = __esmMin((() => {
|
|
5190
5218
|
init_date_fns();
|
|
5191
|
-
day$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
5261
|
-
const left = differenceInCalendarDays(day$
|
|
5262
|
-
const right = differenceInCalendarDays(day$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
18654
|
+
if (slack > 0) return `${plural$4(slack, "day")} spare`;
|
|
18623
18655
|
if (slack === 0) return "no slack";
|
|
18624
|
-
return last ? `runs ${plural$
|
|
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$
|
|
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$
|
|
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
|
|
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,
|
|
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 =
|
|
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
|
|
29621
|
-
|
|
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
|
|
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.
|
|
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(
|
|
29745
|
-
const at = parseIso(
|
|
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
|
-
|
|
29814
|
-
|
|
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;
|