@harborppm/cli 0.62.0 → 0.63.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -4
- package/dist/app.mjs +501 -84
- package/lib/cli.mjs +93 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -135,8 +135,11 @@ harbor hill [--program P] where each project is on its hill
|
|
|
135
135
|
harbor hill PROJECT --at 0-100|top|done move it: up to 50 is figuring it out,
|
|
136
136
|
past 50 is getting it done
|
|
137
137
|
harbor mine what's on you, late first
|
|
138
|
+
harbor clock [NAME] [--zone ZONE|here|none] [--hours 9-17]
|
|
139
|
+
who's working now; set your (or NAME's) time zone and hours
|
|
138
140
|
harbor tasks [words] [--project P] [--assignee me|none|NAME] [--late] [--status S,S] [--all]
|
|
139
|
-
harbor show TASK one task in full, with its web link
|
|
141
|
+
harbor show TASK one task in full, with its web link and the
|
|
142
|
+
date its owner's queue suggests (pilotDue in --json)
|
|
140
143
|
harbor open [TASK|PROJECT] open it on the website (or Today)
|
|
141
144
|
harbor invite EMAIL [--viewer] invite someone to your harbor by email
|
|
142
145
|
harbor calendar [--program P] [--new|--off] your private calendar link (.ics)
|
|
@@ -154,8 +157,11 @@ harbor project "NAME" --program P [--lead me|NAME] [--starts DATE]
|
|
|
154
157
|
harbor add "TITLE" --project P [--assignee me|NAME] [--due DATE|pilot]
|
|
155
158
|
[--priority low|medium|high|urgent] [--status S] [--comment TEXT]
|
|
156
159
|
[--over-limit] no TITLE: one title a line from stdin
|
|
157
|
-
--due pilot takes the
|
|
158
|
-
|
|
160
|
+
--due pilot takes the earliest day the owner's queue
|
|
161
|
+
suggests (their open work due first, weekly pace, time
|
|
162
|
+
away, what it waits on, how long their similar tasks
|
|
163
|
+
took); it needs an owner, and an empty queue gives
|
|
164
|
+
their next working day. A date that won't hold says so
|
|
159
165
|
harbor start TASK | submit TASK | done TASK [--confirm] [--over-limit]
|
|
160
166
|
harbor edit TASK [--title T] [--assignee me|none|NAME] [--due DATE|pilot|none]
|
|
161
167
|
[--priority P] [--status S] [--notes TEXT]
|
|
@@ -223,7 +229,7 @@ harbor agent report TASK "WHAT YOU DID" [--start] [--agent NAME]
|
|
|
223
229
|
|
|
224
230
|
`harbor fog` lists the open tasks nobody has opened, moved, edited or commented on for three weeks, longest first. `harbor fog --sweep` walks through them one at a time in a terminal: `k` still needed (clears the fog), `d` a new date, `x` drop it, `s` skip, `q` stop. On the board, in the app and on the website, those cards show dimmed.
|
|
225
231
|
|
|
226
|
-
`harbor watch` turns a narrow split or a tmux pane into a live ticker of your harbor: cards moving, comments, health changes, a line each as they happen, over the same live updates the website uses. Lines about your own work (your tasks, mentions of you, what you lead) are bright and the rest dim; `--bell` rings the terminal's bell when something of yours changes, and `--project` keeps to one project. It starts with the latest few changes and the time it began listening; `--since 14:30` (or a date) starts from then instead. `q` stops; piped or with `--json` (one JSON object a line) it runs until it's killed, and `--once` prints what's there and returns, for scripts.
|
|
232
|
+
`harbor watch` turns a narrow split or a tmux pane into a live ticker of your harbor: cards moving, comments, health changes, a line each as they happen, over the same live updates the website uses. Lines about your own work (your tasks, mentions of you, what you lead) are bright and the rest dim; `--bell` rings the terminal's bell when something of yours changes, and `--project` keeps to one project. It starts with the latest few changes and the time it began listening; `--since 14:30` (or a date) starts from then instead, on this computer's clock (`14:30Z` or `14:30 UTC` for UTC); the header names the time zone the ticker's times are in, and Harbor says "Nothing since 14:30 BST." when nothing has. A change the GitHub link made reads "Finished RR-4 … via GitHub", as History does. `q` stops; piped or with `--json` (one JSON object a line) it runs until it's killed, and `--once` prints what's there and returns, for scripts.
|
|
227
233
|
|
|
228
234
|
`harbor lifeboats` lists the work only one person has touched (a project, their corner of one, a task others wait on, their serious risks), numbered, each with someone who has room this week and already works nearby. `harbor lifeboats 2` puts that person on line 2 as a second pair of hands (`--person NAME` for someone else); `harbor follow RR-4 --person Sam` does it for one task. Following doesn't take the work over. `harbor raid "DECISION" --still-stands "WHY"` says a decision that came back for a look still stands.
|
|
229
235
|
|
package/dist/app.mjs
CHANGED
|
@@ -36,15 +36,15 @@ var __exportAll = (all, no_symbols) => {
|
|
|
36
36
|
//#region src/lib/clock.ts
|
|
37
37
|
/** A date's local wall-clock time, with no zone: "2026-10-05T23:10:00". */
|
|
38
38
|
function wallClock(d = /* @__PURE__ */ new Date()) {
|
|
39
|
-
return `${d.getFullYear()}-${pad$
|
|
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),
|
|
@@ -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({
|
|
@@ -9049,7 +9077,7 @@ function dollars(value) {
|
|
|
9049
9077
|
const n = Number(value ?? 0);
|
|
9050
9078
|
return whole.format(Number.isFinite(n) ? n : 0);
|
|
9051
9079
|
}
|
|
9052
|
-
function day(value) {
|
|
9080
|
+
function day$1(value) {
|
|
9053
9081
|
return value ? shortDate(value) : "no date";
|
|
9054
9082
|
}
|
|
9055
9083
|
/** Whole days from one ISO day to another. */
|
|
@@ -9129,15 +9157,15 @@ function describeEvent(e, names) {
|
|
|
9129
9157
|
};
|
|
9130
9158
|
case "dueDate": return {
|
|
9131
9159
|
action: "moved the due date of",
|
|
9132
|
-
detail: `from ${day(from)} to ${day(to)}${slip(from, to)}`
|
|
9160
|
+
detail: `from ${day$1(from)} to ${day$1(to)}${slip(from, to)}`
|
|
9133
9161
|
};
|
|
9134
9162
|
case "startDate": return {
|
|
9135
9163
|
action: "moved the start of",
|
|
9136
|
-
detail: `from ${day(from)} to ${day(to)}${slip(from, to)}`
|
|
9164
|
+
detail: `from ${day$1(from)} to ${day$1(to)}${slip(from, to)}`
|
|
9137
9165
|
};
|
|
9138
9166
|
case "endDate": return {
|
|
9139
9167
|
action: "moved the end of",
|
|
9140
|
-
detail: `from ${day(from)} to ${day(to)}${slip(from, to)}`
|
|
9168
|
+
detail: `from ${day$1(from)} to ${day$1(to)}${slip(from, to)}`
|
|
9141
9169
|
};
|
|
9142
9170
|
case "budget": return {
|
|
9143
9171
|
action: "changed the budget of",
|
|
@@ -9441,7 +9469,7 @@ const PING_KIND_LABEL = {
|
|
|
9441
9469
|
idle: "Quiet blockers",
|
|
9442
9470
|
mention: "Mentions"
|
|
9443
9471
|
};
|
|
9444
|
-
function firstName$
|
|
9472
|
+
function firstName$3(name) {
|
|
9445
9473
|
return name.split(/\s+/)[0] ?? name;
|
|
9446
9474
|
}
|
|
9447
9475
|
/** FNV-1a over the text, as eight hex digits: a short, stable name for a set of ids. */
|
|
@@ -9480,7 +9508,7 @@ function inboxOf(data, personId, now = clockNow()) {
|
|
|
9480
9508
|
pings.push({
|
|
9481
9509
|
id: `assigned:${task.id}:${task.assignedAt}`,
|
|
9482
9510
|
kind: "assigned",
|
|
9483
|
-
title: by ? `${firstName$
|
|
9511
|
+
title: by ? `${firstName$3(by.name)} gave you a task` : "You have a new task",
|
|
9484
9512
|
detail: `“${task.title}”${project ? ` · ${project.name}` : ""}`,
|
|
9485
9513
|
at: task.assignedAt,
|
|
9486
9514
|
taskId: task.id,
|
|
@@ -9494,7 +9522,7 @@ function inboxOf(data, personId, now = clockNow()) {
|
|
|
9494
9522
|
const project = projectOf(data, blocker);
|
|
9495
9523
|
const names = [...new Set(waiting.map((t) => {
|
|
9496
9524
|
const who = data.people.find((p) => p.id === t.assigneeId);
|
|
9497
|
-
return who ? firstName$
|
|
9525
|
+
return who ? firstName$3(who.name) : "Someone";
|
|
9498
9526
|
}))];
|
|
9499
9527
|
const blocked = waiting.length === 1 ? `“${waiting[0].title}”` : `${waiting.length} tasks`;
|
|
9500
9528
|
const upstream = openBlockerNames(blocker, data.tasks);
|
|
@@ -9529,7 +9557,7 @@ function inboxOf(data, personId, now = clockNow()) {
|
|
|
9529
9557
|
pings.push({
|
|
9530
9558
|
id: `changes:${m.id}:${shortHash(note)}`,
|
|
9531
9559
|
kind: "approval",
|
|
9532
|
-
title: `${approver ? firstName$
|
|
9560
|
+
title: `${approver ? firstName$3(approver.name) : "The approver"} asked for changes to ${m.name}`,
|
|
9533
9561
|
detail: note ? `${where}${excerpt(note)}` : program?.name ?? "",
|
|
9534
9562
|
at: m.dueDate,
|
|
9535
9563
|
programId: m.programId,
|
|
@@ -9575,7 +9603,7 @@ function inboxOf(data, personId, now = clockNow()) {
|
|
|
9575
9603
|
pings.push({
|
|
9576
9604
|
id: `sounding:${task.id}:${s.askedAt}`,
|
|
9577
9605
|
kind: "sounding",
|
|
9578
|
-
title: `${by ? firstName$
|
|
9606
|
+
title: `${by ? firstName$3(by.name) : "Someone"} asked how long a task will take`,
|
|
9579
9607
|
detail: `“${task.title}”${project ? ` · ${project.name}` : ""} · answer alone; the guesses show together`,
|
|
9580
9608
|
at: s.askedAt,
|
|
9581
9609
|
taskId: task.id,
|
|
@@ -9592,7 +9620,7 @@ function inboxOf(data, personId, now = clockNow()) {
|
|
|
9592
9620
|
pings.push({
|
|
9593
9621
|
id: `mention:${comment.id}`,
|
|
9594
9622
|
kind: "mention",
|
|
9595
|
-
title: author ? `${firstName$
|
|
9623
|
+
title: author ? `${firstName$3(author.name)} mentioned you` : "You were mentioned",
|
|
9596
9624
|
detail: snippet ? `on “${task.title}”${project ? ` · ${project.name}` : ""} — ${snippet}` : `on “${task.title}”${project ? ` · ${project.name}` : ""}`,
|
|
9597
9625
|
at: comment.createdAt,
|
|
9598
9626
|
taskId: task.id,
|
|
@@ -10019,7 +10047,7 @@ function byTask(rows, who) {
|
|
|
10019
10047
|
}
|
|
10020
10048
|
return out;
|
|
10021
10049
|
}
|
|
10022
|
-
const plural$
|
|
10050
|
+
const plural$5 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
10023
10051
|
/** Work only one person has touched, soonest-needed first. `scope` narrows it to some programs. */
|
|
10024
10052
|
function lifeboats(data, now, opts = {}) {
|
|
10025
10053
|
const today = isoDay(now);
|
|
@@ -10057,7 +10085,7 @@ function lifeboats(data, now, opts = {}) {
|
|
|
10057
10085
|
kind: "project",
|
|
10058
10086
|
id: project.id,
|
|
10059
10087
|
label: project.name,
|
|
10060
|
-
detail: `${plural$
|
|
10088
|
+
detail: `${plural$5(open.length, "open task")}, nobody else on them`,
|
|
10061
10089
|
soloId: solo[0],
|
|
10062
10090
|
projectId: project.id,
|
|
10063
10091
|
programId: project.programId,
|
|
@@ -10079,7 +10107,7 @@ function lifeboats(data, now, opts = {}) {
|
|
|
10079
10107
|
out.push({
|
|
10080
10108
|
kind: "tasks",
|
|
10081
10109
|
id: `tasks:${project.id}:${soloId}`,
|
|
10082
|
-
label: `${plural$
|
|
10110
|
+
label: `${plural$5(list.length, "task")} on ${project.name}`,
|
|
10083
10111
|
detail: `only ${first} has touched them`,
|
|
10084
10112
|
soloId,
|
|
10085
10113
|
projectId: project.id,
|
|
@@ -10099,7 +10127,7 @@ function lifeboats(data, now, opts = {}) {
|
|
|
10099
10127
|
kind: "task",
|
|
10100
10128
|
id: t.id,
|
|
10101
10129
|
label: key ? `${key} ${t.title}` : t.title,
|
|
10102
|
-
detail: [d < today ? `late since ${md(d)}` : `due ${md(d)}`, holds ? `${plural$
|
|
10130
|
+
detail: [d < today ? `late since ${md(d)}` : `due ${md(d)}`, holds ? `${plural$5(holds, "task")} ${holds === 1 ? "waits" : "wait"} on it` : "urgent"].join(", "),
|
|
10103
10131
|
soloId,
|
|
10104
10132
|
projectId: project.id,
|
|
10105
10133
|
programId: project.programId,
|
|
@@ -10126,7 +10154,7 @@ function lifeboats(data, now, opts = {}) {
|
|
|
10126
10154
|
const programId = first.programId ?? project?.programId ?? null;
|
|
10127
10155
|
const where = project?.name ?? (programId ? programs.get(programId)?.name : null) ?? null;
|
|
10128
10156
|
const issues = items.filter((r) => r.kind === "issue").length;
|
|
10129
|
-
const noun = issues === 0 ? plural$
|
|
10157
|
+
const noun = issues === 0 ? plural$5(items.length, "risk") : issues === items.length ? plural$5(items.length, "issue") : `${plural$5(items.length - issues, "risk")} and ${plural$5(issues, "issue")}`;
|
|
10130
10158
|
out.push({
|
|
10131
10159
|
kind: "risks",
|
|
10132
10160
|
id: key,
|
|
@@ -18619,13 +18647,13 @@ function milestonePole(milestone, data, now) {
|
|
|
18619
18647
|
undated: first.pole.kind === "chain" ? first.pole.undated : 0
|
|
18620
18648
|
};
|
|
18621
18649
|
}
|
|
18622
|
-
const plural$
|
|
18650
|
+
const plural$4 = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
18623
18651
|
/** A link's slack in a few words: "4 days spare", "no slack", "3 days late for what's next". */
|
|
18624
18652
|
function slackText(slack, last = false) {
|
|
18625
18653
|
if (slack == null) return last ? "no date to hold" : "next has no due date";
|
|
18626
|
-
if (slack > 0) return `${plural$
|
|
18654
|
+
if (slack > 0) return `${plural$4(slack, "day")} spare`;
|
|
18627
18655
|
if (slack === 0) return "no slack";
|
|
18628
|
-
return last ? `runs ${plural$
|
|
18656
|
+
return last ? `runs ${plural$4(-slack, "day")} over` : `pushes the next ${plural$4(-slack, "day")}`;
|
|
18629
18657
|
}
|
|
18630
18658
|
/**
|
|
18631
18659
|
* The sentence above the line: what sets the date and how it compares with
|
|
@@ -18637,9 +18665,9 @@ function poleHeadline(pole, name, held) {
|
|
|
18637
18665
|
const lead = pole.via ? `${pole.via.project.name} finishes ${shortDate(pole.finish)}` : `${last ? name(last.task) : "It"} can finish ${shortDate(pole.finish)}`;
|
|
18638
18666
|
const tail = pole.via && last && last.finish < pole.finish ? ` Its tasks can all be done by ${shortDate(last.finish)}; its end date sets it.` : "";
|
|
18639
18667
|
if (pole.over == null || !pole.target) return `${lead}.${tail}`;
|
|
18640
|
-
if (pole.over > 0) return `${lead}, ${plural$
|
|
18668
|
+
if (pole.over > 0) return `${lead}, ${plural$4(pole.over, "day")} after ${held} (${shortDate(pole.target)}).${tail}`;
|
|
18641
18669
|
if (pole.over === 0) return `${lead}, right on ${held}.${tail}`;
|
|
18642
|
-
return `${lead}, ${plural$
|
|
18670
|
+
return `${lead}, ${plural$4(-pole.over, "day")} before ${held}.${tail}`;
|
|
18643
18671
|
}
|
|
18644
18672
|
/** "13 open tasks have no due date, so they can't hold the date."; null when none. */
|
|
18645
18673
|
function undatedText(pole) {
|
|
@@ -18784,6 +18812,288 @@ function reviewCsv(data, programs, now) {
|
|
|
18784
18812
|
]));
|
|
18785
18813
|
}
|
|
18786
18814
|
|
|
18815
|
+
//#endregion
|
|
18816
|
+
//#region src/lib/crew-clocks.ts
|
|
18817
|
+
/** Working hours when only a time zone is set. */
|
|
18818
|
+
const DEFAULT_HOURS$1 = {
|
|
18819
|
+
start: "09:00",
|
|
18820
|
+
end: "17:00"
|
|
18821
|
+
};
|
|
18822
|
+
const HHMM = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
|
18823
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
18824
|
+
/** "9:00", "17:30": a time as the crew reads it. */
|
|
18825
|
+
const timeText = (minutes) => `${Math.floor(minutes / 60)}:${pad(minutes % 60)}`;
|
|
18826
|
+
/** "09:00" as minutes since midnight, or null. */
|
|
18827
|
+
function minutesOf(hhmm) {
|
|
18828
|
+
const m = hhmm ? HHMM.exec(hhmm) : null;
|
|
18829
|
+
return m ? Number(m[1]) * 60 + Number(m[2]) : null;
|
|
18830
|
+
}
|
|
18831
|
+
const formats = /* @__PURE__ */ new Map();
|
|
18832
|
+
function formatFor(zone) {
|
|
18833
|
+
let f = formats.get(zone);
|
|
18834
|
+
if (!f) {
|
|
18835
|
+
f = new Intl.DateTimeFormat("en-US", {
|
|
18836
|
+
timeZone: zone,
|
|
18837
|
+
year: "numeric",
|
|
18838
|
+
month: "2-digit",
|
|
18839
|
+
day: "2-digit",
|
|
18840
|
+
weekday: "short",
|
|
18841
|
+
hour: "2-digit",
|
|
18842
|
+
minute: "2-digit",
|
|
18843
|
+
hourCycle: "h23"
|
|
18844
|
+
});
|
|
18845
|
+
formats.set(zone, f);
|
|
18846
|
+
}
|
|
18847
|
+
return f;
|
|
18848
|
+
}
|
|
18849
|
+
/** A time zone this runtime knows ("Europe/London"). */
|
|
18850
|
+
function isZone(zone) {
|
|
18851
|
+
if (!zone || zone.length > 64) return false;
|
|
18852
|
+
try {
|
|
18853
|
+
formatFor(zone);
|
|
18854
|
+
return true;
|
|
18855
|
+
} catch {
|
|
18856
|
+
return false;
|
|
18857
|
+
}
|
|
18858
|
+
}
|
|
18859
|
+
const DAYS = [
|
|
18860
|
+
"Sun",
|
|
18861
|
+
"Mon",
|
|
18862
|
+
"Tue",
|
|
18863
|
+
"Wed",
|
|
18864
|
+
"Thu",
|
|
18865
|
+
"Fri",
|
|
18866
|
+
"Sat"
|
|
18867
|
+
];
|
|
18868
|
+
/** The wall clock in a zone at an instant: its date (ISO), weekday (0 Sunday) and minutes since midnight. */
|
|
18869
|
+
function wallIn(zone, at) {
|
|
18870
|
+
const parts = {};
|
|
18871
|
+
for (const p of formatFor(zone).formatToParts(at)) parts[p.type] = p.value;
|
|
18872
|
+
return {
|
|
18873
|
+
date: `${parts.year}-${parts.month}-${parts.day}`,
|
|
18874
|
+
weekday: DAYS.indexOf(parts.weekday ?? ""),
|
|
18875
|
+
minutes: Number(parts.hour) % 24 * 60 + Number(parts.minute)
|
|
18876
|
+
};
|
|
18877
|
+
}
|
|
18878
|
+
const LONG_DAYS = [
|
|
18879
|
+
"Sunday",
|
|
18880
|
+
"Monday",
|
|
18881
|
+
"Tuesday",
|
|
18882
|
+
"Wednesday",
|
|
18883
|
+
"Thursday",
|
|
18884
|
+
"Friday",
|
|
18885
|
+
"Saturday"
|
|
18886
|
+
];
|
|
18887
|
+
/** Someone's clock now, or null with no time zone set. */
|
|
18888
|
+
function clockOf(person, at) {
|
|
18889
|
+
const zone = person?.timeZone;
|
|
18890
|
+
if (!isZone(zone)) return null;
|
|
18891
|
+
const wall = wallIn(zone, at);
|
|
18892
|
+
const start = minutesOf(person?.workStart) ?? minutesOf(DEFAULT_HOURS$1.start);
|
|
18893
|
+
let end = minutesOf(person?.workEnd) ?? minutesOf(DEFAULT_HOURS$1.end);
|
|
18894
|
+
if (end <= start) end = Math.min(1440, start + 480);
|
|
18895
|
+
const weekday = wall.weekday >= 1 && wall.weekday <= 5;
|
|
18896
|
+
const working = weekday && wall.minutes >= start && wall.minutes < end;
|
|
18897
|
+
let untilStart = 0;
|
|
18898
|
+
let backDay = null;
|
|
18899
|
+
if (!working) {
|
|
18900
|
+
let offset = wall.minutes < start && weekday ? 0 : 1;
|
|
18901
|
+
let day = (wall.weekday + offset) % 7;
|
|
18902
|
+
while (day === 0 || day === 6) {
|
|
18903
|
+
offset += 1;
|
|
18904
|
+
day = (wall.weekday + offset) % 7;
|
|
18905
|
+
}
|
|
18906
|
+
untilStart = offset * 24 * 60 + start - wall.minutes;
|
|
18907
|
+
if (offset > 1) backDay = LONG_DAYS[day];
|
|
18908
|
+
}
|
|
18909
|
+
return {
|
|
18910
|
+
zone,
|
|
18911
|
+
date: wall.date,
|
|
18912
|
+
minutes: wall.minutes,
|
|
18913
|
+
start,
|
|
18914
|
+
end,
|
|
18915
|
+
working,
|
|
18916
|
+
untilStart,
|
|
18917
|
+
backDay
|
|
18918
|
+
};
|
|
18919
|
+
}
|
|
18920
|
+
/** "in 9 hours", "in 40 minutes", "in 2 days". */
|
|
18921
|
+
function inText(minutes) {
|
|
18922
|
+
if (minutes < 60) return `in ${Math.max(1, Math.round(minutes))} minute${Math.round(minutes) === 1 ? "" : "s"}`;
|
|
18923
|
+
const hours = Math.round(minutes / 60);
|
|
18924
|
+
if (hours < 36) return `in ${hours} hour${hours === 1 ? "" : "s"}`;
|
|
18925
|
+
return `in ${Math.round(minutes / 1440)} days`;
|
|
18926
|
+
}
|
|
18927
|
+
const firstName$2 = (name) => name.trim().split(/\s+/)[0] || name;
|
|
18928
|
+
/**
|
|
18929
|
+
* When someone off for the day will pick up work: "Priya is off for the day
|
|
18930
|
+
* (23:40 there); picks it up at 9:00 their time, in 9 hours." Null while
|
|
18931
|
+
* they're working or with no time zone set.
|
|
18932
|
+
*/
|
|
18933
|
+
function picksUpLine(person, at, you = false) {
|
|
18934
|
+
const clock = clockOf(person, at);
|
|
18935
|
+
if (!person || !clock || clock.working) return null;
|
|
18936
|
+
const who = you ? "You're" : `${firstName$2(person.name)} is`;
|
|
18937
|
+
const when = `${clock.backDay ? `${clock.backDay} ` : ""}at ${timeText(clock.start)}`;
|
|
18938
|
+
return `${who} ${clock.backDay ? `off until ${clock.backDay}` : "off for the day"} (${timeText(clock.minutes)} there); picks it up ${when} ${you ? "your" : "their"} time, ${inText(clock.untilStart)}.`;
|
|
18939
|
+
}
|
|
18940
|
+
/** Today where someone is: their date when they've set a time zone, otherwise `fallback`. */
|
|
18941
|
+
function todayFor(person, at, fallback) {
|
|
18942
|
+
return isZone(person?.timeZone) ? wallIn(person.timeZone, at).date : fallback;
|
|
18943
|
+
}
|
|
18944
|
+
|
|
18945
|
+
//#endregion
|
|
18946
|
+
//#region src/lib/pilot.ts
|
|
18947
|
+
init_load();
|
|
18948
|
+
init_format();
|
|
18949
|
+
init_handover();
|
|
18950
|
+
init_indexes();
|
|
18951
|
+
init_task_keys();
|
|
18952
|
+
/** How many of the owner's finished tasks in the project are read for how long one takes. */
|
|
18953
|
+
const SIMILAR = 20;
|
|
18954
|
+
/** Fewer finished tasks than this and the owner's history isn't used. */
|
|
18955
|
+
const SIMILAR_MIN = 3;
|
|
18956
|
+
/** A finished task that took longer than this sat somewhere; it counts as this long. */
|
|
18957
|
+
const SIMILAR_CAP = 15;
|
|
18958
|
+
const firstName$1 = (name) => name.trim().split(/\s+/)[0] || name;
|
|
18959
|
+
const day = (iso) => iso ? iso.slice(0, 10) : null;
|
|
18960
|
+
function quantile(sorted, q) {
|
|
18961
|
+
const at = (sorted.length - 1) * q;
|
|
18962
|
+
const lo = Math.floor(at);
|
|
18963
|
+
const hi = Math.ceil(at);
|
|
18964
|
+
return sorted[lo] + (sorted[hi] - sorted[lo]) * (at - lo);
|
|
18965
|
+
}
|
|
18966
|
+
/**
|
|
18967
|
+
* The suggestion for a task with an owner, or null when there's nothing to
|
|
18968
|
+
* say: no owner, an agent owns it, or it's done.
|
|
18969
|
+
*/
|
|
18970
|
+
function pilotDate(task, data, localToday, at) {
|
|
18971
|
+
if (!task.assigneeId || task.status === "done") return null;
|
|
18972
|
+
const owner = byId(data.people).get(task.assigneeId);
|
|
18973
|
+
if (!owner || owner.kind === "agent") return null;
|
|
18974
|
+
const today = at ? todayFor(owner, at, localToday) : localToday;
|
|
18975
|
+
const chosen = day(task.dueDate);
|
|
18976
|
+
let underWay = 0;
|
|
18977
|
+
const dated = [];
|
|
18978
|
+
const finished = [];
|
|
18979
|
+
for (const t of data.tasks) {
|
|
18980
|
+
if (t.assigneeId !== owner.id || t.id === task.id) continue;
|
|
18981
|
+
if (t.status === "done") {
|
|
18982
|
+
if (t.projectId === task.projectId && t.completedAt) finished.push(t);
|
|
18983
|
+
continue;
|
|
18984
|
+
}
|
|
18985
|
+
const due = day(t.dueDate);
|
|
18986
|
+
if (t.status === "doing" || t.status === "review") underWay += 1;
|
|
18987
|
+
else if (due) dated.push(due);
|
|
18988
|
+
}
|
|
18989
|
+
const perDay = Math.max(.2, (owner.weeklyCapacity ?? 5) / 5);
|
|
18990
|
+
let similar = null;
|
|
18991
|
+
if (finished.length >= SIMILAR_MIN) {
|
|
18992
|
+
const took = finished.sort((a, b) => b.completedAt < a.completedAt ? -1 : 1).slice(0, SIMILAR).map((t) => Math.min(SIMILAR_CAP, Math.max(1, workingDaysBetween(day(t.createdAt), day(t.completedAt))))).sort((a, b) => a - b);
|
|
18993
|
+
similar = {
|
|
18994
|
+
low: Math.round(quantile(took, .25)),
|
|
18995
|
+
high: Math.round(quantile(took, .75)),
|
|
18996
|
+
count: took.length
|
|
18997
|
+
};
|
|
18998
|
+
}
|
|
18999
|
+
const ownDays = Math.min(5, Math.max(1, similar?.low ?? 1));
|
|
19000
|
+
const backOn = isAway(owner, today) ? day(owner.away.until) : null;
|
|
19001
|
+
const start = backOn && backOn > today ? dayBefore(backOn) : today;
|
|
19002
|
+
const tasks = byId(data.tasks);
|
|
19003
|
+
const projects = byId(data.projects);
|
|
19004
|
+
let waitsOn = null;
|
|
19005
|
+
let waitEnd = null;
|
|
19006
|
+
const blockers = /* @__PURE__ */ new Set([...task.blockedByIds ?? [], ...task.blockedById ? [task.blockedById] : []]);
|
|
19007
|
+
let undated = null;
|
|
19008
|
+
for (const id of blockers) {
|
|
19009
|
+
const b = tasks.get(id);
|
|
19010
|
+
if (!b || b.status === "done") continue;
|
|
19011
|
+
const due = day(b.dueDate);
|
|
19012
|
+
if (!due) {
|
|
19013
|
+
undated ??= b;
|
|
19014
|
+
continue;
|
|
19015
|
+
}
|
|
19016
|
+
const end = due > today ? due : today;
|
|
19017
|
+
if (!waitEnd || end > waitEnd) {
|
|
19018
|
+
waitEnd = end;
|
|
19019
|
+
waitsOn = {
|
|
19020
|
+
key: taskKey(b, projects.get(b.projectId)) ?? b.title,
|
|
19021
|
+
title: b.title,
|
|
19022
|
+
due
|
|
19023
|
+
};
|
|
19024
|
+
}
|
|
19025
|
+
}
|
|
19026
|
+
if (!waitsOn && undated) waitsOn = {
|
|
19027
|
+
key: taskKey(undated, projects.get(undated.projectId)) ?? undated.title,
|
|
19028
|
+
title: undated.title,
|
|
19029
|
+
due: null
|
|
19030
|
+
};
|
|
19031
|
+
const afterWait = waitEnd ? addWorkingDays(waitEnd, ownDays) : null;
|
|
19032
|
+
const by = (limit) => {
|
|
19033
|
+
const ahead = underWay + (limit ? dated.filter((d) => d <= limit).length : dated.length);
|
|
19034
|
+
const queueDays = Math.ceil(ahead / perDay);
|
|
19035
|
+
let earliest = addWorkingDays(start, queueDays + ownDays);
|
|
19036
|
+
if (afterWait && afterWait > earliest) earliest = afterWait;
|
|
19037
|
+
return {
|
|
19038
|
+
ahead,
|
|
19039
|
+
queueDays,
|
|
19040
|
+
earliest
|
|
19041
|
+
};
|
|
19042
|
+
};
|
|
19043
|
+
let read = by(chosen);
|
|
19044
|
+
const fits = Boolean(chosen && chosen >= read.earliest);
|
|
19045
|
+
if (chosen && !fits) for (let i = 0; i < 100; i++) {
|
|
19046
|
+
const next = by(read.earliest);
|
|
19047
|
+
if (next.earliest <= read.earliest) {
|
|
19048
|
+
read = {
|
|
19049
|
+
...next,
|
|
19050
|
+
earliest: read.earliest
|
|
19051
|
+
};
|
|
19052
|
+
break;
|
|
19053
|
+
}
|
|
19054
|
+
read = next;
|
|
19055
|
+
}
|
|
19056
|
+
return {
|
|
19057
|
+
earliest: read.earliest,
|
|
19058
|
+
fits,
|
|
19059
|
+
owner: {
|
|
19060
|
+
id: owner.id,
|
|
19061
|
+
name: owner.name
|
|
19062
|
+
},
|
|
19063
|
+
ahead: read.ahead,
|
|
19064
|
+
queueDays: read.queueDays,
|
|
19065
|
+
backOn: backOn && backOn > today ? backOn : null,
|
|
19066
|
+
waitsOn,
|
|
19067
|
+
similar,
|
|
19068
|
+
ownDays
|
|
19069
|
+
};
|
|
19070
|
+
}
|
|
19071
|
+
function dayBefore(iso) {
|
|
19072
|
+
const d = /* @__PURE__ */ new Date(`${iso}T12:00:00`);
|
|
19073
|
+
d.setDate(d.getDate() - 1);
|
|
19074
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
19075
|
+
}
|
|
19076
|
+
const plural$3 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
19077
|
+
/** The line under the date: "Dana's queue says Nov 12 at the earliest", or, with no date yet, "Dana could likely have this by Nov 12". */
|
|
19078
|
+
function pilotHeadline(p, chosen, you = false) {
|
|
19079
|
+
const who = you ? "You" : firstName$1(p.owner.name);
|
|
19080
|
+
if (!day(chosen)) return `${who} could likely have this by ${shortDate(p.earliest)}`;
|
|
19081
|
+
return `${you ? "Your" : `${who}'s`} queue says ${shortDate(p.earliest)} at the earliest`;
|
|
19082
|
+
}
|
|
19083
|
+
/** Why: the queue, time away, what it waits on, and how long similar tasks took. */
|
|
19084
|
+
function pilotReasons(p, you = false) {
|
|
19085
|
+
const out = [];
|
|
19086
|
+
if (p.ahead > 0) out.push(`${plural$3(p.ahead, "open task")} ahead of this one, about ${plural$3(p.queueDays, "working day")}`);
|
|
19087
|
+
else out.push("Nothing else in the queue ahead of it");
|
|
19088
|
+
if (p.backOn) out.push(`${you ? "You're" : "Away,"} back on ${shortDate(p.backOn)}`);
|
|
19089
|
+
if (p.waitsOn) out.push(p.waitsOn.due ? `Waits on ${p.waitsOn.key}, due ${shortDate(p.waitsOn.due)}` : `Waits on ${p.waitsOn.key}, which has no date yet`);
|
|
19090
|
+
if (p.similar) {
|
|
19091
|
+
const range = p.similar.low === p.similar.high ? plural$3(p.similar.low, "day") : `${p.similar.low} to ${p.similar.high} days`;
|
|
19092
|
+
out.push(`Similar tasks here took ${you ? "you" : firstName$1(p.owner.name)} ${range}`);
|
|
19093
|
+
}
|
|
19094
|
+
return out;
|
|
19095
|
+
}
|
|
19096
|
+
|
|
18787
19097
|
//#endregion
|
|
18788
19098
|
//#region src/lib/agents.ts
|
|
18789
19099
|
const isAgent = (p) => p?.kind === "agent";
|
|
@@ -19742,7 +20052,7 @@ const HARBOR_TOOLS = [
|
|
|
19742
20052
|
tool({
|
|
19743
20053
|
name: "get_task",
|
|
19744
20054
|
title: "Task details",
|
|
19745
|
-
description: "One task: notes, checklist, what it waits on, what waits on it, its latest comments, its place in its board column (`position`, from 1 at the top), and its url on the website." + DATA_NOTE,
|
|
20055
|
+
description: "One task: notes, checklist, what it waits on, what waits on it, its latest comments, the earliest due date its owner's queue suggests (pilotDue: fits says whether the due date holds; absent with no owner, an agent owner, or once done), its place in its board column (`position`, from 1 at the top), and its url on the website." + DATA_NOTE,
|
|
19746
20056
|
input: object({
|
|
19747
20057
|
harbor: harborArg,
|
|
19748
20058
|
task: string().min(1).max(100).describe("Task id or key (\"RR-42\"), from search_tasks or my_work.")
|
|
@@ -19769,6 +20079,8 @@ const HARBOR_TOOLS = [
|
|
|
19769
20079
|
body: c.body
|
|
19770
20080
|
}));
|
|
19771
20081
|
const key = taskKey(t, project);
|
|
20082
|
+
const pilot = pilotDate(t, data, todayISO(ctx.now), ctx.now);
|
|
20083
|
+
const you = Boolean(pilot && pilot.owner.id === me(ctx)?.id);
|
|
19772
20084
|
return {
|
|
19773
20085
|
id: t.id,
|
|
19774
20086
|
...key ? { key } : {},
|
|
@@ -19791,6 +20103,12 @@ const HARBOR_TOOLS = [
|
|
|
19791
20103
|
waitingOn: blockersOf(t, data.tasks).map(brief),
|
|
19792
20104
|
waitedOnBy: waiters.map(brief),
|
|
19793
20105
|
...t.sounding ? { sounding: soundingOut(data, t, me(ctx)?.id ?? null, ctx.now) } : {},
|
|
20106
|
+
...pilot ? { pilotDue: {
|
|
20107
|
+
earliest: pilot.earliest,
|
|
20108
|
+
fits: pilot.fits,
|
|
20109
|
+
says: pilotHeadline(pilot, t.dueDate, you),
|
|
20110
|
+
why: pilotReasons(pilot, you)
|
|
20111
|
+
} } : {},
|
|
19794
20112
|
comments,
|
|
19795
20113
|
...webLink(ctx, { task: t.id })
|
|
19796
20114
|
};
|
|
@@ -19811,7 +20129,7 @@ const HARBOR_TOOLS = [
|
|
|
19811
20129
|
you: null,
|
|
19812
20130
|
note: "You aren't one of this harbor's people yet, so nothing is yours."
|
|
19813
20131
|
};
|
|
19814
|
-
const today = todayISO(ctx.now);
|
|
20132
|
+
const today = todayFor(mine, ctx.now, todayISO(ctx.now));
|
|
19815
20133
|
const people = byId(data.people);
|
|
19816
20134
|
const projects = byId(data.projects);
|
|
19817
20135
|
const open = data.tasks.filter((t) => t.assigneeId === mine.id && isOpen(t)).sort((a, b) => (a.dueDate ?? "9999").localeCompare(b.dueDate ?? "9999"));
|
|
@@ -20554,6 +20872,83 @@ const HARBOR_TOOLS = [
|
|
|
20554
20872
|
};
|
|
20555
20873
|
}
|
|
20556
20874
|
}),
|
|
20875
|
+
tool({
|
|
20876
|
+
name: "suggest_due",
|
|
20877
|
+
title: "Suggested due dates for new tasks",
|
|
20878
|
+
description: "The due date the owner's queue suggests for new tasks in a project (Pilot): their open work due first, their weekly pace, time away and how long their similar tasks took. With count, one date for each of that many new tasks, each counting the ones before it. Changes nothing; create_task with suggestedDue sets it." + DATA_NOTE,
|
|
20879
|
+
input: object({
|
|
20880
|
+
harbor: harborArg,
|
|
20881
|
+
project: string().min(1).max(200).describe("Project id or name."),
|
|
20882
|
+
assignee: string().min(1).max(200).default("me").describe("\"me\", or a person's id or name."),
|
|
20883
|
+
count: number().int().min(1).max(100).default(1)
|
|
20884
|
+
}),
|
|
20885
|
+
run: (ctx, input) => {
|
|
20886
|
+
const { data } = ctx;
|
|
20887
|
+
const project = findOne(data.projects, input.project, "project");
|
|
20888
|
+
const owner = input.assignee.trim().toLowerCase() === "me" ? me(ctx) : findOne(data.people, input.assignee, "person");
|
|
20889
|
+
if (!owner) throw new ToolError("You aren't one of this harbor's people, so say whose: assignee.");
|
|
20890
|
+
const you = owner.id === me(ctx)?.id;
|
|
20891
|
+
const today = todayISO(ctx.now);
|
|
20892
|
+
let tasks = data.tasks;
|
|
20893
|
+
const dates = [];
|
|
20894
|
+
for (let i = 0; i < input.count; i++) {
|
|
20895
|
+
const next = {
|
|
20896
|
+
id: `new-${i}`,
|
|
20897
|
+
projectId: project.id,
|
|
20898
|
+
assigneeId: owner.id,
|
|
20899
|
+
dueDate: null
|
|
20900
|
+
};
|
|
20901
|
+
const pilot = pilotDate(next, {
|
|
20902
|
+
...data,
|
|
20903
|
+
tasks
|
|
20904
|
+
}, today, ctx.now);
|
|
20905
|
+
if (!pilot) throw new ToolError(`${owner.name} is an agent: Pilot reads a person's queue.`);
|
|
20906
|
+
dates.push({
|
|
20907
|
+
earliest: pilot.earliest,
|
|
20908
|
+
says: pilotHeadline(pilot, null, you),
|
|
20909
|
+
why: pilotReasons(pilot, you)
|
|
20910
|
+
});
|
|
20911
|
+
const added = {
|
|
20912
|
+
...next,
|
|
20913
|
+
status: "todo",
|
|
20914
|
+
dueDate: pilot.earliest
|
|
20915
|
+
};
|
|
20916
|
+
tasks = [...tasks, added];
|
|
20917
|
+
}
|
|
20918
|
+
return {
|
|
20919
|
+
owner: owner.name,
|
|
20920
|
+
project: project.name,
|
|
20921
|
+
dates
|
|
20922
|
+
};
|
|
20923
|
+
}
|
|
20924
|
+
}),
|
|
20925
|
+
tool({
|
|
20926
|
+
name: "crew_clocks",
|
|
20927
|
+
title: "Who's working now",
|
|
20928
|
+
description: "Each person's time zone, local time and working hours (Monday to Friday, in their own zone), whether they're working now, and for someone off, when they'll pick up work (picksUp). People with no time zone set are listed under noTimeZone." + DATA_NOTE,
|
|
20929
|
+
input: object({ harbor: harborArg }),
|
|
20930
|
+
run: (ctx) => {
|
|
20931
|
+
const { data } = ctx;
|
|
20932
|
+
const mine = me(ctx);
|
|
20933
|
+
const crew = data.people.filter((p) => !isAgent(p));
|
|
20934
|
+
return {
|
|
20935
|
+
clocks: crew.flatMap((p) => {
|
|
20936
|
+
const c = clockOf(p, ctx.now);
|
|
20937
|
+
if (!c) return [];
|
|
20938
|
+
return [{
|
|
20939
|
+
who: p.name,
|
|
20940
|
+
zone: c.zone,
|
|
20941
|
+
localTime: timeText(c.minutes),
|
|
20942
|
+
date: c.date,
|
|
20943
|
+
hours: `${timeText(c.start)}-${timeText(c.end)}`,
|
|
20944
|
+
working: c.working,
|
|
20945
|
+
...c.working ? {} : { picksUp: picksUpLine(p, ctx.now, p.id === mine?.id) }
|
|
20946
|
+
}];
|
|
20947
|
+
}),
|
|
20948
|
+
noTimeZone: crew.filter((p) => !clockOf(p, ctx.now)).map((p) => p.name)
|
|
20949
|
+
};
|
|
20950
|
+
}
|
|
20951
|
+
}),
|
|
20557
20952
|
tool({
|
|
20558
20953
|
name: "handover_plan",
|
|
20559
20954
|
title: "What handing someone's work over would move",
|
|
@@ -29857,7 +30252,9 @@ function fogSummary(t) {
|
|
|
29857
30252
|
|
|
29858
30253
|
//#endregion
|
|
29859
30254
|
//#region src/tui/watch.ts
|
|
30255
|
+
init_clock();
|
|
29860
30256
|
init_date_text();
|
|
30257
|
+
init_format();
|
|
29861
30258
|
init_indexes();
|
|
29862
30259
|
init_swatches();
|
|
29863
30260
|
/** Most lines one change prints; a whole reload says how many more there were. */
|
|
@@ -29877,14 +30274,17 @@ function watchLines(prev, next, meId) {
|
|
|
29877
30274
|
const label = (t) => `${keyOf$1(next, t)} ${t.title}`;
|
|
29878
30275
|
const seen = new Set(prev.activity.map((a) => a.id));
|
|
29879
30276
|
const fresh = next.activity.filter((a) => !seen.has(a.id));
|
|
30277
|
+
const logged = (t) => fresh.find((a) => a.text.includes(`“${t.title}”`));
|
|
29880
30278
|
const who = (t) => {
|
|
29881
|
-
const hit =
|
|
30279
|
+
const hit = logged(t);
|
|
29882
30280
|
if (!hit) return null;
|
|
29883
30281
|
return hit.personId === meId ? "You" : firstName(people.get(hit.personId));
|
|
29884
30282
|
};
|
|
29885
30283
|
const said = (t, phrase) => {
|
|
29886
|
-
const
|
|
29887
|
-
|
|
30284
|
+
const via = logged(t)?.via;
|
|
30285
|
+
const name = via ? null : who(t);
|
|
30286
|
+
const text = name ? `${name} ${phrase}` : phrase.charAt(0).toUpperCase() + phrase.slice(1);
|
|
30287
|
+
return via ? `${text} via ${via}` : text;
|
|
29888
30288
|
};
|
|
29889
30289
|
for (const t of next.tasks) {
|
|
29890
30290
|
const was = before.get(t.id);
|
|
@@ -29988,7 +30388,7 @@ function recentLines(doc, meId, opts = {}) {
|
|
|
29988
30388
|
return (opts.since ? rows : rows.slice(-(opts.limit ?? 5))).map((a) => ({
|
|
29989
30389
|
at: a.at,
|
|
29990
30390
|
line: {
|
|
29991
|
-
text: `${a.personId === meId ? "You" : firstName(people.get(a.personId))} ${a.text}`,
|
|
30391
|
+
text: a.via ? `${a.text.charAt(0).toUpperCase()}${a.text.slice(1)} via ${a.via}` : `${a.personId === meId ? "You" : firstName(people.get(a.personId))} ${a.text}`,
|
|
29992
30392
|
mine: false,
|
|
29993
30393
|
tone: "plain"
|
|
29994
30394
|
}
|
|
@@ -29997,20 +30397,36 @@ function recentLines(doc, meId, opts = {}) {
|
|
|
29997
30397
|
/**
|
|
29998
30398
|
* `--since`: "14:30" (today, on this computer's clock) or a date or moment
|
|
29999
30399
|
* ("2026-10-21", "2026-10-21T14:30"), as an ISO moment; null when it isn't one.
|
|
30400
|
+
* A trailing "Z" or "UTC" reads it in UTC instead ("14:30Z", "2026-10-21 14:30 UTC").
|
|
30000
30401
|
*/
|
|
30001
30402
|
function watchSince(text, now) {
|
|
30002
|
-
const
|
|
30403
|
+
const t = text.trim();
|
|
30404
|
+
const utc = /(?:\s*utc|z)$/i.test(t);
|
|
30405
|
+
const bare = utc ? t.replace(/(?:\s*utc|z)$/i, "") : t;
|
|
30406
|
+
const clock = /^(\d{1,2}):(\d{2})$/.exec(bare);
|
|
30003
30407
|
if (clock) {
|
|
30004
30408
|
const [h, m] = [Number(clock[1]), Number(clock[2])];
|
|
30005
30409
|
if (h > 23 || m > 59) return null;
|
|
30006
30410
|
const at = new Date(now);
|
|
30007
|
-
at.
|
|
30411
|
+
if (utc) at.setUTCHours(h, m, 0, 0);
|
|
30412
|
+
else at.setHours(h, m, 0, 0);
|
|
30008
30413
|
return at.toISOString();
|
|
30009
30414
|
}
|
|
30010
|
-
if (!/^\d{4}-\d{2}-\d{2}/.test(
|
|
30011
|
-
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);
|
|
30012
30417
|
return Number.isNaN(at.getTime()) ? null : at.toISOString();
|
|
30013
30418
|
}
|
|
30419
|
+
/** This computer's zone at a moment, short: "BST", "EDT", "UTC", "GMT+5:30". */
|
|
30420
|
+
function zoneName(at) {
|
|
30421
|
+
return new Intl.DateTimeFormat("en-US", { timeZoneName: "short" }).formatToParts(at).find((p) => p.type === "timeZoneName")?.value ?? "";
|
|
30422
|
+
}
|
|
30423
|
+
/** A --since moment as the ticker says it: "14:30 BST" today, "Oct 20, 14:30 BST" before. */
|
|
30424
|
+
function sinceText(iso, now) {
|
|
30425
|
+
const at = new Date(iso);
|
|
30426
|
+
const wall = wallClock(at);
|
|
30427
|
+
const time = `${wall.slice(11, 16)} ${zoneName(at)}`.trim();
|
|
30428
|
+
return wall.slice(0, 10) === wallClock(now).slice(0, 10) ? time : `${shortDate(wall.slice(0, 10))}, ${time}`;
|
|
30429
|
+
}
|
|
30014
30430
|
|
|
30015
30431
|
//#endregion
|
|
30016
30432
|
//#region src/tui/watch-run.ts
|
|
@@ -30075,9 +30491,10 @@ async function watchOn(opts) {
|
|
|
30075
30491
|
projectId: project?.id ?? null
|
|
30076
30492
|
});
|
|
30077
30493
|
if (!opts.json) {
|
|
30078
|
-
out(dim(opts.once ? `⚓ ${plain(opts.harbor.name)}${project ? ` · ${plain(project.name)}` : ""}` : `⚓ ${plain(opts.harbor.name)}${project ? ` · ${plain(project.name)}` : ""} · on watch since ${wallClock(started).slice(11, 16)}${opts.bell ? " · bell on" : ""} · q to stop`));
|
|
30079
|
-
|
|
30080
|
-
|
|
30494
|
+
out(dim(opts.once ? `⚓ ${plain(opts.harbor.name)}${project ? ` · ${plain(project.name)}` : ""} · times in ${zoneName(started)}` : `⚓ ${plain(opts.harbor.name)}${project ? ` · ${plain(project.name)}` : ""} · on watch since ${wallClock(started).slice(11, 16)} ${zoneName(started)}${opts.bell ? " · bell on" : ""} · q to stop`));
|
|
30495
|
+
const since = opts.since ? sinceText(opts.since, started) : null;
|
|
30496
|
+
if (earlier.length === 0) out(dim(since ? `Nothing since ${since}.` : "Nothing logged yet."));
|
|
30497
|
+
else out(dim(since ? `Since ${since}:` : "Lately:"));
|
|
30081
30498
|
}
|
|
30082
30499
|
for (const e of earlier) print(e.line, new Date(e.at), true);
|
|
30083
30500
|
if (opts.once) return;
|
package/lib/cli.mjs
CHANGED
|
@@ -107,11 +107,16 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
107
107
|
harbor hill PROJECT --at 0-100|top|done move it: up to 50 is figuring it
|
|
108
108
|
out, past 50 is getting it done
|
|
109
109
|
harbor mine what's on you, late first
|
|
110
|
+
harbor clock [NAME] [--zone ZONE|here|none] [--hours 9-17]
|
|
111
|
+
who's working now, or set your
|
|
112
|
+
(or NAME's) time zone and hours
|
|
110
113
|
harbor tasks [words] [--project P] [--assignee me|none|NAME]
|
|
111
114
|
[--late] [--status S,S] [--all]
|
|
112
115
|
the first 20 (--all: every one);
|
|
113
116
|
done ones only with --status done
|
|
114
|
-
harbor show TASK one task (TASK: a key like RR-42)
|
|
117
|
+
harbor show TASK one task (TASK: a key like RR-42),
|
|
118
|
+
with the date its owner's queue
|
|
119
|
+
suggests
|
|
115
120
|
harbor open [TASK|PROJECT] open it on the website (or Today)
|
|
116
121
|
harbor invite EMAIL [--viewer] invite someone to your harbor
|
|
117
122
|
harbor prompt [--shell SHELL] [--refresh] due today and late, for your
|
|
@@ -566,9 +571,14 @@ const DETAIL = {
|
|
|
566
571
|
"",
|
|
567
572
|
" --assignee me|none|NAME, --due DATE|pilot, --priority low|medium|high|urgent,",
|
|
568
573
|
" --status S, --comment TEXT and --over-limit apply to every task added, so",
|
|
569
|
-
" ten lines with --comment add the same comment to all ten.
|
|
570
|
-
"
|
|
571
|
-
"
|
|
574
|
+
" ten lines with --comment add the same comment to all ten. More than 100",
|
|
575
|
+
" lines is harbor import's job.",
|
|
576
|
+
"",
|
|
577
|
+
" --due pilot takes the earliest day the owner's queue suggests: their open",
|
|
578
|
+
" work due first, their weekly pace, time away, what it waits on and how",
|
|
579
|
+
" long their similar tasks took. It needs an owner (--assignee none is",
|
|
580
|
+
" refused), and an empty queue gives their next working day. Several lines",
|
|
581
|
+
" each count the ones before them; with --dry-run it shows each line's date.",
|
|
572
582
|
],
|
|
573
583
|
week: [
|
|
574
584
|
" harbor week [--next|--last|--week DATE] reads: your week, the days and",
|
|
@@ -859,7 +869,9 @@ const DETAIL = {
|
|
|
859
869
|
" --project P one project's changes only",
|
|
860
870
|
" --bell ring the terminal's bell when something of yours changes",
|
|
861
871
|
" --since TIME start with every change since TIME (14:30 today, or",
|
|
862
|
-
" 2026-10-21, or 2026-10-21T14:30) instead of the last 5",
|
|
872
|
+
" 2026-10-21, or 2026-10-21T14:30) instead of the last 5;",
|
|
873
|
+
" times are this computer's, or UTC with Z or UTC after",
|
|
874
|
+
" (14:30Z), and the header names the zone",
|
|
863
875
|
" --once print those and stop instead of listening",
|
|
864
876
|
" --json one JSON object a line (at, text, quote, mine, tone;",
|
|
865
877
|
" earlier: true for the ones from before it started)",
|
|
@@ -1900,6 +1912,8 @@ export async function run(argv, io = {}) {
|
|
|
1900
1912
|
),
|
|
1901
1913
|
];
|
|
1902
1914
|
if (t.repeats) lines.push(`Repeats ${REPEAT_WORD[t.repeats] ?? t.repeats}.`);
|
|
1915
|
+
if (t.pilotDue?.fits) lines.push(`${t.pilotDue.says}, so the due date holds.`);
|
|
1916
|
+
else if (t.pilotDue) lines.push(...pilotLines({ pilot: t.pilotDue }, t));
|
|
1903
1917
|
if (t.description) lines.push("", t.description);
|
|
1904
1918
|
if (t.checklist.length)
|
|
1905
1919
|
lines.push("", ...t.checklist.map((c) => ` [${c.done ? "x" : " "}] ${c.step}`));
|
|
@@ -2160,11 +2174,38 @@ export async function run(argv, io = {}) {
|
|
|
2160
2174
|
);
|
|
2161
2175
|
if (values["dry-run"]) {
|
|
2162
2176
|
const n = titles.length === 1 ? "1 task" : `${titles.length} tasks`;
|
|
2163
|
-
|
|
2177
|
+
// --due pilot: the date each line would take, each counting the ones before it.
|
|
2178
|
+
const pilot = duePilot(values.due)
|
|
2179
|
+
? (
|
|
2180
|
+
await client.call("suggest_due", {
|
|
2181
|
+
harbor,
|
|
2182
|
+
project: values.project,
|
|
2183
|
+
assignee: values.assignee ?? "me",
|
|
2184
|
+
count: titles.length,
|
|
2185
|
+
})
|
|
2186
|
+
).dates
|
|
2187
|
+
: null;
|
|
2188
|
+
if (asJson)
|
|
2189
|
+
out(
|
|
2190
|
+
jsonText({
|
|
2191
|
+
dryRun: true,
|
|
2192
|
+
project: values.project,
|
|
2193
|
+
titles,
|
|
2194
|
+
...(pilot ? { due: pilot.map((d) => d.earliest) } : {}),
|
|
2195
|
+
}),
|
|
2196
|
+
);
|
|
2164
2197
|
else
|
|
2165
2198
|
out(
|
|
2166
2199
|
plain(
|
|
2167
|
-
[
|
|
2200
|
+
[
|
|
2201
|
+
`Would add ${n} to ${values.project}:`,
|
|
2202
|
+
...titles.map((t, i) => ` ${t}${pilot ? ` (due ${pilot[i].earliest})` : ""}`),
|
|
2203
|
+
...(pilot
|
|
2204
|
+
? [
|
|
2205
|
+
`--due pilot: ${pilot[0].says}; ${pilot[0].why.map((w) => w.charAt(0).toLowerCase() + w.slice(1)).join("; ")}.`,
|
|
2206
|
+
]
|
|
2207
|
+
: []),
|
|
2208
|
+
].join("\n"),
|
|
2168
2209
|
),
|
|
2169
2210
|
);
|
|
2170
2211
|
return 0;
|
|
@@ -2393,6 +2434,51 @@ export async function run(argv, io = {}) {
|
|
|
2393
2434
|
`Comment added to ${r.commented.task}${r.mentioned?.length ? `; told ${r.mentioned.join(", ")}` : ""}.`,
|
|
2394
2435
|
);
|
|
2395
2436
|
}
|
|
2437
|
+
case "clock": {
|
|
2438
|
+
const { positionals, values, harbor } = parse(spec([], ["zone", "hours"]));
|
|
2439
|
+
const who = positionals.join(" ").trim();
|
|
2440
|
+
if (values.zone === undefined && values.hours === undefined) {
|
|
2441
|
+
return call("crew_clocks", { harbor }, (r) => {
|
|
2442
|
+
const rows = who ? r.clocks.filter((c) => nameHas(c.who, who)) : r.clocks;
|
|
2443
|
+
const quiet = who ? r.noTimeZone.filter((n) => nameHas(n, who)) : r.noTimeZone;
|
|
2444
|
+
if (rows.length === 0 && quiet.length === 0) {
|
|
2445
|
+
return who ? `Nobody on the crew matches "${who}".` : "Nobody on the crew yet.";
|
|
2446
|
+
}
|
|
2447
|
+
const width = Math.max(0, ...rows.map((c) => c.who.length));
|
|
2448
|
+
return [
|
|
2449
|
+
...rows.map(
|
|
2450
|
+
(c) =>
|
|
2451
|
+
`${c.who.padEnd(width)} ${c.localTime.padStart(5)} ${c.zone.replace(/_/g, " ")} ${c.working ? `working (${c.hours})` : (c.picksUp ?? "off")}`,
|
|
2452
|
+
),
|
|
2453
|
+
...(quiet.length
|
|
2454
|
+
? [
|
|
2455
|
+
`${rows.length ? "\n" : ""}No time zone yet: ${quiet.join(", ")}. harbor clock --zone here sets yours.`,
|
|
2456
|
+
]
|
|
2457
|
+
: []),
|
|
2458
|
+
].join("\n");
|
|
2459
|
+
});
|
|
2460
|
+
}
|
|
2461
|
+
let zone = values.zone;
|
|
2462
|
+
if (zone?.toLowerCase() === "here") {
|
|
2463
|
+
zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
2464
|
+
if (!zone) throw new HarborError("This machine doesn't say its time zone. Give one, like --zone Europe/London.");
|
|
2465
|
+
}
|
|
2466
|
+
let starts;
|
|
2467
|
+
let ends;
|
|
2468
|
+
if (values.hours !== undefined) {
|
|
2469
|
+
const m = /^\s*([^-–]+?)\s*[-–]\s*(.+?)\s*$/.exec(values.hours);
|
|
2470
|
+
if (!m) throw new HarborError("--hours is a start and an end, like 9-17 or 8:30-16:30.");
|
|
2471
|
+
[, starts, ends] = m;
|
|
2472
|
+
}
|
|
2473
|
+
return call(
|
|
2474
|
+
"set_clock",
|
|
2475
|
+
{ harbor, person: who || "me", zone, starts, ends },
|
|
2476
|
+
(r) =>
|
|
2477
|
+
r.zone
|
|
2478
|
+
? `${r.person}: ${r.zone.replace(/_/g, " ")}, ${r.hours} on weekdays. ${r.working ? `Working now (${r.localTime} there).` : (r.picksUp ?? "")}`.trim()
|
|
2479
|
+
: `${r.person} has no time zone now.`,
|
|
2480
|
+
);
|
|
2481
|
+
}
|
|
2396
2482
|
case "checkin": {
|
|
2397
2483
|
const { positionals, values, harbor } = parse(spec(["on-blocker"]));
|
|
2398
2484
|
return call(
|