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