@harborppm/cli 0.63.0 → 0.64.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.mjs +389 -56
- package/lib/args.mjs +5 -4
- package/lib/cli.mjs +80 -15
- package/lib/format.mjs +8 -3
- package/package.json +1 -1
package/dist/app.mjs
CHANGED
|
@@ -3275,6 +3275,13 @@ function barnaclesOf(project, tasks, people, now, slip = null) {
|
|
|
3275
3275
|
const weekly = weeklyFinished(tasks, project.startDate, now);
|
|
3276
3276
|
const finished = weekly.reduce((a, b) => a + b, 0);
|
|
3277
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.";
|
|
3278
3285
|
return {
|
|
3279
3286
|
since,
|
|
3280
3287
|
from,
|
|
@@ -3286,7 +3293,8 @@ function barnaclesOf(project, tasks, people, now, slip = null) {
|
|
|
3286
3293
|
adders,
|
|
3287
3294
|
perWeek,
|
|
3288
3295
|
days: perWeek ? Math.round(added.length / perWeek * 7) : null,
|
|
3289
|
-
slip
|
|
3296
|
+
slip,
|
|
3297
|
+
caveat
|
|
3290
3298
|
};
|
|
3291
3299
|
}
|
|
3292
3300
|
/**
|
|
@@ -3305,12 +3313,12 @@ function slipAgainstPlan(project, tasks, now, tide = tideForecast(project, tasks
|
|
|
3305
3313
|
function barnaclesHeadline(b) {
|
|
3306
3314
|
if (b.added === 0) return null;
|
|
3307
3315
|
const when = b.from === "baseline" ? "since the baseline" : "since its first week";
|
|
3308
|
-
const pct = b.share != null ? `, ${Math.round(b.share * 100)}% more than planned` : "";
|
|
3316
|
+
const pct = b.share != null && !b.caveat ? `, ${Math.round(b.share * 100)}% more than planned` : "";
|
|
3309
3317
|
return `+${plural$7(b.added, "task")} ${when}${pct}`;
|
|
3310
3318
|
}
|
|
3311
3319
|
/** What the added work costs in days, and how much of the slip it explains. */
|
|
3312
3320
|
function barnaclesDaysLine(b) {
|
|
3313
|
-
if (b.added === 0 || b.days == null || b.perWeek == null) return null;
|
|
3321
|
+
if (b.added === 0 || b.days == null || b.perWeek == null || b.caveat) return null;
|
|
3314
3322
|
const work = `At the crew's pace (about ${b.perWeek} a week) that's about ${plural$7(b.days, "day")} of work`;
|
|
3315
3323
|
if (b.slip == null || b.slip <= 0) return `${work}.`;
|
|
3316
3324
|
if (b.days >= b.slip) return `${work}: enough to explain all ${plural$7(b.slip, "day")} of the slip.`;
|
|
@@ -3318,24 +3326,28 @@ function barnaclesDaysLine(b) {
|
|
|
3318
3326
|
}
|
|
3319
3327
|
/** One line for the brief or a list: "+14 tasks (38%) since the baseline, 3 this week, about 65 days of work". */
|
|
3320
3328
|
function barnaclesShort(b) {
|
|
3321
|
-
const pct = b.share != null ? ` (${Math.round(b.share * 100)}%)` : "";
|
|
3329
|
+
const pct = b.share != null && !b.caveat ? ` (${Math.round(b.share * 100)}%)` : "";
|
|
3322
3330
|
const when = b.from === "baseline" ? "since the baseline" : "since its first week";
|
|
3323
3331
|
const week = b.thisWeek ? `, ${b.thisWeek} this week` : "";
|
|
3324
|
-
const days = b.days != null ? `, about ${plural$7(b.days, "day")} of work` : "";
|
|
3332
|
+
const days = b.days != null && !b.caveat ? `, about ${plural$7(b.days, "day")} of work` : "";
|
|
3325
3333
|
const top = b.adders.find((a) => a.personId);
|
|
3326
3334
|
const who = top && top.added * 2 > b.added ? `, mostly from ${top.name}` : "";
|
|
3327
3335
|
return `+${plural$7(b.added, "task")}${pct} ${when}${week}${days}${who}`;
|
|
3328
3336
|
}
|
|
3329
3337
|
/** Whether the brief names it: measured from a baseline, enough added, and enough of the plan. */
|
|
3330
3338
|
function briefWorthy(b) {
|
|
3331
|
-
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);
|
|
3332
3340
|
}
|
|
3333
|
-
var SETTLE_DAYS, BRIEF_MIN_ADDED, BRIEF_MIN_SHARE, noon$2, isoDay$3, plural$7;
|
|
3341
|
+
var SETTLE_DAYS, THIN_PLANNED, THIN_WEEKS, BULK_SHARE, BULK_MIN, BRIEF_MIN_ADDED, BRIEF_MIN_SHARE, noon$2, isoDay$3, plural$7;
|
|
3334
3342
|
var init_barnacles = __esmMin((() => {
|
|
3335
3343
|
init_date_fns();
|
|
3336
3344
|
init_format();
|
|
3337
3345
|
init_tide();
|
|
3338
3346
|
SETTLE_DAYS = 7;
|
|
3347
|
+
THIN_PLANNED = 5;
|
|
3348
|
+
THIN_WEEKS = 3;
|
|
3349
|
+
BULK_SHARE = .5;
|
|
3350
|
+
BULK_MIN = 5;
|
|
3339
3351
|
BRIEF_MIN_ADDED = 3;
|
|
3340
3352
|
BRIEF_MIN_SHARE = .15;
|
|
3341
3353
|
noon$2 = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
|
|
@@ -3358,7 +3370,7 @@ function projectKeyFor(name, taken) {
|
|
|
3358
3370
|
for (let n = 2;; n++) if (!taken.has(`${base}${n}`)) return `${base}${n}`;
|
|
3359
3371
|
}
|
|
3360
3372
|
/** Whether `a` has the better claim to the number both hold. */
|
|
3361
|
-
function first$
|
|
3373
|
+
function first$3(a, b, stored) {
|
|
3362
3374
|
const ka = kept(a, stored);
|
|
3363
3375
|
if (ka !== kept(b, stored)) return ka;
|
|
3364
3376
|
return made(a, b) <= 0;
|
|
@@ -3406,7 +3418,7 @@ function settleKeys(data, stored) {
|
|
|
3406
3418
|
}
|
|
3407
3419
|
const other = holder.get(n);
|
|
3408
3420
|
if (other) {
|
|
3409
|
-
const [keep, move] = first$
|
|
3421
|
+
const [keep, move] = first$3(other, t, stored) ? [other, t] : [t, other];
|
|
3410
3422
|
holder.set(n, keep);
|
|
3411
3423
|
loose.push(move);
|
|
3412
3424
|
} else holder.set(n, t);
|
|
@@ -3570,8 +3582,8 @@ function thanksSince(doc, since, keep = () => true) {
|
|
|
3570
3582
|
if (!target || !keep(target)) continue;
|
|
3571
3583
|
out.push({
|
|
3572
3584
|
thanks: r,
|
|
3573
|
-
from: first$
|
|
3574
|
-
to: first$
|
|
3585
|
+
from: first$2(doc, r.authorId),
|
|
3586
|
+
to: first$2(doc, r.toId),
|
|
3575
3587
|
for: target.title
|
|
3576
3588
|
});
|
|
3577
3589
|
}
|
|
@@ -3581,12 +3593,12 @@ function thanksSince(doc, since, keep = () => true) {
|
|
|
3581
3593
|
function thanksText(line) {
|
|
3582
3594
|
return `${line.from} to ${line.to} for ${line.for}: “${line.thanks.body.trim()}”`;
|
|
3583
3595
|
}
|
|
3584
|
-
var isThanks, first$
|
|
3596
|
+
var isThanks, first$2;
|
|
3585
3597
|
var init_thanks = __esmMin((() => {
|
|
3586
3598
|
init_indexes();
|
|
3587
3599
|
init_task_keys();
|
|
3588
3600
|
isThanks = (r) => r.kind === "thanks" && !!r.thanksFor && !!r.toId;
|
|
3589
|
-
first$
|
|
3601
|
+
first$2 = (doc, id) => doc.people.find((p) => p.id === id)?.name.split(" ")[0] ?? "Someone";
|
|
3590
3602
|
}));
|
|
3591
3603
|
|
|
3592
3604
|
//#endregion
|
|
@@ -3672,9 +3684,13 @@ function charterOverdue(project, today) {
|
|
|
3672
3684
|
const days = differenceInCalendarDays(parseIso(today), parseIso(project.startDate.slice(0, 10)));
|
|
3673
3685
|
return days >= 7 ? days : null;
|
|
3674
3686
|
}
|
|
3675
|
-
/**
|
|
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
|
+
*/
|
|
3676
3692
|
function charterItems(text) {
|
|
3677
|
-
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);
|
|
3678
3694
|
}
|
|
3679
3695
|
/** True when a task's title names an item: every meaningful word of a short item, or two of a longer one. */
|
|
3680
3696
|
function names(item, title) {
|
|
@@ -3746,8 +3762,8 @@ init_date_fns();
|
|
|
3746
3762
|
init_blockers();
|
|
3747
3763
|
init_date_text();
|
|
3748
3764
|
init_premortem();
|
|
3749
|
-
const day$
|
|
3750
|
-
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));
|
|
3751
3767
|
/** When the project was marked Done: its last move to Done in its health history. */
|
|
3752
3768
|
function finishedOn(project, tasks) {
|
|
3753
3769
|
const marked = [...project.healthHistory ?? []].reverse().find((h) => h.health === "done");
|
|
@@ -3820,7 +3836,7 @@ function voyageLog(data, projectId) {
|
|
|
3820
3836
|
};
|
|
3821
3837
|
}
|
|
3822
3838
|
const plural$6 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
3823
|
-
const md$2 = (iso) => format(day$
|
|
3839
|
+
const md$2 = (iso) => format(day$8(iso), "MMM d");
|
|
3824
3840
|
/** The three numbers at the top: how late, how much work, how many risks came true. */
|
|
3825
3841
|
function voyageStats(log) {
|
|
3826
3842
|
const happened = log.risks.filter((r) => r.happened).length;
|
|
@@ -4914,7 +4930,7 @@ var init_cascade = __esmMin((() => {
|
|
|
4914
4930
|
//#region src/lib/forecast.ts
|
|
4915
4931
|
init_cascade();
|
|
4916
4932
|
init_date_fns();
|
|
4917
|
-
const day$
|
|
4933
|
+
const day$7 = (iso) => /* @__PURE__ */ new Date(`${iso}T12:00:00`);
|
|
4918
4934
|
/**
|
|
4919
4935
|
* A straight-line estimate at completion from the burn so far: spend divided
|
|
4920
4936
|
* by the share of the schedule elapsed. Null before anything is spent or
|
|
@@ -4923,8 +4939,8 @@ const day$6 = (iso) => /* @__PURE__ */ new Date(`${iso}T12:00:00`);
|
|
|
4923
4939
|
*/
|
|
4924
4940
|
function forecastAtCompletion(item, now) {
|
|
4925
4941
|
if (item.spent <= 0) return null;
|
|
4926
|
-
const total = differenceInCalendarDays(day$
|
|
4927
|
-
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;
|
|
4928
4944
|
if (total <= 0 || gone <= 0) return null;
|
|
4929
4945
|
const elapsed = Math.min(1, gone / total);
|
|
4930
4946
|
const eac = Math.round(item.spent / elapsed);
|
|
@@ -5047,7 +5063,7 @@ var init_flow = __esmMin((() => {
|
|
|
5047
5063
|
//#region src/lib/phasing.ts
|
|
5048
5064
|
init_flow();
|
|
5049
5065
|
init_date_fns();
|
|
5050
|
-
const day$
|
|
5066
|
+
const day$6 = (iso) => /* @__PURE__ */ new Date(`${iso}T12:00:00`);
|
|
5051
5067
|
function monthKey(iso) {
|
|
5052
5068
|
return iso.slice(0, 7);
|
|
5053
5069
|
}
|
|
@@ -5063,7 +5079,7 @@ function monthsBetween(from, to) {
|
|
|
5063
5079
|
return out;
|
|
5064
5080
|
}
|
|
5065
5081
|
function daysIn(key) {
|
|
5066
|
-
return getDaysInMonth(day$
|
|
5082
|
+
return getDaysInMonth(day$6(`${key}-01`));
|
|
5067
5083
|
}
|
|
5068
5084
|
/** How many of the schedule's days fall in `key`. */
|
|
5069
5085
|
function overlapDays(key, start, end) {
|
|
@@ -5071,7 +5087,7 @@ function overlapDays(key, start, end) {
|
|
|
5071
5087
|
const lastDay = `${key}-${String(daysIn(key)).padStart(2, "0")}`;
|
|
5072
5088
|
const from = start > first ? start : first;
|
|
5073
5089
|
const to = end < lastDay ? end : lastDay;
|
|
5074
|
-
return to < from ? 0 : differenceInCalendarDays(day$
|
|
5090
|
+
return to < from ? 0 : differenceInCalendarDays(day$6(to), day$6(from)) + 1;
|
|
5075
5091
|
}
|
|
5076
5092
|
/**
|
|
5077
5093
|
* The budget spread straight across the schedule: each month gets the share
|
|
@@ -5081,7 +5097,7 @@ function overlapDays(key, start, end) {
|
|
|
5081
5097
|
function evenPhasing(project) {
|
|
5082
5098
|
const end = project.endDate < project.startDate ? project.startDate : project.endDate;
|
|
5083
5099
|
const months = monthsBetween(project.startDate, end);
|
|
5084
|
-
const total = differenceInCalendarDays(day$
|
|
5100
|
+
const total = differenceInCalendarDays(day$6(end), day$6(project.startDate)) + 1;
|
|
5085
5101
|
const out = {};
|
|
5086
5102
|
let placed = 0;
|
|
5087
5103
|
months.forEach((k, i) => {
|
|
@@ -5191,7 +5207,7 @@ function hasBaseline(p) {
|
|
|
5191
5207
|
/** Days the end has moved past the baseline end; negative when it came in. Null without a baseline. */
|
|
5192
5208
|
function slipDays(p) {
|
|
5193
5209
|
if (!p.baselineEnd) return null;
|
|
5194
|
-
return differenceInCalendarDays(day$
|
|
5210
|
+
return differenceInCalendarDays(day$5(p.endDate), day$5(p.baselineEnd));
|
|
5195
5211
|
}
|
|
5196
5212
|
/**
|
|
5197
5213
|
* The projects with the current dates committed as their baseline; ones that
|
|
@@ -5213,10 +5229,10 @@ function slipLabel(p) {
|
|
|
5213
5229
|
const n = Math.abs(slip);
|
|
5214
5230
|
return slip > 0 ? `slipped ${n} day${n === 1 ? "" : "s"}` : `${n} day${n === 1 ? "" : "s"} early`;
|
|
5215
5231
|
}
|
|
5216
|
-
var day$
|
|
5232
|
+
var day$5;
|
|
5217
5233
|
var init_baseline = __esmMin((() => {
|
|
5218
5234
|
init_date_fns();
|
|
5219
|
-
day$
|
|
5235
|
+
day$5 = (iso) => /* @__PURE__ */ new Date(`${iso}T12:00:00`);
|
|
5220
5236
|
}));
|
|
5221
5237
|
|
|
5222
5238
|
//#endregion
|
|
@@ -5245,7 +5261,7 @@ const ZOOMS = [
|
|
|
5245
5261
|
step: 3
|
|
5246
5262
|
}
|
|
5247
5263
|
];
|
|
5248
|
-
const day$
|
|
5264
|
+
const day$4 = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
|
|
5249
5265
|
/** Names in the order people expect: "Phase 2" before "Phase 10". */
|
|
5250
5266
|
const byName = (a, b) => a.localeCompare(b, void 0, { numeric: true });
|
|
5251
5267
|
const isoDay$2 = (d) => format(d, "yyyy-MM-dd");
|
|
@@ -5255,7 +5271,7 @@ const isoDay$2 = (d) => format(d, "yyyy-MM-dd");
|
|
|
5255
5271
|
*/
|
|
5256
5272
|
function roadmapWindow(zoom, today, shift = 0) {
|
|
5257
5273
|
const z = ZOOMS.find((x) => x.id === zoom) ?? ZOOMS[1];
|
|
5258
|
-
const first = addMonths$1(startOfMonth(day$
|
|
5274
|
+
const first = addMonths$1(startOfMonth(day$4(today)), -1 + shift * z.step);
|
|
5259
5275
|
const after = addMonths$1(first, z.months);
|
|
5260
5276
|
const days = differenceInCalendarDays(after, first);
|
|
5261
5277
|
const months = Array.from({ length: z.months }, (_, i) => {
|
|
@@ -5279,15 +5295,15 @@ function roadmapWindow(zoom, today, shift = 0) {
|
|
|
5279
5295
|
function at$1(iso, w) {
|
|
5280
5296
|
if (!iso) return null;
|
|
5281
5297
|
if (iso < w.start || iso >= w.end) return null;
|
|
5282
|
-
return differenceInCalendarDays(day$
|
|
5298
|
+
return differenceInCalendarDays(day$4(iso), day$4(w.start)) / w.days;
|
|
5283
5299
|
}
|
|
5284
5300
|
/** A bar for a start–end span, clipped to the window; null when it's wholly outside. */
|
|
5285
5301
|
function span(start, end, w) {
|
|
5286
5302
|
if (!start || !end || end < w.start || start >= w.end) return null;
|
|
5287
5303
|
const from = start < w.start ? w.start : start;
|
|
5288
|
-
const until = end >= w.end ? w.end : isoDay$2(new Date(day$
|
|
5289
|
-
const left = differenceInCalendarDays(day$
|
|
5290
|
-
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;
|
|
5291
5307
|
return {
|
|
5292
5308
|
left,
|
|
5293
5309
|
width: Math.max(right - left, 1 / w.days),
|
|
@@ -7009,7 +7025,7 @@ function bottleState(note, ctx) {
|
|
|
7009
7025
|
/** "opens on Nov 14", "opens when Factory buy-off is reached", "opens if Ridge Runner launch turns Off track" */
|
|
7010
7026
|
function opensText(opens, ctx) {
|
|
7011
7027
|
if (!opens) return "open now";
|
|
7012
|
-
if (opens.on === "date") return `opens on ${day$
|
|
7028
|
+
if (opens.on === "date") return `opens on ${day$3(opens.date)}`;
|
|
7013
7029
|
if (opens.on === "milestone") {
|
|
7014
7030
|
const m = ctx.milestones.find((x) => x.id === opens.milestoneId);
|
|
7015
7031
|
return m ? `opens when ${m.name} is reached` : "opens when its milestone is reached";
|
|
@@ -7043,7 +7059,7 @@ function freshlyOpened(data, projects, now) {
|
|
|
7043
7059
|
}
|
|
7044
7060
|
return out.sort((a, b) => (b.state.openedOn ?? "").localeCompare(a.state.openedOn ?? ""));
|
|
7045
7061
|
}
|
|
7046
|
-
var BOTTLE_FRESH_DAYS, isBottle, sealed, day$
|
|
7062
|
+
var BOTTLE_FRESH_DAYS, isBottle, sealed, day$3;
|
|
7047
7063
|
var init_bottle = __esmMin((() => {
|
|
7048
7064
|
init_date_fns();
|
|
7049
7065
|
init_date_text();
|
|
@@ -7055,7 +7071,7 @@ var init_bottle = __esmMin((() => {
|
|
|
7055
7071
|
open: false,
|
|
7056
7072
|
openedOn: null
|
|
7057
7073
|
};
|
|
7058
|
-
day$
|
|
7074
|
+
day$3 = (iso) => format(parseIso(iso.slice(0, 10)), "MMM d");
|
|
7059
7075
|
}));
|
|
7060
7076
|
|
|
7061
7077
|
//#endregion
|
|
@@ -7981,11 +7997,11 @@ function bridgeSince(program, now) {
|
|
|
7981
7997
|
return addDays(now, -7).toISOString();
|
|
7982
7998
|
}
|
|
7983
7999
|
const after = (at, since) => !!at && !Number.isNaN(Date.parse(at)) && Date.parse(at) > Date.parse(since);
|
|
7984
|
-
const first = (name) => name?.split(" ")[0] ?? null;
|
|
8000
|
+
const first$1 = (name) => name?.split(" ")[0] ?? null;
|
|
7985
8001
|
/** "RR-4 Draft the copy (Sam)", with the key when it has one. */
|
|
7986
8002
|
function taskLine$1(t, project, doc) {
|
|
7987
8003
|
const key = taskKey(t, project);
|
|
7988
|
-
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;
|
|
7989
8005
|
return `${key ? `${key} ` : ""}${t.title}${who ? ` (${who})` : ""}`;
|
|
7990
8006
|
}
|
|
7991
8007
|
function listed(prefix, items) {
|
|
@@ -8141,7 +8157,7 @@ function meetingSummary(doc, slides, logged) {
|
|
|
8141
8157
|
const project = t ? projects.get(t.projectId) : void 0;
|
|
8142
8158
|
const program = project ? doc.programs.find((p) => p.id === project.programId) : void 0;
|
|
8143
8159
|
if (!t || !project || !program) continue;
|
|
8144
|
-
const who = t.assigneeId ? first(people.get(t.assigneeId)?.name) : null;
|
|
8160
|
+
const who = t.assigneeId ? first$1(people.get(t.assigneeId)?.name) : null;
|
|
8145
8161
|
const key = taskKey(t, project);
|
|
8146
8162
|
partOf(program).lines.push({
|
|
8147
8163
|
kind: "action",
|
|
@@ -9077,11 +9093,11 @@ function dollars(value) {
|
|
|
9077
9093
|
const n = Number(value ?? 0);
|
|
9078
9094
|
return whole.format(Number.isFinite(n) ? n : 0);
|
|
9079
9095
|
}
|
|
9080
|
-
function day$
|
|
9096
|
+
function day$2(value) {
|
|
9081
9097
|
return value ? shortDate(value) : "no date";
|
|
9082
9098
|
}
|
|
9083
9099
|
/** Whole days from one ISO day to another. */
|
|
9084
|
-
function daysBetween(from, to) {
|
|
9100
|
+
function daysBetween$1(from, to) {
|
|
9085
9101
|
const a = Date.parse(`${from.slice(0, 10)}T12:00:00Z`);
|
|
9086
9102
|
const b = Date.parse(`${to.slice(0, 10)}T12:00:00Z`);
|
|
9087
9103
|
if (Number.isNaN(a) || Number.isNaN(b)) return 0;
|
|
@@ -9089,7 +9105,7 @@ function daysBetween(from, to) {
|
|
|
9089
9105
|
}
|
|
9090
9106
|
function slip(from, to) {
|
|
9091
9107
|
if (!from || !to) return "";
|
|
9092
|
-
const d = daysBetween(from, to);
|
|
9108
|
+
const d = daysBetween$1(from, to);
|
|
9093
9109
|
if (d === 0) return "";
|
|
9094
9110
|
const n = Math.abs(d);
|
|
9095
9111
|
return ` (${n} day${n === 1 ? "" : "s"} ${d > 0 ? "later" : "earlier"})`;
|
|
@@ -9157,15 +9173,15 @@ function describeEvent(e, names) {
|
|
|
9157
9173
|
};
|
|
9158
9174
|
case "dueDate": return {
|
|
9159
9175
|
action: "moved the due date of",
|
|
9160
|
-
detail: `from ${day$
|
|
9176
|
+
detail: `from ${day$2(from)} to ${day$2(to)}${slip(from, to)}`
|
|
9161
9177
|
};
|
|
9162
9178
|
case "startDate": return {
|
|
9163
9179
|
action: "moved the start of",
|
|
9164
|
-
detail: `from ${day$
|
|
9180
|
+
detail: `from ${day$2(from)} to ${day$2(to)}${slip(from, to)}`
|
|
9165
9181
|
};
|
|
9166
9182
|
case "endDate": return {
|
|
9167
9183
|
action: "moved the end of",
|
|
9168
|
-
detail: `from ${day$
|
|
9184
|
+
detail: `from ${day$2(from)} to ${day$2(to)}${slip(from, to)}`
|
|
9169
9185
|
};
|
|
9170
9186
|
case "budget": return {
|
|
9171
9187
|
action: "changed the budget of",
|
|
@@ -18956,7 +18972,7 @@ const SIMILAR_MIN = 3;
|
|
|
18956
18972
|
/** A finished task that took longer than this sat somewhere; it counts as this long. */
|
|
18957
18973
|
const SIMILAR_CAP = 15;
|
|
18958
18974
|
const firstName$1 = (name) => name.trim().split(/\s+/)[0] || name;
|
|
18959
|
-
const day = (iso) => iso ? iso.slice(0, 10) : null;
|
|
18975
|
+
const day$1 = (iso) => iso ? iso.slice(0, 10) : null;
|
|
18960
18976
|
function quantile(sorted, q) {
|
|
18961
18977
|
const at = (sorted.length - 1) * q;
|
|
18962
18978
|
const lo = Math.floor(at);
|
|
@@ -18972,7 +18988,7 @@ function pilotDate(task, data, localToday, at) {
|
|
|
18972
18988
|
const owner = byId(data.people).get(task.assigneeId);
|
|
18973
18989
|
if (!owner || owner.kind === "agent") return null;
|
|
18974
18990
|
const today = at ? todayFor(owner, at, localToday) : localToday;
|
|
18975
|
-
const chosen = day(task.dueDate);
|
|
18991
|
+
const chosen = day$1(task.dueDate);
|
|
18976
18992
|
let underWay = 0;
|
|
18977
18993
|
const dated = [];
|
|
18978
18994
|
const finished = [];
|
|
@@ -18982,14 +18998,14 @@ function pilotDate(task, data, localToday, at) {
|
|
|
18982
18998
|
if (t.projectId === task.projectId && t.completedAt) finished.push(t);
|
|
18983
18999
|
continue;
|
|
18984
19000
|
}
|
|
18985
|
-
const due = day(t.dueDate);
|
|
19001
|
+
const due = day$1(t.dueDate);
|
|
18986
19002
|
if (t.status === "doing" || t.status === "review") underWay += 1;
|
|
18987
19003
|
else if (due) dated.push(due);
|
|
18988
19004
|
}
|
|
18989
19005
|
const perDay = Math.max(.2, (owner.weeklyCapacity ?? 5) / 5);
|
|
18990
19006
|
let similar = null;
|
|
18991
19007
|
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);
|
|
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);
|
|
18993
19009
|
similar = {
|
|
18994
19010
|
low: Math.round(quantile(took, .25)),
|
|
18995
19011
|
high: Math.round(quantile(took, .75)),
|
|
@@ -18997,7 +19013,7 @@ function pilotDate(task, data, localToday, at) {
|
|
|
18997
19013
|
};
|
|
18998
19014
|
}
|
|
18999
19015
|
const ownDays = Math.min(5, Math.max(1, similar?.low ?? 1));
|
|
19000
|
-
const backOn = isAway(owner, today) ? day(owner.away.until) : null;
|
|
19016
|
+
const backOn = isAway(owner, today) ? day$1(owner.away.until) : null;
|
|
19001
19017
|
const start = backOn && backOn > today ? dayBefore(backOn) : today;
|
|
19002
19018
|
const tasks = byId(data.tasks);
|
|
19003
19019
|
const projects = byId(data.projects);
|
|
@@ -19008,7 +19024,7 @@ function pilotDate(task, data, localToday, at) {
|
|
|
19008
19024
|
for (const id of blockers) {
|
|
19009
19025
|
const b = tasks.get(id);
|
|
19010
19026
|
if (!b || b.status === "done") continue;
|
|
19011
|
-
const due = day(b.dueDate);
|
|
19027
|
+
const due = day$1(b.dueDate);
|
|
19012
19028
|
if (!due) {
|
|
19013
19029
|
undated ??= b;
|
|
19014
19030
|
continue;
|
|
@@ -19077,7 +19093,7 @@ const plural$3 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
|
19077
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". */
|
|
19078
19094
|
function pilotHeadline(p, chosen, you = false) {
|
|
19079
19095
|
const who = you ? "You" : firstName$1(p.owner.name);
|
|
19080
|
-
if (!day(chosen)) return `${who} could likely have this by ${shortDate(p.earliest)}`;
|
|
19096
|
+
if (!day$1(chosen)) return `${who} could likely have this by ${shortDate(p.earliest)}`;
|
|
19081
19097
|
return `${you ? "Your" : `${who}'s`} queue says ${shortDate(p.earliest)} at the earliest`;
|
|
19082
19098
|
}
|
|
19083
19099
|
/** Why: the queue, time away, what it waits on, and how long similar tasks took. */
|
|
@@ -19410,6 +19426,7 @@ function barnaclesData(b) {
|
|
|
19410
19426
|
daysOfWork: b.days,
|
|
19411
19427
|
slipDays: b.slip,
|
|
19412
19428
|
daysLine: barnaclesDaysLine(b),
|
|
19429
|
+
caveat: b.caveat,
|
|
19413
19430
|
byWeek: b.weeks.filter((w) => w.added > 0).map((w) => ({
|
|
19414
19431
|
weekOf: w.week,
|
|
19415
19432
|
added: w.added
|
|
@@ -22098,6 +22115,14 @@ function hierarchySlice({ get, write }) {
|
|
|
22098
22115
|
const program = get().programs.find((p) => p.id === programId);
|
|
22099
22116
|
get().log(`${reset ? "reset" : "set"} the baseline on ${program?.name ?? "a program"}`, `/programs/${programId}`);
|
|
22100
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
|
+
},
|
|
22101
22126
|
setPriorityRules: (rules) => {
|
|
22102
22127
|
write({ priorityRules: rules ? normalizeRules(rules) : null });
|
|
22103
22128
|
},
|
|
@@ -26597,6 +26622,292 @@ function ShorePanel({ id, width, height, go, active, onClose }) {
|
|
|
26597
26622
|
});
|
|
26598
26623
|
}
|
|
26599
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
|
+
|
|
26600
26911
|
//#endregion
|
|
26601
26912
|
//#region src/tui/views/bands.tsx
|
|
26602
26913
|
/**
|
|
@@ -29469,7 +29780,7 @@ const SAVE_LABEL = {
|
|
|
29469
29780
|
offline: ["● offline, will save", "bad"]
|
|
29470
29781
|
};
|
|
29471
29782
|
function App({ ui, startIn }) {
|
|
29472
|
-
const { store, sync, account, harbors, today, remember } = useDeps();
|
|
29783
|
+
const { store, sync, account, harbors, today, remember, seen } = useDeps();
|
|
29473
29784
|
const { exit } = useApp();
|
|
29474
29785
|
const { columns, rows } = useWindowSize();
|
|
29475
29786
|
const harborName = useHarbor((s) => s.harborName);
|
|
@@ -29490,6 +29801,21 @@ function App({ ui, startIn }) {
|
|
|
29490
29801
|
const [overlay, setOverlay] = useState(null);
|
|
29491
29802
|
const [focus, setFocus] = useState("main");
|
|
29492
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
|
+
};
|
|
29493
29819
|
useEffect(() => {
|
|
29494
29820
|
if (!notice) return;
|
|
29495
29821
|
const t = setTimeout(() => ui.setState({ notice: null }), 5e3);
|
|
@@ -29939,6 +30265,11 @@ function App({ ui, startIn }) {
|
|
|
29939
30265
|
portfolioId: page.id,
|
|
29940
30266
|
onBack: back
|
|
29941
30267
|
}, page.id);
|
|
30268
|
+
else if (screen === "today" && welcome) screenNode = /* @__PURE__ */ jsx(WelcomeView, {
|
|
30269
|
+
...viewProps,
|
|
30270
|
+
when: welcome,
|
|
30271
|
+
onDone: doneWelcome
|
|
30272
|
+
});
|
|
29942
30273
|
else if (screen === "today") screenNode = /* @__PURE__ */ jsx(TodayView, { ...viewProps });
|
|
29943
30274
|
else if (screen === "inbox") screenNode = /* @__PURE__ */ jsx(InboxView, { ...viewProps });
|
|
29944
30275
|
else if (screen === "projects") screenNode = /* @__PURE__ */ jsx(ProjectsView, { ...viewProps });
|
|
@@ -30807,7 +31138,8 @@ async function startApp(deps, options = {}) {
|
|
|
30807
31138
|
harbors,
|
|
30808
31139
|
today,
|
|
30809
31140
|
remember: deps.remember,
|
|
30810
|
-
browse: deps.browse
|
|
31141
|
+
browse: deps.browse,
|
|
31142
|
+
seen: deps.seen
|
|
30811
31143
|
},
|
|
30812
31144
|
children: /* @__PURE__ */ jsx(App, {
|
|
30813
31145
|
ui,
|
|
@@ -30891,7 +31223,8 @@ async function runApp(opts) {
|
|
|
30891
31223
|
firstRun,
|
|
30892
31224
|
remember: opts.remember,
|
|
30893
31225
|
browse: opts.browse,
|
|
30894
|
-
startIn: opts.startIn
|
|
31226
|
+
startIn: opts.startIn,
|
|
31227
|
+
seen: opts.seen
|
|
30895
31228
|
}, { alternateScreen: true });
|
|
30896
31229
|
await instance.waitUntilExit();
|
|
30897
31230
|
}
|
package/lib/args.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* A small argument reader: positionals, `--flag`, `--key value` and
|
|
3
|
-
* `--key=value`.
|
|
3
|
+
* `--key=value`. An option named in `repeat` may be given more than once and
|
|
4
|
+
* reads as a list. Unknown flags are an error, so a typo doesn't quietly do
|
|
4
5
|
* something else.
|
|
5
6
|
*/
|
|
6
|
-
export function parseArgs(argv, { flags = [], options = [] } = {}) {
|
|
7
|
+
export function parseArgs(argv, { flags = [], options = [], repeat = [] } = {}) {
|
|
7
8
|
const positionals = [];
|
|
8
9
|
const values = {};
|
|
9
10
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -20,7 +21,7 @@ export function parseArgs(argv, { flags = [], options = [] } = {}) {
|
|
|
20
21
|
if (flags.includes(key)) {
|
|
21
22
|
if (inline !== undefined) throw new Error(`--${key} takes no value.`);
|
|
22
23
|
values[key] = true;
|
|
23
|
-
} else if (options.includes(key)) {
|
|
24
|
+
} else if (options.includes(key) || repeat.includes(key)) {
|
|
24
25
|
const value = inline ?? argv[++i];
|
|
25
26
|
if (
|
|
26
27
|
value === undefined ||
|
|
@@ -29,7 +30,7 @@ export function parseArgs(argv, { flags = [], options = [] } = {}) {
|
|
|
29
30
|
) {
|
|
30
31
|
throw new Error(`--${key} needs a value.`);
|
|
31
32
|
}
|
|
32
|
-
values[key] = value;
|
|
33
|
+
values[key] = repeat.includes(key) ? [...(values[key] ?? []), value] : value;
|
|
33
34
|
} else {
|
|
34
35
|
throw new Error(`Unknown option --${key}.`);
|
|
35
36
|
}
|
package/lib/cli.mjs
CHANGED
|
@@ -103,6 +103,9 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
103
103
|
harbor barnacles PROJECT work added since the plan was set:
|
|
104
104
|
by week, by who, and the days of
|
|
105
105
|
slip it explains
|
|
106
|
+
harbor baseline PROJECT [--reset] sets a project's baseline: its
|
|
107
|
+
dates now, and growth counted
|
|
108
|
+
from now
|
|
106
109
|
harbor hill [--program P] where each project is on its hill
|
|
107
110
|
harbor hill PROJECT --at 0-100|top|done move it: up to 50 is figuring it
|
|
108
111
|
out, past 50 is getting it done
|
|
@@ -228,9 +231,10 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
228
231
|
harbor voyage PROJECT ["YOUR LINE"] a Done project's voyage log, and
|
|
229
232
|
your line in it
|
|
230
233
|
harbor charter PROJECT [--why T] [--in T] [--out T] [--done-when T]
|
|
231
|
-
[--calls
|
|
232
|
-
a project's five-line charter
|
|
233
|
-
|
|
234
|
+
[--calls NAME]
|
|
235
|
+
a project's five-line charter
|
|
236
|
+
(why, in, out, done when, who
|
|
237
|
+
makes the calls): read or set
|
|
234
238
|
harbor note PROJECT ["NOTE"] [--opens DATE|--milestone NAME|--off-track]
|
|
235
239
|
a note for later: sealed until
|
|
236
240
|
its day, milestone, or Off track
|
|
@@ -293,9 +297,10 @@ function banner(io, env, words) {
|
|
|
293
297
|
const THEMES = ["auto", "light", "dark"];
|
|
294
298
|
|
|
295
299
|
const COMMON = { flags: ["json", "help"], options: ["harbor"] };
|
|
296
|
-
const spec = (flags = [], options = []) => ({
|
|
300
|
+
const spec = (flags = [], options = [], repeat = []) => ({
|
|
297
301
|
flags: [...COMMON.flags, ...flags],
|
|
298
302
|
options: [...COMMON.options, ...options],
|
|
303
|
+
repeat,
|
|
299
304
|
});
|
|
300
305
|
|
|
301
306
|
/**
|
|
@@ -778,14 +783,21 @@ const DETAIL = {
|
|
|
778
783
|
charter: [
|
|
779
784
|
" harbor charter PROJECT reads its charter, and any open task",
|
|
780
785
|
" that names something it leaves out",
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
786
|
+
" Set any line, or all at once:",
|
|
787
|
+
' --why "TEXT" why the project exists',
|
|
788
|
+
' --in "A, B" what it covers (the voyage log counts the',
|
|
789
|
+
" finished tasks naming these)",
|
|
790
|
+
' --out "C, D" what it leaves out (tasks naming these show as',
|
|
791
|
+
" Out of scope?)",
|
|
792
|
+
' --done-when "TEXT" what finished looks like',
|
|
793
|
+
' --calls "NAME" who makes the calls when scope is in question',
|
|
784
794
|
"",
|
|
785
|
-
"
|
|
786
|
-
|
|
787
|
-
"
|
|
788
|
-
"
|
|
795
|
+
" Items in --in and --out are split on commas; give --in or --out again",
|
|
796
|
+
' for an item that has a comma in it: --out "Coupons" --out "Gift cards,',
|
|
797
|
+
' vouchers". none clears a line: --out none. The review lists running',
|
|
798
|
+
" projects still without a charter a week after they started. A charter",
|
|
799
|
+
" is for the harbor's members: share links and public roadmaps never show",
|
|
800
|
+
" it. PROJECT is a name, a key like RR, or an id.",
|
|
789
801
|
],
|
|
790
802
|
sound: [
|
|
791
803
|
" harbor sound TASK reads its sounding: who hasn't answered,",
|
|
@@ -944,6 +956,30 @@ const DETAIL = {
|
|
|
944
956
|
" days before it (negative: that many over). PROJECT is a name, a key",
|
|
945
957
|
" like RR, or an id.",
|
|
946
958
|
],
|
|
959
|
+
clock: [
|
|
960
|
+
" harbor clock reads everyone's local time, and",
|
|
961
|
+
" who's working now",
|
|
962
|
+
" harbor clock NAME reads one person's",
|
|
963
|
+
" harbor clock [NAME] --zone ZONE writes: sets a time zone (yours",
|
|
964
|
+
" without NAME)",
|
|
965
|
+
" harbor clock [NAME] --hours 9-17 writes: sets working hours",
|
|
966
|
+
"",
|
|
967
|
+
" ZONE is an IANA name like America/New_York or Europe/London, here for",
|
|
968
|
+
" this machine's zone, or none to clear the zone and hours. --hours is",
|
|
969
|
+
" one start and end in that zone, like 9-17, 8:30-16:30 or 9am-5:30pm,",
|
|
970
|
+
" Monday to Friday: one span a day that ends after it starts, so no split",
|
|
971
|
+
" day or overnight shift. Without --hours a day runs 9 to 17. Agents",
|
|
972
|
+
" don't keep hours.",
|
|
973
|
+
],
|
|
974
|
+
baseline: [
|
|
975
|
+
" harbor baseline PROJECT writes: commits its start and end",
|
|
976
|
+
" dates as its baseline",
|
|
977
|
+
" harbor baseline PROJECT --reset writes: replaces one already set",
|
|
978
|
+
"",
|
|
979
|
+
" The timeline then shows slips against those dates, and harbor barnacles",
|
|
980
|
+
" counts tasks added from now on as growth. On the website a program's",
|
|
981
|
+
" Set baseline does the same for all its projects.",
|
|
982
|
+
],
|
|
947
983
|
barnacles: [
|
|
948
984
|
" harbor barnacles PROJECT reads how much the plan has grown",
|
|
949
985
|
"",
|
|
@@ -1303,6 +1339,7 @@ export async function run(argv, io = {}) {
|
|
|
1303
1339
|
const now = await readCredentials(env);
|
|
1304
1340
|
return now ? { ...c, harbor: now.harbor ?? null, harborName: now.harborName ?? null } : c;
|
|
1305
1341
|
};
|
|
1342
|
+
const settings = await readSettings(env);
|
|
1306
1343
|
await runApp({
|
|
1307
1344
|
source: tokenSource({
|
|
1308
1345
|
creds,
|
|
@@ -1316,9 +1353,20 @@ export async function run(argv, io = {}) {
|
|
|
1316
1353
|
const now = (await readCredentials(env)) ?? creds;
|
|
1317
1354
|
await writeCredentials({ ...now, harbor: h.id, harborName: h.name }, env);
|
|
1318
1355
|
},
|
|
1319
|
-
theme:
|
|
1356
|
+
theme: settings.theme,
|
|
1320
1357
|
browse: io.browse ?? openBrowser,
|
|
1321
1358
|
...(command === "bridge" ? { startIn: "bridge" } : {}),
|
|
1359
|
+
// When this machine last saw you in each harbor: the app opens on a
|
|
1360
|
+
// catch-up after a few days away (src/lib/welcome-back.ts).
|
|
1361
|
+
seen: {
|
|
1362
|
+
last: (id) => settings.seen?.[id] ?? null,
|
|
1363
|
+
mark: (id, at) => {
|
|
1364
|
+
settings.seen = { ...settings.seen, [id]: at };
|
|
1365
|
+
void readSettings(env)
|
|
1366
|
+
.then((now) => writeSettings({ ...now, seen: { ...now.seen, [id]: at } }, env))
|
|
1367
|
+
.catch(() => {});
|
|
1368
|
+
},
|
|
1369
|
+
},
|
|
1322
1370
|
});
|
|
1323
1371
|
return 0;
|
|
1324
1372
|
}
|
|
@@ -2680,11 +2728,15 @@ export async function run(argv, io = {}) {
|
|
|
2680
2728
|
}
|
|
2681
2729
|
case "charter": {
|
|
2682
2730
|
const { positionals, values, harbor } = parse(
|
|
2683
|
-
spec([], ["why", "
|
|
2731
|
+
spec([], ["why", "done-when", "calls"], ["in", "out"]),
|
|
2684
2732
|
);
|
|
2685
2733
|
const project = one(positionals, 'project: harbor charter "NAME"');
|
|
2686
|
-
// none clears a line, as --due none does.
|
|
2687
|
-
|
|
2734
|
+
// none clears a line, as --due none does. --in and --out given more than
|
|
2735
|
+
// once write one item each, as a bulleted list, so an item keeps its commas.
|
|
2736
|
+
const line = (v) => {
|
|
2737
|
+
if (Array.isArray(v)) v = v.length > 1 ? v.map((x) => `- ${x.trim()}`).join("\n") : v[0];
|
|
2738
|
+
return v.trim().toLowerCase() === "none" ? "" : v;
|
|
2739
|
+
};
|
|
2688
2740
|
const lines = Object.fromEntries(
|
|
2689
2741
|
[
|
|
2690
2742
|
["why", values.why],
|
|
@@ -2827,6 +2879,19 @@ export async function run(argv, io = {}) {
|
|
|
2827
2879
|
if (!project) throw new HarborError("Say which project: harbor barnacles PROJECT.");
|
|
2828
2880
|
return call("scope_growth", { harbor, project }, barnaclesText);
|
|
2829
2881
|
}
|
|
2882
|
+
case "baseline": {
|
|
2883
|
+
const { positionals, values, harbor } = parse(spec(["reset"]));
|
|
2884
|
+
const project = positionals.join(" ").trim();
|
|
2885
|
+
if (!project) throw new HarborError("Say which project: harbor baseline PROJECT.");
|
|
2886
|
+
return call(
|
|
2887
|
+
"set_baseline",
|
|
2888
|
+
{ harbor, project, ...(values.reset ? { reset: true } : {}) },
|
|
2889
|
+
(r) =>
|
|
2890
|
+
r.changed
|
|
2891
|
+
? `Set ${r.project}'s baseline: ${r.baselineStart} to ${r.baselineEnd}. harbor barnacles counts tasks added from now on.`
|
|
2892
|
+
: `${r.project} already has a baseline (${r.baselineStart} to ${r.baselineEnd}); --reset replaces it.`,
|
|
2893
|
+
);
|
|
2894
|
+
}
|
|
2830
2895
|
case "voyage": {
|
|
2831
2896
|
const { positionals, harbor } = parse(spec());
|
|
2832
2897
|
const [project, ...words] = positionals;
|
package/lib/format.mjs
CHANGED
|
@@ -635,6 +635,7 @@ export function barnaclesText(r) {
|
|
|
635
635
|
}
|
|
636
636
|
head.push(`${r.headline} (${r.planned} planned, ${r.addedThisWeek} added this week).`);
|
|
637
637
|
if (r.daysLine) head.push(r.daysLine);
|
|
638
|
+
if (r.caveat) head.push(r.caveat);
|
|
638
639
|
const top = Math.max(1, ...r.byWeek.map((w) => w.added));
|
|
639
640
|
const weeks = r.byWeek.map(
|
|
640
641
|
(w) => ` w/c ${w.weekOf} ${"█".repeat(Math.max(1, Math.round((w.added / top) * 20)))} ${w.added}`,
|
|
@@ -643,7 +644,7 @@ export function barnaclesText(r) {
|
|
|
643
644
|
const from =
|
|
644
645
|
r.measuredFrom === "when the baseline was set"
|
|
645
646
|
? `Counted from when the baseline was set, ${r.since.slice(0, 10)}.`
|
|
646
|
-
: `Counted from a week after its first task was made, ${r.since.slice(0, 10)};
|
|
647
|
+
: `Counted from a week after its first task was made, ${r.since.slice(0, 10)}; harbor baseline "${r.project}" counts from then instead.`;
|
|
647
648
|
return [...head, "", "Added by week:", ...weeks, "", `By who: ${who}`, "", from].join("\n");
|
|
648
649
|
}
|
|
649
650
|
|
|
@@ -751,12 +752,16 @@ const CHARTER_LINES = [
|
|
|
751
752
|
export function charterText(r) {
|
|
752
753
|
const project = r.name ?? r.project;
|
|
753
754
|
if (!r.charter) {
|
|
754
|
-
return `${project} has no charter yet.
|
|
755
|
+
return `${project} has no charter yet. Set any line, or all at once:\n harbor charter "${project}" --why "…" --in "…, …" --out "…, …" --done-when "…" --calls "…"`;
|
|
755
756
|
}
|
|
756
757
|
const lines = [`Charter · ${project}`];
|
|
757
758
|
const width = Math.max(...CHARTER_LINES.map(([, label]) => label.length));
|
|
758
759
|
for (const [key, label] of CHARTER_LINES) {
|
|
759
|
-
|
|
760
|
+
// A bulleted In or Out (--in given more than once) keeps its lines lined up.
|
|
761
|
+
if (r.charter[key])
|
|
762
|
+
lines.push(
|
|
763
|
+
` ${label.padEnd(width)} ${String(r.charter[key]).replace(/\n/g, `\n ${" ".repeat(width)} `)}`,
|
|
764
|
+
);
|
|
760
765
|
}
|
|
761
766
|
if (r.outOfScope?.length) {
|
|
762
767
|
lines.push("", "Out of scope?");
|