@harborppm/cli 0.96.0 → 0.98.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 +710 -65
- package/lib/cli.mjs +158 -0
- package/lib/format.mjs +67 -0
- package/package.json +1 -1
package/dist/app.mjs
CHANGED
|
@@ -2688,13 +2688,13 @@ var init_holidays = __esmMin((() => {
|
|
|
2688
2688
|
// src/lib/tide.ts
|
|
2689
2689
|
/** Tasks finished in each of the last weeks, oldest first. Done tasks without a date, or dated after now, count as finished now. */
|
|
2690
2690
|
function weeklyFinished(tasks, startDate, now, weeks = 6) {
|
|
2691
|
-
const today = noon$
|
|
2692
|
-
const age = Math.floor(differenceInCalendarDays(today, noon$
|
|
2691
|
+
const today = noon$4(todayISO(now));
|
|
2692
|
+
const age = Math.floor(differenceInCalendarDays(today, noon$4(startDate)) / 7) + 1;
|
|
2693
2693
|
const span = Math.max(1, Math.min(weeks, age));
|
|
2694
2694
|
const counts = new Array(span).fill(0);
|
|
2695
2695
|
for (const t of tasks) {
|
|
2696
2696
|
if (t.status !== "done") continue;
|
|
2697
|
-
const at = t.completedAt ? noon$
|
|
2697
|
+
const at = t.completedAt ? noon$4(t.completedAt) : today;
|
|
2698
2698
|
const back = Math.max(0, differenceInCalendarDays(today, at));
|
|
2699
2699
|
const week = Math.floor(back / 7);
|
|
2700
2700
|
if (week < span) counts[span - 1 - week] += 1;
|
|
@@ -2872,7 +2872,7 @@ function tideOptions(data) {
|
|
|
2872
2872
|
function tideForecast(project, tasks, now, opts = {}) {
|
|
2873
2873
|
if (tasks.length === 0) return { kind: "empty" };
|
|
2874
2874
|
const remaining = tasks.filter((t) => t.status !== "done").length;
|
|
2875
|
-
if (remaining === 0) return { kind: "done" };
|
|
2875
|
+
if (remaining === 0 || project.health === "done") return { kind: "done" };
|
|
2876
2876
|
if (project.health === "paused") return {
|
|
2877
2877
|
kind: "paused",
|
|
2878
2878
|
remaining
|
|
@@ -2885,7 +2885,7 @@ function tideForecast(project, tasks, now, opts = {}) {
|
|
|
2885
2885
|
finished
|
|
2886
2886
|
};
|
|
2887
2887
|
const today = todayISO(now);
|
|
2888
|
-
const base = noon$
|
|
2888
|
+
const base = noon$4(today);
|
|
2889
2889
|
const { shares, crew } = weekShares(tasks, base, opts.calendar);
|
|
2890
2890
|
const paced = simulate(remaining, weekly, hash(`${project.id}|${remaining}|${today}`), Math.max(0, opts.pace ?? 1), shares);
|
|
2891
2891
|
const at = (share, list = runs) => list[Math.min(list.length - 1, Math.ceil(share * list.length) - 1)];
|
|
@@ -2894,12 +2894,12 @@ function tideForecast(project, tasks, now, opts = {}) {
|
|
|
2894
2894
|
if (t.status === "done" || !t.dueDate) continue;
|
|
2895
2895
|
if (!lastDue || t.dueDate > lastDue.dueDate) lastDue = t;
|
|
2896
2896
|
}
|
|
2897
|
-
const floor = lastDue ? differenceInCalendarDays(noon$
|
|
2897
|
+
const floor = lastDue ? differenceInCalendarDays(noon$4(lastDue.dueDate), base) : 0;
|
|
2898
2898
|
const sounded = soundingFloors(tasks, opts.reflections, base, hash(`${project.id}|sound|${today}`), opts.calendar);
|
|
2899
2899
|
const runs = paced.map((d, i) => Math.max(d, floor, sounded.floors?.[i] ?? 0)).sort((a, b) => a - b);
|
|
2900
2900
|
const heldBy = lastDue && floor > at(.5, paced) ? lastDue : null;
|
|
2901
2901
|
const dayOf = (days) => Number.isFinite(days) ? todayISO(addDays(base, days)) : null;
|
|
2902
|
-
const toEnd = differenceInCalendarDays(noon$
|
|
2902
|
+
const toEnd = differenceInCalendarDays(noon$4(project.endDate), base);
|
|
2903
2903
|
const byEnd = runs.filter((d) => d <= toEnd).length / runs.length;
|
|
2904
2904
|
const last = Math.max(Number.isFinite(at(.99)) ? at(.99) : 728, toEnd + 14, 7);
|
|
2905
2905
|
const curve = [];
|
|
@@ -2952,7 +2952,7 @@ function tideQuiet(tide) {
|
|
|
2952
2952
|
default: return null;
|
|
2953
2953
|
}
|
|
2954
2954
|
}
|
|
2955
|
-
var TIDE_WEEKS, TRIALS, MAX_WEEKS, ROUGH_BELOW, TIDE_MIN_FINISHED, noon$
|
|
2955
|
+
var TIDE_WEEKS, TRIALS, MAX_WEEKS, ROUGH_BELOW, TIDE_MIN_FINISHED, noon$4;
|
|
2956
2956
|
var init_tide = __esmMin((() => {
|
|
2957
2957
|
init_date_fns();
|
|
2958
2958
|
init_format();
|
|
@@ -2963,7 +2963,7 @@ var init_tide = __esmMin((() => {
|
|
|
2963
2963
|
MAX_WEEKS = 104;
|
|
2964
2964
|
ROUGH_BELOW = 5;
|
|
2965
2965
|
TIDE_MIN_FINISHED = 3;
|
|
2966
|
-
noon$
|
|
2966
|
+
noon$4 = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
|
|
2967
2967
|
}));
|
|
2968
2968
|
|
|
2969
2969
|
// src/lib/barnacles.ts
|
|
@@ -2980,7 +2980,7 @@ function planSetAt(project, tasks) {
|
|
|
2980
2980
|
for (const t of tasks) if (!first || t.createdAt < first) first = t.createdAt;
|
|
2981
2981
|
first = (first ?? project.startDate).slice(0, 10);
|
|
2982
2982
|
return {
|
|
2983
|
-
since: `${isoDay$3(addDays(noon$
|
|
2983
|
+
since: `${isoDay$3(addDays(noon$3(first), 7))}T00:00:00`,
|
|
2984
2984
|
from: "start"
|
|
2985
2985
|
};
|
|
2986
2986
|
}
|
|
@@ -2992,20 +2992,20 @@ function planSetAt(project, tasks) {
|
|
|
2992
2992
|
function barnaclesOf(project, tasks, people, now, slip = null) {
|
|
2993
2993
|
const { since, from } = planSetAt(project, tasks);
|
|
2994
2994
|
const today = todayISO(now);
|
|
2995
|
-
const weekAgo = isoDay$3(addDays(noon$
|
|
2995
|
+
const weekAgo = isoDay$3(addDays(noon$3(today), -7));
|
|
2996
2996
|
let planned = 0;
|
|
2997
2997
|
const added = [];
|
|
2998
2998
|
for (const t of tasks) if (t.createdAt > since) added.push(t);
|
|
2999
2999
|
else planned += 1;
|
|
3000
|
-
const first = startOfWeek(noon$
|
|
3001
|
-
const span = Math.max(0, Math.floor(differenceInCalendarDays(noon$
|
|
3000
|
+
const first = startOfWeek(noon$3(since), { weekStartsOn: 1 });
|
|
3001
|
+
const span = Math.max(0, Math.floor(differenceInCalendarDays(noon$3(today), first) / 7));
|
|
3002
3002
|
const weeks = Array.from({ length: Math.min(span + 1, 104) }, (_, i) => ({
|
|
3003
3003
|
week: isoDay$3(addDays(first, i * 7)),
|
|
3004
3004
|
added: 0
|
|
3005
3005
|
}));
|
|
3006
3006
|
const counts = /* @__PURE__ */ new Map();
|
|
3007
3007
|
for (const t of added) {
|
|
3008
|
-
const i = Math.floor(differenceInCalendarDays(noon$
|
|
3008
|
+
const i = Math.floor(differenceInCalendarDays(noon$3(t.createdAt), first) / 7);
|
|
3009
3009
|
const slot = weeks[Math.min(Math.max(0, i), weeks.length - 1)];
|
|
3010
3010
|
if (slot) slot.added += 1;
|
|
3011
3011
|
const who = t.createdById ?? null;
|
|
@@ -3023,7 +3023,7 @@ function barnaclesOf(project, tasks, people, now, slip = null) {
|
|
|
3023
3023
|
const byDay = /* @__PURE__ */ new Map();
|
|
3024
3024
|
for (const t of added) byDay.set(t.createdAt.slice(0, 10), (byDay.get(t.createdAt.slice(0, 10)) ?? 0) + 1);
|
|
3025
3025
|
const [bulkDay, bulk] = [...byDay].sort((a, b) => b[1] - a[1])[0] ?? [null, 0];
|
|
3026
|
-
const age = differenceInCalendarDays(noon$
|
|
3026
|
+
const age = differenceInCalendarDays(noon$3(today), addDays(noon$3(since), -7)) / 7;
|
|
3027
3027
|
let caveat = null;
|
|
3028
3028
|
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.`;
|
|
3029
3029
|
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.";
|
|
@@ -3050,8 +3050,8 @@ function barnaclesOf(project, tasks, people, now, slip = null) {
|
|
|
3050
3050
|
*/
|
|
3051
3051
|
function slipAgainstPlan(project, tasks, now, tide = tideForecast(project, tasks, now)) {
|
|
3052
3052
|
const plannedEnd = (project.baselineEnd ?? project.endDate).slice(0, 10);
|
|
3053
|
-
const likely = tide.kind === "forecast" && tide.likely ? differenceInCalendarDays(noon$
|
|
3054
|
-
const moved = project.baselineEnd ? differenceInCalendarDays(noon$
|
|
3053
|
+
const likely = tide.kind === "forecast" && tide.likely ? differenceInCalendarDays(noon$3(tide.likely), noon$3(plannedEnd)) : 0;
|
|
3054
|
+
const moved = project.baselineEnd ? differenceInCalendarDays(noon$3(project.endDate), noon$3(project.baselineEnd)) : 0;
|
|
3055
3055
|
const slip = Math.max(likely, moved);
|
|
3056
3056
|
return slip > 0 ? slip : null;
|
|
3057
3057
|
}
|
|
@@ -3084,7 +3084,7 @@ function barnaclesShort(b) {
|
|
|
3084
3084
|
function briefWorthy(b) {
|
|
3085
3085
|
return b.from === "baseline" && !b.caveat && b.added >= 3 && (b.share == null || b.share >= .15);
|
|
3086
3086
|
}
|
|
3087
|
-
var SETTLE_DAYS, THIN_PLANNED, THIN_WEEKS, BULK_SHARE, BULK_MIN, BRIEF_MIN_ADDED, BRIEF_MIN_SHARE, noon$
|
|
3087
|
+
var SETTLE_DAYS, THIN_PLANNED, THIN_WEEKS, BULK_SHARE, BULK_MIN, BRIEF_MIN_ADDED, BRIEF_MIN_SHARE, noon$3, isoDay$3, plural$7;
|
|
3088
3088
|
var init_barnacles = __esmMin((() => {
|
|
3089
3089
|
init_date_fns();
|
|
3090
3090
|
init_format();
|
|
@@ -3096,7 +3096,7 @@ var init_barnacles = __esmMin((() => {
|
|
|
3096
3096
|
BULK_MIN = 5;
|
|
3097
3097
|
BRIEF_MIN_ADDED = 3;
|
|
3098
3098
|
BRIEF_MIN_SHARE = .15;
|
|
3099
|
-
noon$
|
|
3099
|
+
noon$3 = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
|
|
3100
3100
|
isoDay$3 = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
3101
3101
|
plural$7 = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
3102
3102
|
}));
|
|
@@ -3376,7 +3376,7 @@ var init_thanks = __esmMin((() => {
|
|
|
3376
3376
|
// src/lib/charter-lines.ts
|
|
3377
3377
|
/** Nothing written on any of the five lines. */
|
|
3378
3378
|
function charterEmpty(charter) {
|
|
3379
|
-
return !charter || CHARTER_FIELDS.every((f) => !clean(charter[f]));
|
|
3379
|
+
return !charter || CHARTER_FIELDS.every((f) => !clean$1(charter[f]));
|
|
3380
3380
|
}
|
|
3381
3381
|
/**
|
|
3382
3382
|
* The charter after a change to some of its lines: trimmed, and null once
|
|
@@ -3396,7 +3396,7 @@ function withCharter(current, patch, by, at) {
|
|
|
3396
3396
|
}
|
|
3397
3397
|
return charterEmpty(next) ? null : next;
|
|
3398
3398
|
}
|
|
3399
|
-
var CHARTER_FIELDS, clean;
|
|
3399
|
+
var CHARTER_FIELDS, clean$1;
|
|
3400
3400
|
var init_charter_lines = __esmMin((() => {
|
|
3401
3401
|
CHARTER_FIELDS = [
|
|
3402
3402
|
"why",
|
|
@@ -3405,7 +3405,7 @@ var init_charter_lines = __esmMin((() => {
|
|
|
3405
3405
|
"doneWhen",
|
|
3406
3406
|
"callsBy"
|
|
3407
3407
|
];
|
|
3408
|
-
clean = (s) => (s ?? "").replace(/\s+/g, " ").trim();
|
|
3408
|
+
clean$1 = (s) => (s ?? "").replace(/\s+/g, " ").trim();
|
|
3409
3409
|
}));
|
|
3410
3410
|
|
|
3411
3411
|
// src/lib/charter.ts
|
|
@@ -3434,7 +3434,7 @@ function charterLines(charter) {
|
|
|
3434
3434
|
return CHARTER_FIELDS.map((field) => ({
|
|
3435
3435
|
field,
|
|
3436
3436
|
label: CHARTER_LABEL[field],
|
|
3437
|
-
text: clean(charter[field])
|
|
3437
|
+
text: clean$1(charter[field])
|
|
3438
3438
|
})).filter((l) => l.text);
|
|
3439
3439
|
}
|
|
3440
3440
|
function charterFilled(charter) {
|
|
@@ -3502,7 +3502,7 @@ function charterCheck(charter, tasks) {
|
|
|
3502
3502
|
};
|
|
3503
3503
|
});
|
|
3504
3504
|
return {
|
|
3505
|
-
doneWhen: clean(charter.doneWhen) || null,
|
|
3505
|
+
doneWhen: clean$1(charter.doneWhen) || null,
|
|
3506
3506
|
delivered,
|
|
3507
3507
|
crept: outOfScope(charter, tasks),
|
|
3508
3508
|
outItems: charterItems(charter.outScope).length
|
|
@@ -3524,6 +3524,18 @@ function charterSentence(check) {
|
|
|
3524
3524
|
return `Against the charter: ${bits.join("; ")}.`;
|
|
3525
3525
|
}
|
|
3526
3526
|
|
|
3527
|
+
// src/lib/finished-on.ts
|
|
3528
|
+
/**
|
|
3529
|
+
* When the project was marked Done: its last move to Done in its health
|
|
3530
|
+
* history, else its last finished task, else its end date. Kept on its own so
|
|
3531
|
+
* the inbox can ask it without the voyage log's code.
|
|
3532
|
+
*/
|
|
3533
|
+
function finishedOn(project, tasks) {
|
|
3534
|
+
const marked = [...project.healthHistory ?? []].reverse().find((h) => h.health === "done");
|
|
3535
|
+
if (marked) return marked.at.slice(0, 10);
|
|
3536
|
+
return (tasks.map((t) => t.completedAt).filter((x) => Boolean(x)).sort().at(-1) ?? project.endDate).slice(0, 10);
|
|
3537
|
+
}
|
|
3538
|
+
|
|
3527
3539
|
// src/lib/voyage-log.ts
|
|
3528
3540
|
init_date_fns();
|
|
3529
3541
|
init_blockers();
|
|
@@ -3531,12 +3543,6 @@ init_date_text();
|
|
|
3531
3543
|
init_premortem();
|
|
3532
3544
|
const day$8 = (iso) => parseIso(localDay(iso));
|
|
3533
3545
|
const between = (a, b) => differenceInCalendarDays(day$8(b), day$8(a));
|
|
3534
|
-
/** When the project was marked Done: its last move to Done in its health history. */
|
|
3535
|
-
function finishedOn(project, tasks) {
|
|
3536
|
-
const marked = [...project.healthHistory ?? []].reverse().find((h) => h.health === "done");
|
|
3537
|
-
if (marked) return marked.at.slice(0, 10);
|
|
3538
|
-
return (tasks.map((t) => t.completedAt).filter((x) => Boolean(x)).sort().at(-1) ?? project.endDate).slice(0, 10);
|
|
3539
|
-
}
|
|
3540
3546
|
function voyageLog(data, projectId) {
|
|
3541
3547
|
const project = data.projects.find((p) => p.id === projectId);
|
|
3542
3548
|
if (!project) return null;
|
|
@@ -5942,18 +5948,18 @@ init_tide();
|
|
|
5942
5948
|
const BURN_DAYS = 56;
|
|
5943
5949
|
/** A window shorter than a week says too little to read a rate from. */
|
|
5944
5950
|
const MIN_DAYS = 7;
|
|
5945
|
-
const noon$
|
|
5951
|
+
const noon$2 = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
|
|
5946
5952
|
/**
|
|
5947
5953
|
* Spend a day over the last BURN_DAYS (or the project's life, when it's
|
|
5948
5954
|
* younger), from the ledger; null when nothing was spent in that time.
|
|
5949
5955
|
*/
|
|
5950
5956
|
function burnPerDay(entries, startDate, now) {
|
|
5951
|
-
const today = noon$
|
|
5952
|
-
const age = differenceInCalendarDays(today, noon$
|
|
5957
|
+
const today = noon$2(todayISO(now));
|
|
5958
|
+
const age = differenceInCalendarDays(today, noon$2(startDate)) + 1;
|
|
5953
5959
|
const span = Math.max(MIN_DAYS, Math.min(56, age));
|
|
5954
5960
|
let sum = 0;
|
|
5955
5961
|
for (const e of entries) {
|
|
5956
|
-
const back = differenceInCalendarDays(today, noon$
|
|
5962
|
+
const back = differenceInCalendarDays(today, noon$2(e.date));
|
|
5957
5963
|
if (back >= 0 && back < span) sum += e.amount;
|
|
5958
5964
|
}
|
|
5959
5965
|
return sum > 0 ? sum / span : null;
|
|
@@ -5975,8 +5981,8 @@ function slipCost(project, tide, entries, now) {
|
|
|
5975
5981
|
const from = project.endDate > today ? project.endDate : today;
|
|
5976
5982
|
const point = (date) => {
|
|
5977
5983
|
if (!date) return null;
|
|
5978
|
-
const daysLate = Math.max(0, differenceInCalendarDays(noon$
|
|
5979
|
-
const ahead = Math.max(0, differenceInCalendarDays(noon$
|
|
5984
|
+
const daysLate = Math.max(0, differenceInCalendarDays(noon$2(date), noon$2(from)));
|
|
5985
|
+
const ahead = Math.max(0, differenceInCalendarDays(noon$2(date), noon$2(today)));
|
|
5980
5986
|
return {
|
|
5981
5987
|
date,
|
|
5982
5988
|
daysLate,
|
|
@@ -10136,11 +10142,11 @@ function hillText(project) {
|
|
|
10136
10142
|
if (at === 100) return "Over the hill: done";
|
|
10137
10143
|
return `${HILL_PHASE_LABEL[hillPhase(at)]} (${at} of 100)`;
|
|
10138
10144
|
}
|
|
10139
|
-
var HILL_TOP, STILL_DAYS, HILL_PHASE_LABEL;
|
|
10145
|
+
var HILL_TOP, STILL_DAYS$1, HILL_PHASE_LABEL;
|
|
10140
10146
|
var init_hill = __esmMin((() => {
|
|
10141
10147
|
init_date_fns();
|
|
10142
10148
|
HILL_TOP = 50;
|
|
10143
|
-
STILL_DAYS = 14;
|
|
10149
|
+
STILL_DAYS$1 = 14;
|
|
10144
10150
|
HILL_PHASE_LABEL = {
|
|
10145
10151
|
figuring: "Figuring it out",
|
|
10146
10152
|
doing: "Getting it done"
|
|
@@ -10560,6 +10566,126 @@ function HistoryView({ active, width, height, go }) {
|
|
|
10560
10566
|
});
|
|
10561
10567
|
}
|
|
10562
10568
|
|
|
10569
|
+
// src/lib/landfall-lines.ts
|
|
10570
|
+
function isLandfallDays(n) {
|
|
10571
|
+
return LANDFALL_DAYS.includes(n);
|
|
10572
|
+
}
|
|
10573
|
+
/** Why a landfall can't be saved as given, in words for the person; null when it can. */
|
|
10574
|
+
function landfallProblem(input) {
|
|
10575
|
+
const expect = clean(input.expect);
|
|
10576
|
+
if (!expect) return "Say what landfall should look like.";
|
|
10577
|
+
if (expect.length > 300) return `Keep it under ${300} characters.`;
|
|
10578
|
+
if (!isLandfallDays(input.afterDays)) return "Pick 30, 90 or 180 days.";
|
|
10579
|
+
if (clean(input.measure).length > 60) return `Name the number in under ${60} characters.`;
|
|
10580
|
+
return null;
|
|
10581
|
+
}
|
|
10582
|
+
/** Why an answer can't be saved as given; null when it can. */
|
|
10583
|
+
function landfallAnswerProblem(input) {
|
|
10584
|
+
if (!LANDFALL_RESULTS.includes(input.result)) return "Say yes, partly or no.";
|
|
10585
|
+
if (input.value != null && (typeof input.value !== "number" || !Number.isFinite(input.value))) return "The number has to be a number.";
|
|
10586
|
+
if (typeof input.value === "number" && Math.abs(input.value) > 0xe8d4a51000) return "That number is too big.";
|
|
10587
|
+
if (clean(input.note).length > 500) return `Keep the note under ${500} characters.`;
|
|
10588
|
+
return null;
|
|
10589
|
+
}
|
|
10590
|
+
/**
|
|
10591
|
+
* The landfall after someone sets or changes what it looks like. An answer
|
|
10592
|
+
* already given stays: changing the words after the fact doesn't unask it.
|
|
10593
|
+
* Throws on bad input, so callers check `landfallProblem` first.
|
|
10594
|
+
*/
|
|
10595
|
+
function withLandfall(current, input, by, at) {
|
|
10596
|
+
const problem = landfallProblem(input);
|
|
10597
|
+
if (problem) throw new Error(problem);
|
|
10598
|
+
const measure = clean(input.measure);
|
|
10599
|
+
return {
|
|
10600
|
+
...current ?? {},
|
|
10601
|
+
expect: clean(input.expect),
|
|
10602
|
+
afterDays: input.afterDays,
|
|
10603
|
+
measure: measure || null,
|
|
10604
|
+
setAt: current?.setAt ?? at,
|
|
10605
|
+
setById: current?.setById ?? by
|
|
10606
|
+
};
|
|
10607
|
+
}
|
|
10608
|
+
/** The landfall with an answer, or with the answer taken back (null) so it asks again. */
|
|
10609
|
+
function withLandfallAnswer(current, answer, by, at) {
|
|
10610
|
+
if (!answer) {
|
|
10611
|
+
const { result: _r, value: _v, note: _n, answeredAt: _a, answeredById: _b, ...rest } = current;
|
|
10612
|
+
return rest;
|
|
10613
|
+
}
|
|
10614
|
+
const problem = landfallAnswerProblem(answer);
|
|
10615
|
+
if (problem) throw new Error(problem);
|
|
10616
|
+
const note = clean(answer.note);
|
|
10617
|
+
return {
|
|
10618
|
+
...current,
|
|
10619
|
+
result: answer.result,
|
|
10620
|
+
value: answer.value ?? null,
|
|
10621
|
+
note: note || null,
|
|
10622
|
+
answeredAt: at,
|
|
10623
|
+
answeredById: by
|
|
10624
|
+
};
|
|
10625
|
+
}
|
|
10626
|
+
var LANDFALL_DAYS, LANDFALL_EXPECT_MAX, LANDFALL_MEASURE_MAX, LANDFALL_NOTE_MAX, LANDFALL_RESULTS, clean;
|
|
10627
|
+
var init_landfall_lines = __esmMin((() => {
|
|
10628
|
+
LANDFALL_DAYS = [
|
|
10629
|
+
30,
|
|
10630
|
+
90,
|
|
10631
|
+
180
|
|
10632
|
+
];
|
|
10633
|
+
LANDFALL_EXPECT_MAX = 300;
|
|
10634
|
+
LANDFALL_MEASURE_MAX = 60;
|
|
10635
|
+
LANDFALL_NOTE_MAX = 500;
|
|
10636
|
+
LANDFALL_RESULTS = [
|
|
10637
|
+
"yes",
|
|
10638
|
+
"partly",
|
|
10639
|
+
"no"
|
|
10640
|
+
];
|
|
10641
|
+
clean = (s) => (s ?? "").replace(/\s+/g, " ").trim();
|
|
10642
|
+
}));
|
|
10643
|
+
|
|
10644
|
+
// src/lib/landfall.ts
|
|
10645
|
+
init_date_fns();
|
|
10646
|
+
init_date_text();
|
|
10647
|
+
function plusDays(iso, days) {
|
|
10648
|
+
return addDays(parseIso(iso.slice(0, 10)), days).toISOString().slice(0, 10);
|
|
10649
|
+
}
|
|
10650
|
+
/** The day to look: that many days after the project was marked Done. */
|
|
10651
|
+
function landfallCheckOn(project, tasks) {
|
|
10652
|
+
if (!project.landfall) return null;
|
|
10653
|
+
return plusDays(finishedOn(project, tasks), project.landfall.afterDays);
|
|
10654
|
+
}
|
|
10655
|
+
function landfallState(project, tasks, today) {
|
|
10656
|
+
const lf = project.landfall;
|
|
10657
|
+
if (lf?.result) return "answered";
|
|
10658
|
+
if (project.health !== "done") return "sailing";
|
|
10659
|
+
if (!lf) return "unset";
|
|
10660
|
+
const on = landfallCheckOn(project, tasks);
|
|
10661
|
+
return on && on <= today ? "due" : "waiting";
|
|
10662
|
+
}
|
|
10663
|
+
/** Who is asked: the project's owner, else (their person gone) the program's. */
|
|
10664
|
+
function landfallAskee(data, project) {
|
|
10665
|
+
const here = (id) => id ? data.people.find((p) => p.id === id) : void 0;
|
|
10666
|
+
return here(project.ownerId) ?? here(data.programs.find((p) => p.id === project.programId)?.ownerId);
|
|
10667
|
+
}
|
|
10668
|
+
/** A project's landfall as agents and the CLI's --json read it; null while there's none. */
|
|
10669
|
+
function landfallJson(project, tasks, today, people) {
|
|
10670
|
+
const lf = project.landfall;
|
|
10671
|
+
if (!lf) return null;
|
|
10672
|
+
const answered = Boolean(lf.result);
|
|
10673
|
+
return {
|
|
10674
|
+
expect: lf.expect,
|
|
10675
|
+
afterDays: lf.afterDays,
|
|
10676
|
+
measure: lf.measure ?? null,
|
|
10677
|
+
state: landfallState(project, tasks, today),
|
|
10678
|
+
checkOn: project.health === "done" ? landfallCheckOn(project, tasks) : null,
|
|
10679
|
+
result: lf.result ?? null,
|
|
10680
|
+
...answered ? {
|
|
10681
|
+
value: lf.value ?? null,
|
|
10682
|
+
note: lf.note ?? null,
|
|
10683
|
+
answeredOn: lf.answeredAt?.slice(0, 10) ?? null,
|
|
10684
|
+
answeredBy: people.find((p) => p.id === lf.answeredById)?.name ?? null
|
|
10685
|
+
} : {}
|
|
10686
|
+
};
|
|
10687
|
+
}
|
|
10688
|
+
|
|
10563
10689
|
// src/lib/project-messages.ts
|
|
10564
10690
|
/** This project's messages, oldest first. */
|
|
10565
10691
|
function messagesOf(reflections, projectId) {
|
|
@@ -10682,6 +10808,7 @@ const KIND_RANK = {
|
|
|
10682
10808
|
waiting: 0,
|
|
10683
10809
|
approval: 1,
|
|
10684
10810
|
review: 2,
|
|
10811
|
+
landfall: 2.5,
|
|
10685
10812
|
sounding: 3,
|
|
10686
10813
|
assigned: 4,
|
|
10687
10814
|
idle: 5,
|
|
@@ -10694,6 +10821,7 @@ const PING_KIND_LABEL = {
|
|
|
10694
10821
|
waiting: "Waiting on you",
|
|
10695
10822
|
approval: "Sign-offs",
|
|
10696
10823
|
review: "Reviews due",
|
|
10824
|
+
landfall: "Landfall",
|
|
10697
10825
|
sounding: "Soundings",
|
|
10698
10826
|
assigned: "Assigned to you",
|
|
10699
10827
|
idle: "Quiet blockers",
|
|
@@ -10827,6 +10955,41 @@ function inboxOf(data, personId, now = clockNow()) {
|
|
|
10827
10955
|
personId: null
|
|
10828
10956
|
});
|
|
10829
10957
|
}
|
|
10958
|
+
const askSince = todayISO(subDays(now, 14));
|
|
10959
|
+
for (const project of data.projects) {
|
|
10960
|
+
if (project.health !== "done" && !project.landfall?.result) continue;
|
|
10961
|
+
if (landfallAskee(data, project)?.id !== personId) continue;
|
|
10962
|
+
const tasks = tasksOf(data.tasks, project.id);
|
|
10963
|
+
const state = landfallState(project, tasks, today);
|
|
10964
|
+
if (state === "unset") {
|
|
10965
|
+
const finished = finishedOn(project, tasks);
|
|
10966
|
+
if (finished < askSince) continue;
|
|
10967
|
+
pings.push({
|
|
10968
|
+
id: `landfall-set:${project.id}:${finished}`,
|
|
10969
|
+
kind: "landfall",
|
|
10970
|
+
title: `${project.name} is done. What should landfall look like?`,
|
|
10971
|
+
detail: "Say what should be true in 30, 90 or 180 days, and Harbor asks you then.",
|
|
10972
|
+
at: finished,
|
|
10973
|
+
projectId: project.id,
|
|
10974
|
+
landfall: "set",
|
|
10975
|
+
personId: null,
|
|
10976
|
+
href: `/projects/${project.id}?view=log`
|
|
10977
|
+
});
|
|
10978
|
+
} else if (state === "due") {
|
|
10979
|
+
const on = landfallCheckOn(project, tasks);
|
|
10980
|
+
pings.push({
|
|
10981
|
+
id: `landfall:${project.id}:${on}`,
|
|
10982
|
+
kind: "landfall",
|
|
10983
|
+
title: `Did ${project.name} reach landfall?`,
|
|
10984
|
+
detail: `“${project.landfall.expect}”`,
|
|
10985
|
+
at: on,
|
|
10986
|
+
projectId: project.id,
|
|
10987
|
+
landfall: "check",
|
|
10988
|
+
personId: null,
|
|
10989
|
+
href: `/projects/${project.id}?view=log`
|
|
10990
|
+
});
|
|
10991
|
+
}
|
|
10992
|
+
}
|
|
10830
10993
|
for (const constraint of constraintsOwnedBy(data, personId)) {
|
|
10831
10994
|
if (!constraintIdle(data, constraint)) continue;
|
|
10832
10995
|
const program = data.programs.find((p) => p.id === constraint.programId);
|
|
@@ -14807,6 +14970,12 @@ function taskMinutes(task) {
|
|
|
14807
14970
|
function entriesNewestFirst(task) {
|
|
14808
14971
|
return [...task.time ?? []].sort((a, b) => (b.on + b.at).localeCompare(a.on + a.at));
|
|
14809
14972
|
}
|
|
14973
|
+
/** The ISO day `n` days before `iso`. */
|
|
14974
|
+
function daysBefore(iso, n) {
|
|
14975
|
+
const d = /* @__PURE__ */ new Date(`${iso}T12:00:00Z`);
|
|
14976
|
+
d.setUTCDate(d.getUTCDate() - n);
|
|
14977
|
+
return d.toISOString().slice(0, 10);
|
|
14978
|
+
}
|
|
14810
14979
|
|
|
14811
14980
|
// src/lib/goals.ts
|
|
14812
14981
|
init_indexes();
|
|
@@ -20783,8 +20952,8 @@ init_date_fns();
|
|
|
20783
20952
|
init_blockers();
|
|
20784
20953
|
init_format();
|
|
20785
20954
|
init_indexes();
|
|
20786
|
-
const noon = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
|
|
20787
|
-
const days$2 = (from, to) => differenceInCalendarDays(noon(to), noon(from));
|
|
20955
|
+
const noon$1 = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
|
|
20956
|
+
const days$2 = (from, to) => differenceInCalendarDays(noon$1(to), noon$1(from));
|
|
20788
20957
|
const later = (a, b) => !a ? b : !b ? a : a >= b ? a : b;
|
|
20789
20958
|
const open = (t) => t.status !== "done";
|
|
20790
20959
|
/** True when `a` finishes later than `b`, or as late behind a longer line. */
|
|
@@ -21815,8 +21984,349 @@ var init_fog = __esmMin((() => {
|
|
|
21815
21984
|
fileCache = /* @__PURE__ */ new WeakMap();
|
|
21816
21985
|
}));
|
|
21817
21986
|
|
|
21818
|
-
// src/lib/
|
|
21987
|
+
// src/lib/assignment.ts
|
|
21988
|
+
/**
|
|
21989
|
+
* Record who set a task's assignee and when. Only a change of assignee
|
|
21990
|
+
* stamps; clearing the assignee clears the stamp.
|
|
21991
|
+
*/
|
|
21992
|
+
function stampAssignment(before, next, by, at) {
|
|
21993
|
+
if (before && before.assigneeId === next.assigneeId) return next;
|
|
21994
|
+
if (!next.assigneeId) return {
|
|
21995
|
+
...next,
|
|
21996
|
+
assignedBy: null,
|
|
21997
|
+
assignedAt: null
|
|
21998
|
+
};
|
|
21999
|
+
return {
|
|
22000
|
+
...next,
|
|
22001
|
+
assignedBy: by,
|
|
22002
|
+
assignedAt: at
|
|
22003
|
+
};
|
|
22004
|
+
}
|
|
22005
|
+
var init_assignment = __esmMin((() => {}));
|
|
22006
|
+
|
|
22007
|
+
// src/lib/scuttle-rows.ts
|
|
22008
|
+
/** Whether it's scuttled: closed through Scuttle or sail and not raised since. */
|
|
22009
|
+
function isScuttled(project) {
|
|
22010
|
+
return Boolean(project.scuttled) && project.healthOverride === "done";
|
|
22011
|
+
}
|
|
22012
|
+
/**
|
|
22013
|
+
* Scuttle a project: Done by hand with the reason as its note, its open
|
|
22014
|
+
* tasks unassigned and undated (kept in `scuttled.freed`), and a decision on
|
|
22015
|
+
* the register. Null for a project that's already scuttled or gone, or a
|
|
22016
|
+
* blank why.
|
|
22017
|
+
*/
|
|
22018
|
+
function scuttleRows(data, projectId, why, byId, at, decisionId, context = "Closed through Scuttle or sail.") {
|
|
22019
|
+
const project = data.projects.find((p) => p.id === projectId);
|
|
22020
|
+
const reason = why.trim().slice(0, 600);
|
|
22021
|
+
if (!project || !reason || isScuttled(project)) return null;
|
|
22022
|
+
const freed = [];
|
|
22023
|
+
const tasks = [];
|
|
22024
|
+
for (const t of tasksOf(data.tasks, projectId)) {
|
|
22025
|
+
if (t.status === "done" || !t.assigneeId && !t.dueDate) continue;
|
|
22026
|
+
freed.push({
|
|
22027
|
+
taskId: t.id,
|
|
22028
|
+
assigneeId: t.assigneeId,
|
|
22029
|
+
dueDate: t.dueDate
|
|
22030
|
+
});
|
|
22031
|
+
tasks.push(stampAssignment(t, {
|
|
22032
|
+
...t,
|
|
22033
|
+
assigneeId: null,
|
|
22034
|
+
dueDate: null,
|
|
22035
|
+
touchedAt: at
|
|
22036
|
+
}, byId, at));
|
|
22037
|
+
}
|
|
22038
|
+
const decision = normalizeRaidItem({
|
|
22039
|
+
id: decisionId,
|
|
22040
|
+
kind: "decision",
|
|
22041
|
+
projectId,
|
|
22042
|
+
title: `Scuttle ${project.name}`,
|
|
22043
|
+
description: context,
|
|
22044
|
+
status: "decided",
|
|
22045
|
+
severity: "low",
|
|
22046
|
+
ownerId: byId,
|
|
22047
|
+
dueDate: null,
|
|
22048
|
+
resolution: "Stop the project. Its open tasks come off people’s lists.",
|
|
22049
|
+
rationale: reason,
|
|
22050
|
+
decidedById: byId,
|
|
22051
|
+
decidedAt: at,
|
|
22052
|
+
createdById: byId,
|
|
22053
|
+
createdAt: at,
|
|
22054
|
+
updatedAt: at,
|
|
22055
|
+
updates: []
|
|
22056
|
+
});
|
|
22057
|
+
return {
|
|
22058
|
+
project: {
|
|
22059
|
+
...project,
|
|
22060
|
+
health: "done",
|
|
22061
|
+
healthOverride: "done",
|
|
22062
|
+
healthNote: `Scuttled: ${reason}`,
|
|
22063
|
+
scuttled: {
|
|
22064
|
+
at,
|
|
22065
|
+
byId,
|
|
22066
|
+
why: reason,
|
|
22067
|
+
decisionId,
|
|
22068
|
+
freed
|
|
22069
|
+
}
|
|
22070
|
+
},
|
|
22071
|
+
tasks,
|
|
22072
|
+
decision
|
|
22073
|
+
};
|
|
22074
|
+
}
|
|
22075
|
+
/**
|
|
22076
|
+
* Raise a scuttled project: the scuttle cleared (the caller clears its Done), and each
|
|
22077
|
+
* freed task given back its person and date, unless someone has since given
|
|
22078
|
+
* it others, it's done, or the person has left the crew. Null when it isn't
|
|
22079
|
+
* scuttled.
|
|
22080
|
+
*/
|
|
22081
|
+
function raiseRows(data, projectId, at, byId) {
|
|
22082
|
+
const project = data.projects.find((p) => p.id === projectId);
|
|
22083
|
+
if (!project?.scuttled) return null;
|
|
22084
|
+
const people = new Set(data.people.map((p) => p.id));
|
|
22085
|
+
const byTask = new Map(project.scuttled.freed.map((f) => [f.taskId, f]));
|
|
22086
|
+
const tasks = [];
|
|
22087
|
+
for (const t of tasksOf(data.tasks, projectId)) {
|
|
22088
|
+
const f = byTask.get(t.id);
|
|
22089
|
+
if (!f || t.status === "done") continue;
|
|
22090
|
+
const assigneeId = !t.assigneeId && f.assigneeId && people.has(f.assigneeId) ? f.assigneeId : t.assigneeId;
|
|
22091
|
+
const dueDate = t.dueDate ?? f.dueDate;
|
|
22092
|
+
if (assigneeId === t.assigneeId && dueDate === t.dueDate) continue;
|
|
22093
|
+
tasks.push(stampAssignment(t, {
|
|
22094
|
+
...t,
|
|
22095
|
+
assigneeId,
|
|
22096
|
+
dueDate,
|
|
22097
|
+
touchedAt: at
|
|
22098
|
+
}, byId, at));
|
|
22099
|
+
}
|
|
22100
|
+
return {
|
|
22101
|
+
project: {
|
|
22102
|
+
...project,
|
|
22103
|
+
scuttled: null,
|
|
22104
|
+
voyageCall: {
|
|
22105
|
+
call: "sail",
|
|
22106
|
+
at,
|
|
22107
|
+
byId
|
|
22108
|
+
}
|
|
22109
|
+
},
|
|
22110
|
+
tasks
|
|
22111
|
+
};
|
|
22112
|
+
}
|
|
22113
|
+
var WHY_MAX;
|
|
22114
|
+
var init_scuttle_rows = __esmMin((() => {
|
|
22115
|
+
init_assignment();
|
|
22116
|
+
init_indexes();
|
|
22117
|
+
init_raid();
|
|
22118
|
+
WHY_MAX = 600;
|
|
22119
|
+
}));
|
|
22120
|
+
|
|
22121
|
+
// src/lib/scuttle.ts
|
|
22122
|
+
init_date_fns();
|
|
21819
22123
|
init_fog();
|
|
22124
|
+
init_indexes();
|
|
22125
|
+
init_scuttle_rows();
|
|
22126
|
+
init_spend();
|
|
22127
|
+
init_format();
|
|
22128
|
+
/** Days with nothing moving before a project reads as still. */
|
|
22129
|
+
const STILL_DAYS = 28;
|
|
22130
|
+
/** A project younger than this is left alone: it's still being set up. */
|
|
22131
|
+
const YOUNG_DAYS = 21;
|
|
22132
|
+
/** Sailing on or a refit keeps it out of the line-up this long. */
|
|
22133
|
+
const QUIET_DAYS = 90;
|
|
22134
|
+
/** Slips are counted over this window. */
|
|
22135
|
+
const SLIP_WINDOW_DAYS = 90;
|
|
22136
|
+
/** This many falls into trouble in the window reads as slipping again and again. */
|
|
22137
|
+
const SLIP_TIMES = 3;
|
|
22138
|
+
/** Its end date this far past the baseline reads as drifting. */
|
|
22139
|
+
const DRIFT_DAYS = 21;
|
|
22140
|
+
/** This far past its end date with work open. */
|
|
22141
|
+
const OVERDUE_DAYS = 14;
|
|
22142
|
+
/** Paused this long. */
|
|
22143
|
+
const PAUSED_DAYS = 30;
|
|
22144
|
+
const noon = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
|
|
22145
|
+
const daysSince = (iso, today) => differenceInCalendarDays(noon(today), noon(iso));
|
|
22146
|
+
const TRACK = {
|
|
22147
|
+
"on-track": 0,
|
|
22148
|
+
"at-risk": 1,
|
|
22149
|
+
"off-track": 2
|
|
22150
|
+
};
|
|
22151
|
+
/** When anything last moved on the project: its tasks (as fog reads them) and its hill dot. */
|
|
22152
|
+
function lastMoved(project, data) {
|
|
22153
|
+
let last = project.hillMovedAt ?? null;
|
|
22154
|
+
for (const t of tasksOf(data.tasks, project.id)) {
|
|
22155
|
+
const at = lastTouch(t, data);
|
|
22156
|
+
if (!last || at > last) last = at;
|
|
22157
|
+
}
|
|
22158
|
+
return last;
|
|
22159
|
+
}
|
|
22160
|
+
/** Times its health fell (on track to at risk or off track, at risk to off track) since `since`. */
|
|
22161
|
+
function slipsSince(project, since) {
|
|
22162
|
+
let times = 0;
|
|
22163
|
+
let prev;
|
|
22164
|
+
for (const line of project.healthHistory ?? []) {
|
|
22165
|
+
const rank = TRACK[line.health];
|
|
22166
|
+
if (rank === void 0) {
|
|
22167
|
+
prev = void 0;
|
|
22168
|
+
continue;
|
|
22169
|
+
}
|
|
22170
|
+
if (prev !== void 0 && rank > prev && line.at.slice(0, 10) >= since) times += 1;
|
|
22171
|
+
prev = rank;
|
|
22172
|
+
}
|
|
22173
|
+
return times;
|
|
22174
|
+
}
|
|
22175
|
+
/** Days it has been paused, from the history's latest line saying so; null when it isn't, or nobody knows since when. */
|
|
22176
|
+
function pausedDays(project, today) {
|
|
22177
|
+
if (project.health !== "paused") return null;
|
|
22178
|
+
const history = project.healthHistory ?? [];
|
|
22179
|
+
let since = null;
|
|
22180
|
+
for (let i = history.length - 1; i >= 0; i--) {
|
|
22181
|
+
if (history[i].health !== "paused") break;
|
|
22182
|
+
since = history[i].at;
|
|
22183
|
+
}
|
|
22184
|
+
return since ? Math.max(0, daysSince(since, today)) : null;
|
|
22185
|
+
}
|
|
22186
|
+
/** A goal's work key result names it or its program. Only asked when its portfolio has goals at all. */
|
|
22187
|
+
function aimless(project, data) {
|
|
22188
|
+
const program = data.programs.find((p) => p.id === project.programId);
|
|
22189
|
+
const goals = (program && data.portfolios.find((p) => p.id === program.portfolioId))?.goals ?? [];
|
|
22190
|
+
if (goals.length === 0) return false;
|
|
22191
|
+
return !goals.some((g) => g.keyResults.some((kr) => kr.kind === "work" && (kr.linkIds ?? []).some((id) => id === project.id || id === project.programId)));
|
|
22192
|
+
}
|
|
22193
|
+
const STRENGTH = {
|
|
22194
|
+
overdue: 0,
|
|
22195
|
+
still: 1,
|
|
22196
|
+
empty: 1,
|
|
22197
|
+
paused: 2,
|
|
22198
|
+
slipping: 3,
|
|
22199
|
+
drifted: 4,
|
|
22200
|
+
aimless: 5
|
|
22201
|
+
};
|
|
22202
|
+
/** The signs that put a project in the line-up today, strongest first; empty when it's sailing fine. */
|
|
22203
|
+
function zombieSigns(project, data, today) {
|
|
22204
|
+
const signs = [];
|
|
22205
|
+
const tasks = tasksOf(data.tasks, project.id);
|
|
22206
|
+
const open = tasks.filter((t) => t.status !== "done").length;
|
|
22207
|
+
const age = daysSince(project.startDate, today);
|
|
22208
|
+
if (tasks.length === 0) {
|
|
22209
|
+
if (age >= 28) signs.push({
|
|
22210
|
+
kind: "empty",
|
|
22211
|
+
days: age
|
|
22212
|
+
});
|
|
22213
|
+
} else if (project.health !== "paused") {
|
|
22214
|
+
const moved = lastMoved(project, data);
|
|
22215
|
+
const still = moved ? daysSince(moved, today) : age;
|
|
22216
|
+
if (still >= 28) signs.push({
|
|
22217
|
+
kind: "still",
|
|
22218
|
+
days: still
|
|
22219
|
+
});
|
|
22220
|
+
}
|
|
22221
|
+
const paused = pausedDays(project, today);
|
|
22222
|
+
if (paused !== null && paused >= 30) signs.push({
|
|
22223
|
+
kind: "paused",
|
|
22224
|
+
days: paused
|
|
22225
|
+
});
|
|
22226
|
+
const late = daysSince(project.endDate, today);
|
|
22227
|
+
if (open > 0 && late >= 14 && project.health !== "paused") signs.push({
|
|
22228
|
+
kind: "overdue",
|
|
22229
|
+
days: late,
|
|
22230
|
+
open
|
|
22231
|
+
});
|
|
22232
|
+
const slips = slipsSince(project, daysBefore(today, 90));
|
|
22233
|
+
if (slips >= 3) signs.push({
|
|
22234
|
+
kind: "slipping",
|
|
22235
|
+
times: slips
|
|
22236
|
+
});
|
|
22237
|
+
if (project.baselineEnd) {
|
|
22238
|
+
const drift = differenceInCalendarDays(noon(project.endDate), noon(project.baselineEnd));
|
|
22239
|
+
if (drift >= 21) signs.push({
|
|
22240
|
+
kind: "drifted",
|
|
22241
|
+
days: drift
|
|
22242
|
+
});
|
|
22243
|
+
}
|
|
22244
|
+
if (signs.length > 0 && aimless(project, data)) signs.push({ kind: "aimless" });
|
|
22245
|
+
return signs.sort((a, b) => STRENGTH[a.kind] - STRENGTH[b.kind]);
|
|
22246
|
+
}
|
|
22247
|
+
/** Left out of the line-up: finished, scuttled, too young, or answered within the quarter. */
|
|
22248
|
+
function restingFromReview(project, today) {
|
|
22249
|
+
if (project.health === "done" || isScuttled(project)) return true;
|
|
22250
|
+
if (project.startDate > today || daysSince(project.startDate, today) < 21) return true;
|
|
22251
|
+
const call = project.voyageCall;
|
|
22252
|
+
return Boolean(call && daysSince(call.at, today) < 90);
|
|
22253
|
+
}
|
|
22254
|
+
/** What scuttling it would free: open tasks by person, logged hours and spend a week. */
|
|
22255
|
+
function whatItHolds(project, data, now) {
|
|
22256
|
+
const today = todayISO(now);
|
|
22257
|
+
const tasks = tasksOf(data.tasks, project.id);
|
|
22258
|
+
const by = /* @__PURE__ */ new Map();
|
|
22259
|
+
let openTasks = 0;
|
|
22260
|
+
let minutes = 0;
|
|
22261
|
+
const from = daysBefore(today, 27);
|
|
22262
|
+
for (const t of tasks) {
|
|
22263
|
+
for (const e of t.time ?? []) if (e.on >= from && e.on <= today) minutes += e.minutes;
|
|
22264
|
+
if (t.status === "done") continue;
|
|
22265
|
+
openTasks += 1;
|
|
22266
|
+
if (t.assigneeId) by.set(t.assigneeId, (by.get(t.assigneeId) ?? 0) + 1);
|
|
22267
|
+
}
|
|
22268
|
+
const crew = [...by].map(([personId, open]) => ({
|
|
22269
|
+
personId,
|
|
22270
|
+
open
|
|
22271
|
+
})).sort((a, b) => b.open - a.open);
|
|
22272
|
+
const burn = project.budget > 0 ? burnPerDay(spendEntriesOf({ spendEntries: data.spendEntries ?? [] }, project.id), project.startDate, now) : null;
|
|
22273
|
+
return {
|
|
22274
|
+
openTasks,
|
|
22275
|
+
crew,
|
|
22276
|
+
hoursPerWeek: minutes > 0 ? Math.round(minutes / 60 / 4 * 10) / 10 : null,
|
|
22277
|
+
spendPerWeek: burn ? Math.round(burn * 7) : null
|
|
22278
|
+
};
|
|
22279
|
+
}
|
|
22280
|
+
/**
|
|
22281
|
+
* The line-up: projects showing a zombie sign that aren't resting, the ones
|
|
22282
|
+
* with the most signs first, then the stillest. `projectIds` narrows it to a
|
|
22283
|
+
* portfolio's or program's; leave it out for the whole harbor.
|
|
22284
|
+
*/
|
|
22285
|
+
function scuttleLineUp(data, now, projectIds) {
|
|
22286
|
+
const today = todayISO(now);
|
|
22287
|
+
const out = [];
|
|
22288
|
+
for (const project of data.projects) {
|
|
22289
|
+
if (projectIds && !projectIds.has(project.id)) continue;
|
|
22290
|
+
if (restingFromReview(project, today)) continue;
|
|
22291
|
+
const signs = zombieSigns(project, data, today);
|
|
22292
|
+
if (signs.length === 0) continue;
|
|
22293
|
+
out.push({
|
|
22294
|
+
project,
|
|
22295
|
+
signs,
|
|
22296
|
+
lastMoved: lastMoved(project, data),
|
|
22297
|
+
...whatItHolds(project, data, now)
|
|
22298
|
+
});
|
|
22299
|
+
}
|
|
22300
|
+
const still = (z) => {
|
|
22301
|
+
const s = z.signs.find((x) => x.kind === "still" || x.kind === "empty");
|
|
22302
|
+
return s && "days" in s ? s.days : 0;
|
|
22303
|
+
};
|
|
22304
|
+
return out.sort((a, b) => b.signs.length - a.signs.length || still(b) - still(a) || a.project.name.localeCompare(b.project.name));
|
|
22305
|
+
}
|
|
22306
|
+
/** The projects in a portfolio or a program, for narrowing the line-up. */
|
|
22307
|
+
function projectIdsUnder(data, scope) {
|
|
22308
|
+
const programs = new Set(scope.programId ? [scope.programId] : data.programs.filter((p) => p.portfolioId === scope.portfolioId).map((p) => p.id));
|
|
22309
|
+
return new Set(data.projects.filter((p) => programs.has(p.programId)).map((p) => p.id));
|
|
22310
|
+
}
|
|
22311
|
+
/** "6 weeks", "10 days". */
|
|
22312
|
+
function spanText(days) {
|
|
22313
|
+
if (days >= 14) return `${Math.floor(days / 7)} weeks`;
|
|
22314
|
+
return `${days} day${days === 1 ? "" : "s"}`;
|
|
22315
|
+
}
|
|
22316
|
+
/** One sign in a short sentence. */
|
|
22317
|
+
function signText(sign) {
|
|
22318
|
+
switch (sign.kind) {
|
|
22319
|
+
case "still": return `Nothing has moved in ${spanText(sign.days)}`;
|
|
22320
|
+
case "empty": return `No tasks, ${spanText(sign.days)} after it started`;
|
|
22321
|
+
case "slipping": return `Slipped into trouble ${sign.times} times in three months`;
|
|
22322
|
+
case "drifted": return `Its end date has moved ${spanText(sign.days)} past the plan`;
|
|
22323
|
+
case "overdue": return `${spanText(sign.days)} past its end date, ${sign.open} task${sign.open === 1 ? "" : "s"} still open`;
|
|
22324
|
+
case "paused": return `Paused for ${spanText(sign.days)}`;
|
|
22325
|
+
case "aimless": return "No goal behind it";
|
|
22326
|
+
}
|
|
22327
|
+
}
|
|
22328
|
+
|
|
22329
|
+
// src/lib/task-approval.ts
|
|
21820
22330
|
/** Waits on its approver now: asked, not yet answered, and not done. */
|
|
21821
22331
|
function awaitingApproval(task) {
|
|
21822
22332
|
return Boolean(task?.approval && task.approval.status === "pending" && task.status !== "done");
|
|
@@ -21853,6 +22363,7 @@ init_handover();
|
|
|
21853
22363
|
init_agents();
|
|
21854
22364
|
init_work();
|
|
21855
22365
|
init_task_people();
|
|
22366
|
+
init_fog();
|
|
21856
22367
|
init_passage();
|
|
21857
22368
|
init_board();
|
|
21858
22369
|
init_holidays();
|
|
@@ -22480,7 +22991,7 @@ const HARBOR_TOOLS = [
|
|
|
22480
22991
|
tool({
|
|
22481
22992
|
name: "get_project",
|
|
22482
22993
|
title: "Project details",
|
|
22483
|
-
description: "One project: health and why, dates, budget, task counts, late tasks, what it waits on, milestones and open risks, when it will likely finish at the crew's recent pace (finish: shortHistory when there's under two weeks of it; lastDue names the open task whose due date the dates wait for, when the pace alone would finish sooner), notes the crew left for later (notesForLater: a sealed one shows only who left it and what opens it, unless it's the person's own), its charter (why, in, out, doneWhen, callsBy; null until someone writes one, and outOfScope lists open tasks whose titles name something it leaves out), the project's own task fields (fields: name, type and options, when it has any), and its url on the website." + DATA_NOTE,
|
|
22994
|
+
description: "One project: health and why, dates, budget, task counts, late tasks, what it waits on, milestones and open risks, when it will likely finish at the crew's recent pace (finish: shortHistory when there's under two weeks of it; lastDue names the open task whose due date the dates wait for, when the pace alone would finish sooner), notes the crew left for later (notesForLater: a sealed one shows only who left it and what opens it, unless it's the person's own), its charter (why, in, out, doneWhen, callsBy; null until someone writes one, and outOfScope lists open tasks whose titles name something it leaves out), its landfall (what should be true some days after it's Done, when Harbor asks, and the answer: yes, partly or no with a number; null until someone sets one; state is sailing while not Done, unset, waiting, due or answered), the project's own task fields (fields: name, type and options, when it has any), and its url on the website." + DATA_NOTE,
|
|
22484
22995
|
input: object({
|
|
22485
22996
|
harbor: harborArg,
|
|
22486
22997
|
project: string().min(1).max(200).describe("Project id or name.")
|
|
@@ -22544,6 +23055,7 @@ const HARBOR_TOOLS = [
|
|
|
22544
23055
|
...p.customFields?.length ? { fields: fieldDefsOut(p) } : {},
|
|
22545
23056
|
finish: finishForecast(p, tasks, ctx.now, spendEntriesOf(data, p.id), data),
|
|
22546
23057
|
...charterOf(p, tasks),
|
|
23058
|
+
landfall: landfallJson(p, tasks, today, data.people),
|
|
22547
23059
|
...notesOf(ctx, p),
|
|
22548
23060
|
...hillOf(p, today),
|
|
22549
23061
|
...webLink(ctx, { project: p.id })
|
|
@@ -23693,6 +24205,53 @@ const HARBOR_TOOLS = [
|
|
|
23693
24205
|
};
|
|
23694
24206
|
}
|
|
23695
24207
|
}),
|
|
24208
|
+
tool({
|
|
24209
|
+
name: "scuttle_or_sail",
|
|
24210
|
+
title: "Projects that look adrift",
|
|
24211
|
+
description: "Scuttle or sail: running projects that look like zombies (nothing moved for four weeks, no tasks a month in, two weeks past the end with work open, paused for a month, slipped into trouble three times in three months, or the end date moved three weeks past the baseline; \"no goal behind it\" is added when the portfolio keeps goals), the most signs first. Each says what stopping it would free: open tasks by person, logged hours a week, spend a week. Projects someone kept or refitted in the last three months, finished ones and ones under three weeks old are left out. Walk the person through them one at a time; answer_scuttle_or_sail records sail on, refit or scuttle, only on their word." + DATA_NOTE,
|
|
24212
|
+
input: object({
|
|
24213
|
+
harbor: harborArg,
|
|
24214
|
+
portfolio: string().max(200).optional().describe("Portfolio id or name, for its projects only."),
|
|
24215
|
+
program: string().max(200).optional().describe("Program id or name, for its projects only."),
|
|
24216
|
+
limit: limitArg(100, 25),
|
|
24217
|
+
offset: offsetArg
|
|
24218
|
+
}),
|
|
24219
|
+
run: (ctx, input) => {
|
|
24220
|
+
const { data } = ctx;
|
|
24221
|
+
const program = input.program ? findOne(data.programs, input.program, "program") : null;
|
|
24222
|
+
const portfolio = !program && input.portfolio ? findOne(data.portfolios, input.portfolio, "portfolio") : null;
|
|
24223
|
+
const ids = program || portfolio ? projectIdsUnder(data, {
|
|
24224
|
+
programId: program?.id,
|
|
24225
|
+
portfolioId: portfolio?.id
|
|
24226
|
+
}) : void 0;
|
|
24227
|
+
const lineUp = scuttleLineUp(data, ctx.now, ids);
|
|
24228
|
+
const people = byId(data.people);
|
|
24229
|
+
return {
|
|
24230
|
+
...program ? { program: program.name } : portfolio ? { portfolio: portfolio.name } : {},
|
|
24231
|
+
adrift: lineUp.length,
|
|
24232
|
+
projects: lineUp.slice(input.offset, input.offset + input.limit).map((z) => ({
|
|
24233
|
+
project: z.project.name,
|
|
24234
|
+
...z.project.key ? { key: z.project.key } : {},
|
|
24235
|
+
health: z.project.health,
|
|
24236
|
+
endDate: z.project.endDate,
|
|
24237
|
+
owner: people.get(z.project.ownerId)?.name ?? null,
|
|
24238
|
+
signs: z.signs.map((s) => ({
|
|
24239
|
+
kind: s.kind,
|
|
24240
|
+
text: signText(s)
|
|
24241
|
+
})),
|
|
24242
|
+
lastMoved: dayOf(z.lastMoved),
|
|
24243
|
+
openTasks: z.openTasks,
|
|
24244
|
+
crew: z.crew.map((c) => ({
|
|
24245
|
+
person: people.get(c.personId)?.name ?? null,
|
|
24246
|
+
openTasks: c.open
|
|
24247
|
+
})),
|
|
24248
|
+
hoursPerWeek: z.hoursPerWeek,
|
|
24249
|
+
spendPerWeek: z.spendPerWeek
|
|
24250
|
+
})),
|
|
24251
|
+
more: lineUp.length > input.offset + input.limit
|
|
24252
|
+
};
|
|
24253
|
+
}
|
|
24254
|
+
}),
|
|
23696
24255
|
tool({
|
|
23697
24256
|
name: "lifeboats",
|
|
23698
24257
|
title: "Work only one person knows",
|
|
@@ -24487,26 +25046,6 @@ var init_session = __esmMin((() => {
|
|
|
24487
25046
|
init_format();
|
|
24488
25047
|
}));
|
|
24489
25048
|
|
|
24490
|
-
// src/lib/assignment.ts
|
|
24491
|
-
/**
|
|
24492
|
-
* Record who set a task's assignee and when. Only a change of assignee
|
|
24493
|
-
* stamps; clearing the assignee clears the stamp.
|
|
24494
|
-
*/
|
|
24495
|
-
function stampAssignment(before, next, by, at) {
|
|
24496
|
-
if (before && before.assigneeId === next.assigneeId) return next;
|
|
24497
|
-
if (!next.assigneeId) return {
|
|
24498
|
-
...next,
|
|
24499
|
-
assignedBy: null,
|
|
24500
|
-
assignedAt: null
|
|
24501
|
-
};
|
|
24502
|
-
return {
|
|
24503
|
-
...next,
|
|
24504
|
-
assignedBy: by,
|
|
24505
|
-
assignedAt: at
|
|
24506
|
-
};
|
|
24507
|
-
}
|
|
24508
|
-
var init_assignment = __esmMin((() => {}));
|
|
24509
|
-
|
|
24510
25049
|
// src/lib/first-week.ts
|
|
24511
25050
|
/** The program launch named for you, when nobody has renamed it yet. */
|
|
24512
25051
|
function starterProgram(data) {
|
|
@@ -24518,6 +25057,33 @@ var init_first_week = __esmMin((() => {
|
|
|
24518
25057
|
|
|
24519
25058
|
// src/lib/slices/hierarchy.ts
|
|
24520
25059
|
function hierarchySlice({ get, write }) {
|
|
25060
|
+
/**
|
|
25061
|
+
* A scuttled project being reopened (its Done cleared or changed): its
|
|
25062
|
+
* freed tasks get their people and dates back, and its decision says so.
|
|
25063
|
+
* Null when it isn't scuttled.
|
|
25064
|
+
*/
|
|
25065
|
+
const raise = (projectId, at) => {
|
|
25066
|
+
const s = get();
|
|
25067
|
+
const rows = raiseRows(s, projectId, at, s.currentUserId);
|
|
25068
|
+
if (!rows) return null;
|
|
25069
|
+
const changed = new Map(rows.tasks.map((t) => [t.id, t]));
|
|
25070
|
+
const decisionId = s.projects.find((p) => p.id === projectId)?.scuttled?.decisionId;
|
|
25071
|
+
const note = {
|
|
25072
|
+
id: uid("ru"),
|
|
25073
|
+
authorId: s.currentUserId,
|
|
25074
|
+
body: "Raised again: the project is back, and its tasks went back to their people.",
|
|
25075
|
+
createdAt: at
|
|
25076
|
+
};
|
|
25077
|
+
return {
|
|
25078
|
+
projects: s.projects.map((p) => p.id === projectId ? rows.project : p),
|
|
25079
|
+
tasks: s.tasks.map((t) => changed.get(t.id) ?? t),
|
|
25080
|
+
raidItems: s.raidItems.map((r) => r.id === decisionId ? {
|
|
25081
|
+
...r,
|
|
25082
|
+
updates: [...r.updates, note],
|
|
25083
|
+
updatedAt: at
|
|
25084
|
+
} : r)
|
|
25085
|
+
};
|
|
25086
|
+
};
|
|
24521
25087
|
return {
|
|
24522
25088
|
addPortfolio: (p) => {
|
|
24523
25089
|
const id = uid("pf");
|
|
@@ -24666,6 +25232,20 @@ function hierarchySlice({ get, write }) {
|
|
|
24666
25232
|
charter: withCharter(p.charter, patch, by, stamp())
|
|
24667
25233
|
} : p) });
|
|
24668
25234
|
},
|
|
25235
|
+
setLandfall: (projectId, input) => {
|
|
25236
|
+
const by = get().currentUserId;
|
|
25237
|
+
write({ projects: get().projects.map((p) => p.id === projectId ? {
|
|
25238
|
+
...p,
|
|
25239
|
+
landfall: input ? withLandfall(p.landfall, input, by, stamp()) : null
|
|
25240
|
+
} : p) });
|
|
25241
|
+
},
|
|
25242
|
+
answerLandfall: (projectId, answer) => {
|
|
25243
|
+
const by = get().currentUserId;
|
|
25244
|
+
write({ projects: get().projects.map((p) => p.id === projectId && p.landfall ? {
|
|
25245
|
+
...p,
|
|
25246
|
+
landfall: withLandfallAnswer(p.landfall, answer, by, stamp())
|
|
25247
|
+
} : p) });
|
|
25248
|
+
},
|
|
24669
25249
|
addCustomField: (projectId, def) => {
|
|
24670
25250
|
const id = uid("cf");
|
|
24671
25251
|
write({ projects: get().projects.map((p) => p.id === projectId ? {
|
|
@@ -24762,11 +25342,24 @@ function hierarchySlice({ get, write }) {
|
|
|
24762
25342
|
programs: get().programs.map((p) => p.id === id ? mark(p) : p),
|
|
24763
25343
|
projects: parked ? get().projects.map((p) => p.programId === id && (!p.healthOverride || p.healthOverride === "paused" || p.healthOverride === "done") ? mark(p) : p) : get().projects
|
|
24764
25344
|
});
|
|
24765
|
-
} else
|
|
25345
|
+
} else {
|
|
25346
|
+
const raised = health === "done" ? null : raise(id, at);
|
|
25347
|
+
write({
|
|
25348
|
+
projects: (raised?.projects ?? get().projects).map((p) => p.id === id ? mark(p) : p),
|
|
25349
|
+
...raised ? {
|
|
25350
|
+
tasks: raised.tasks,
|
|
25351
|
+
raidItems: raised.raidItems
|
|
25352
|
+
} : {}
|
|
25353
|
+
});
|
|
25354
|
+
}
|
|
24766
25355
|
},
|
|
24767
25356
|
clearHealthOverride: (kind, id) => {
|
|
24768
25357
|
const at = stamp();
|
|
24769
|
-
const
|
|
25358
|
+
const raised = kind === "project" ? raise(id, at) : null;
|
|
25359
|
+
const s = raised ? {
|
|
25360
|
+
...get(),
|
|
25361
|
+
...raised
|
|
25362
|
+
} : get();
|
|
24770
25363
|
const cleared = {
|
|
24771
25364
|
portfolio: /* @__PURE__ */ new Set(),
|
|
24772
25365
|
program: /* @__PURE__ */ new Set(),
|
|
@@ -24801,9 +25394,59 @@ function hierarchySlice({ get, write }) {
|
|
|
24801
25394
|
write({
|
|
24802
25395
|
portfolios: next.portfolios.map(byRules("portfolio")),
|
|
24803
25396
|
programs: next.programs.map(byRules("program")),
|
|
24804
|
-
projects: next.projects.map(byRules("project"))
|
|
25397
|
+
projects: next.projects.map(byRules("project")),
|
|
25398
|
+
...raised ? {
|
|
25399
|
+
tasks: raised.tasks,
|
|
25400
|
+
raidItems: raised.raidItems
|
|
25401
|
+
} : {}
|
|
24805
25402
|
});
|
|
24806
25403
|
},
|
|
25404
|
+
scuttleProject: (projectId, why, context) => {
|
|
25405
|
+
const s = get();
|
|
25406
|
+
const at = stamp();
|
|
25407
|
+
const decisionId = uid("raid");
|
|
25408
|
+
const rows = scuttleRows(s, projectId, why, s.currentUserId, at, decisionId, context);
|
|
25409
|
+
if (!rows) return null;
|
|
25410
|
+
const before = s.projects.find((p) => p.id === projectId);
|
|
25411
|
+
const changed = new Map(rows.tasks.map((t) => [t.id, t]));
|
|
25412
|
+
write({
|
|
25413
|
+
projects: s.projects.map((p) => p.id === projectId ? {
|
|
25414
|
+
...rows.project,
|
|
25415
|
+
healthHistory: recordHealth(p.healthHistory, "done", at, true, rows.project.healthNote)
|
|
25416
|
+
} : p),
|
|
25417
|
+
tasks: s.tasks.map((t) => changed.get(t.id) ?? t),
|
|
25418
|
+
raidItems: [...s.raidItems, rows.decision]
|
|
25419
|
+
});
|
|
25420
|
+
get().log(`scuttled ${before.name}`, `/projects/${projectId}`);
|
|
25421
|
+
return {
|
|
25422
|
+
project: before,
|
|
25423
|
+
tasks: s.tasks.filter((t) => changed.has(t.id)),
|
|
25424
|
+
decisionId
|
|
25425
|
+
};
|
|
25426
|
+
},
|
|
25427
|
+
undoScuttle: (undo) => {
|
|
25428
|
+
const s = get();
|
|
25429
|
+
const back = new Map(undo.tasks.map((t) => [t.id, t]));
|
|
25430
|
+
write({
|
|
25431
|
+
projects: s.projects.map((p) => p.id === undo.project.id ? undo.project : p),
|
|
25432
|
+
tasks: s.tasks.map((t) => back.get(t.id) ?? t),
|
|
25433
|
+
raidItems: s.raidItems.filter((r) => r.id !== undo.decisionId)
|
|
25434
|
+
});
|
|
25435
|
+
},
|
|
25436
|
+
answerVoyage: (projectId, call, note) => {
|
|
25437
|
+
const at = stamp();
|
|
25438
|
+
const byId = get().currentUserId;
|
|
25439
|
+
const text = note?.trim().slice(0, 300);
|
|
25440
|
+
write({ projects: get().projects.map((p) => p.id === projectId ? {
|
|
25441
|
+
...p,
|
|
25442
|
+
voyageCall: {
|
|
25443
|
+
call,
|
|
25444
|
+
at,
|
|
25445
|
+
byId,
|
|
25446
|
+
...text ? { note: text } : {}
|
|
25447
|
+
}
|
|
25448
|
+
} : p) });
|
|
25449
|
+
},
|
|
24807
25450
|
snapshotBaseline: (programId, reset = false) => {
|
|
24808
25451
|
const inProgram = new Set(get().projects.filter((p) => p.programId === programId).map((p) => p.id));
|
|
24809
25452
|
const stamped = withBaseline(get().projects.filter((p) => inProgram.has(p.id)), reset, stamp());
|
|
@@ -24961,8 +25604,10 @@ var init_hierarchy = __esmMin((() => {
|
|
|
24961
25604
|
init_derived_health();
|
|
24962
25605
|
init_shared();
|
|
24963
25606
|
init_assignment();
|
|
25607
|
+
init_scuttle_rows();
|
|
24964
25608
|
init_hill();
|
|
24965
25609
|
init_charter_lines();
|
|
25610
|
+
init_landfall_lines();
|
|
24966
25611
|
init_format();
|
|
24967
25612
|
init_empty();
|
|
24968
25613
|
init_first_week();
|
package/lib/cli.mjs
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
notesText,
|
|
36
36
|
messagesText,
|
|
37
37
|
charterText,
|
|
38
|
+
landfallText,
|
|
38
39
|
daysText,
|
|
39
40
|
soundingText,
|
|
40
41
|
lifeboatsText,
|
|
@@ -54,6 +55,8 @@ import {
|
|
|
54
55
|
catchupText,
|
|
55
56
|
handoverText,
|
|
56
57
|
fogText,
|
|
58
|
+
scuttleText,
|
|
59
|
+
scuttleCallText,
|
|
57
60
|
agentWorkText,
|
|
58
61
|
raidDetail,
|
|
59
62
|
raidList,
|
|
@@ -261,6 +264,11 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
261
264
|
a project's five-line charter
|
|
262
265
|
(why, in, out, done when, who
|
|
263
266
|
makes the calls): read or set
|
|
267
|
+
harbor landfall PROJECT [--expect T] [--after 30|90|180] [--measure T]
|
|
268
|
+
[--answer yes|partly|no] [--value N] [--note T]
|
|
269
|
+
did a Done project pay off? say
|
|
270
|
+
what should be true, and answer
|
|
271
|
+
on the day
|
|
264
272
|
harbor note PROJECT ["NOTE"] [--opens DATE|--milestone NAME|--off-track]
|
|
265
273
|
a note for later: sealed until
|
|
266
274
|
its day, milestone, or Off track
|
|
@@ -295,6 +303,13 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
295
303
|
harbor fog --sweep [--project P] one at a time: still needed, a
|
|
296
304
|
new date, or drop it
|
|
297
305
|
harbor fog keep TASK it's still needed: out of the fog
|
|
306
|
+
harbor scuttle [--program P|--portfolio P] projects that look adrift: still
|
|
307
|
+
for weeks, slipping, long late
|
|
308
|
+
harbor scuttle sail|refit PROJECT [--end DATE] [--note TEXT]
|
|
309
|
+
keep it, or refit it, for three
|
|
310
|
+
months out of the line-up
|
|
311
|
+
harbor scuttle PROJECT --why "TEXT" stop it on purpose: Done, on the
|
|
312
|
+
register, its tasks set free
|
|
298
313
|
harbor watch [--project P] [--bell] a live ticker of the harbor's
|
|
299
314
|
changes; yours bright, q stops
|
|
300
315
|
harbor watch --once [--since TIME] [--json] the latest changes (or all since
|
|
@@ -1016,6 +1031,24 @@ const DETAIL = {
|
|
|
1016
1031
|
" --no-people. A task already in its project is left alone: by its key",
|
|
1017
1032
|
" (a Key column, as harbor export writes) or by title.",
|
|
1018
1033
|
],
|
|
1034
|
+
landfall: [
|
|
1035
|
+
" harbor landfall PROJECT reads its landfall: what should be",
|
|
1036
|
+
" true some days after it's Done,",
|
|
1037
|
+
" when Harbor asks, and the answer",
|
|
1038
|
+
" Say what landfall looks like:",
|
|
1039
|
+
' --expect "TEXT" what should be true by then',
|
|
1040
|
+
" --after 30|90|180 days after the project is marked Done to look",
|
|
1041
|
+
" (90 unless you say)",
|
|
1042
|
+
' --measure "TEXT" what the number counts, if there is one',
|
|
1043
|
+
" Answer it (on its day, or sooner):",
|
|
1044
|
+
" --answer yes|partly|no",
|
|
1045
|
+
" --value N the number on the day",
|
|
1046
|
+
' --note "TEXT" what you found',
|
|
1047
|
+
" --clear takes the landfall off (with --answer, only the answer), so it",
|
|
1048
|
+
" asks again. The owner is asked in their Inbox on the day; the portfolio",
|
|
1049
|
+
" counts the voyages that paid off. PROJECT is a name, a key like RR, or",
|
|
1050
|
+
" an id.",
|
|
1051
|
+
],
|
|
1019
1052
|
charter: [
|
|
1020
1053
|
" harbor charter PROJECT reads its charter, and any open task",
|
|
1021
1054
|
" that names something it leaves out",
|
|
@@ -1149,6 +1182,24 @@ const DETAIL = {
|
|
|
1149
1182
|
" The sweep needs a terminal and full access (harbor login without",
|
|
1150
1183
|
" --read-only), since dropping a task deletes it.",
|
|
1151
1184
|
],
|
|
1185
|
+
scuttle: [
|
|
1186
|
+
" harbor scuttle [--program P] reads: running projects that look",
|
|
1187
|
+
" [--portfolio P] adrift (nothing moved for four",
|
|
1188
|
+
" weeks, slipping again and again, long",
|
|
1189
|
+
" past the end, paused for a month),",
|
|
1190
|
+
" and what stopping each would free",
|
|
1191
|
+
" harbor scuttle sail PROJECT writes: keep it sailing; it leaves",
|
|
1192
|
+
" the line-up for three months",
|
|
1193
|
+
" harbor scuttle refit PROJECT writes: a new end date and/or a line",
|
|
1194
|
+
' [--end DATE] [--note "TEXT"] on what changes',
|
|
1195
|
+
' harbor scuttle PROJECT --why "TEXT" writes: stop it. It closes as Done',
|
|
1196
|
+
" with the why as its note, a decision",
|
|
1197
|
+
" goes on the register, and its open",
|
|
1198
|
+
" tasks come off people's lists",
|
|
1199
|
+
"",
|
|
1200
|
+
" Nothing is deleted: harbor health auto --project P raises a scuttled project",
|
|
1201
|
+
" again and gives its tasks back.",
|
|
1202
|
+
],
|
|
1152
1203
|
watch: [
|
|
1153
1204
|
" harbor watch reads, live: each change to the harbor",
|
|
1154
1205
|
" as it lands, one line with its time",
|
|
@@ -3259,6 +3310,70 @@ export async function run(argv, io = {}) {
|
|
|
3259
3310
|
: `Cleared ${r.project}'s charter.`,
|
|
3260
3311
|
);
|
|
3261
3312
|
}
|
|
3313
|
+
case "landfall": {
|
|
3314
|
+
const { positionals, values, harbor } = parse(
|
|
3315
|
+
spec(["clear"], ["expect", "after", "measure", "answer", "value", "note"]),
|
|
3316
|
+
);
|
|
3317
|
+
const project = one(positionals, 'project: harbor landfall "NAME"');
|
|
3318
|
+
const answering =
|
|
3319
|
+
values.answer !== undefined || values.value !== undefined || values.note !== undefined;
|
|
3320
|
+
const setting =
|
|
3321
|
+
values.expect !== undefined || values.after !== undefined || values.measure !== undefined;
|
|
3322
|
+
if (answering && setting) {
|
|
3323
|
+
throw new HarborError("Set the landfall or answer it, one at a time.");
|
|
3324
|
+
}
|
|
3325
|
+
const show = (r) => landfallText(r.project ?? r.name, r.landfall);
|
|
3326
|
+
const json = (r) => ({ project: r.project ?? r.name, landfall: r.landfall ?? null });
|
|
3327
|
+
if (answering || (values.clear && values.answer !== undefined)) {
|
|
3328
|
+
if (values.clear) {
|
|
3329
|
+
return call("answer_landfall", { harbor, project, clear: true }, show, json);
|
|
3330
|
+
}
|
|
3331
|
+
const result = String(values.answer ?? "").toLowerCase();
|
|
3332
|
+
if (!["yes", "partly", "no"].includes(result)) {
|
|
3333
|
+
throw new HarborError("Say --answer yes, partly or no.");
|
|
3334
|
+
}
|
|
3335
|
+
let value;
|
|
3336
|
+
if (values.value !== undefined) {
|
|
3337
|
+
value = Number(String(values.value).replace(/,/g, ""));
|
|
3338
|
+
if (!Number.isFinite(value)) throw new HarborError("--value takes a number, like 120.");
|
|
3339
|
+
}
|
|
3340
|
+
return call(
|
|
3341
|
+
"answer_landfall",
|
|
3342
|
+
{
|
|
3343
|
+
harbor,
|
|
3344
|
+
project,
|
|
3345
|
+
result,
|
|
3346
|
+
...(value !== undefined ? { value } : {}),
|
|
3347
|
+
...(values.note !== undefined ? { note: values.note } : {}),
|
|
3348
|
+
},
|
|
3349
|
+
show,
|
|
3350
|
+
json,
|
|
3351
|
+
);
|
|
3352
|
+
}
|
|
3353
|
+
if (values.clear) return call("set_landfall", { harbor, project, clear: true }, show, json);
|
|
3354
|
+
if (setting) {
|
|
3355
|
+
let afterDays;
|
|
3356
|
+
if (values.after !== undefined) {
|
|
3357
|
+
afterDays = Number(String(values.after).replace(/\s*days?$/i, ""));
|
|
3358
|
+
if (![30, 90, 180].includes(afterDays)) {
|
|
3359
|
+
throw new HarborError("--after takes 30, 90 or 180 (days after the project is Done).");
|
|
3360
|
+
}
|
|
3361
|
+
}
|
|
3362
|
+
return call(
|
|
3363
|
+
"set_landfall",
|
|
3364
|
+
{
|
|
3365
|
+
harbor,
|
|
3366
|
+
project,
|
|
3367
|
+
...(values.expect !== undefined ? { expect: values.expect } : {}),
|
|
3368
|
+
...(afterDays !== undefined ? { afterDays } : {}),
|
|
3369
|
+
...(values.measure !== undefined ? { measure: values.measure } : {}),
|
|
3370
|
+
},
|
|
3371
|
+
show,
|
|
3372
|
+
json,
|
|
3373
|
+
);
|
|
3374
|
+
}
|
|
3375
|
+
return call("get_project", { harbor, project }, show, json);
|
|
3376
|
+
}
|
|
3262
3377
|
case "sound": {
|
|
3263
3378
|
const { positionals, values, harbor } = parse(spec([], ["answer", "why", "ask", "settle"]));
|
|
3264
3379
|
const [task, ...extra] = positionals;
|
|
@@ -3579,6 +3694,49 @@ export async function run(argv, io = {}) {
|
|
|
3579
3694
|
}
|
|
3580
3695
|
return call("fog", { harbor, project: values.project, limit: 100 }, (r) => fogText(r));
|
|
3581
3696
|
}
|
|
3697
|
+
case "scuttle": {
|
|
3698
|
+
const { positionals, values, harbor } = parse(
|
|
3699
|
+
spec(["list"], ["program", "portfolio", "end", "note", "why"]),
|
|
3700
|
+
);
|
|
3701
|
+
const [first, ...rest] = positionals;
|
|
3702
|
+
if (first === "sail" || first === "refit") {
|
|
3703
|
+
if (values.why !== undefined)
|
|
3704
|
+
throw new HarborError(`--why goes with scuttling: harbor scuttle PROJECT --why "TEXT".`);
|
|
3705
|
+
const project = one(rest, "project");
|
|
3706
|
+
if (first === "sail" && (values.end !== undefined || values.note !== undefined))
|
|
3707
|
+
throw new HarborError("--end and --note go with a refit: harbor scuttle refit PROJECT --end DATE.");
|
|
3708
|
+
if (first === "refit" && values.end === undefined && values.note === undefined)
|
|
3709
|
+
throw new HarborError('A refit needs --end DATE or --note "what changes".');
|
|
3710
|
+
return call(
|
|
3711
|
+
"answer_scuttle_or_sail",
|
|
3712
|
+
{
|
|
3713
|
+
harbor,
|
|
3714
|
+
project,
|
|
3715
|
+
call: first,
|
|
3716
|
+
...(values.end !== undefined ? { endDate: dueDate(values.end) } : {}),
|
|
3717
|
+
...(values.note !== undefined ? { note: values.note } : {}),
|
|
3718
|
+
},
|
|
3719
|
+
scuttleCallText,
|
|
3720
|
+
);
|
|
3721
|
+
}
|
|
3722
|
+
if (first !== undefined) {
|
|
3723
|
+
const project = one(positionals, "project");
|
|
3724
|
+
if (values.why === undefined || !values.why.trim())
|
|
3725
|
+
throw new HarborError(
|
|
3726
|
+
`Say why it stops: harbor scuttle ${project} --why "TEXT". To keep it: harbor scuttle sail ${project}.`,
|
|
3727
|
+
);
|
|
3728
|
+
return call(
|
|
3729
|
+
"answer_scuttle_or_sail",
|
|
3730
|
+
{ harbor, project, call: "scuttle", why: values.why },
|
|
3731
|
+
scuttleCallText,
|
|
3732
|
+
);
|
|
3733
|
+
}
|
|
3734
|
+
return call(
|
|
3735
|
+
"scuttle_or_sail",
|
|
3736
|
+
{ harbor, program: values.program, portfolio: values.portfolio, limit: 100 },
|
|
3737
|
+
scuttleText,
|
|
3738
|
+
);
|
|
3739
|
+
}
|
|
3582
3740
|
case "bridge": {
|
|
3583
3741
|
const { positionals, values, harbor } = parse(spec(["list"], ["program"]));
|
|
3584
3742
|
none(positionals);
|
package/lib/format.mjs
CHANGED
|
@@ -815,6 +815,34 @@ export function charterText(r) {
|
|
|
815
815
|
return lines.join("\n");
|
|
816
816
|
}
|
|
817
817
|
|
|
818
|
+
const LANDFALL_RESULT = { yes: "Paid off", partly: "Partly", no: "Didn't" };
|
|
819
|
+
|
|
820
|
+
/** A project's landfall as get_project, set_landfall or answer_landfall gives it. */
|
|
821
|
+
export function landfallText(project, lf) {
|
|
822
|
+
if (!lf) {
|
|
823
|
+
return `${project} has no landfall yet. Say what should be true some days after it's Done:\n harbor landfall "${project}" --expect "…" --after 90 [--measure "weekly sign-ups"]`;
|
|
824
|
+
}
|
|
825
|
+
const lines = [`Landfall · ${project}`, ` Looks like ${lf.expect}`];
|
|
826
|
+
if (lf.measure) lines.push(` Counting ${lf.measure}`);
|
|
827
|
+
if (lf.result) {
|
|
828
|
+
const value =
|
|
829
|
+
lf.value != null ? ` · ${Number(lf.value).toLocaleString("en-US")}${lf.measure ? ` ${lf.measure}` : ""}` : "";
|
|
830
|
+
lines.push(` Answer ${LANDFALL_RESULT[lf.result] ?? lf.result}${value}`);
|
|
831
|
+
if (lf.note) lines.push(` ${lf.note}`);
|
|
832
|
+
if (lf.answeredOn)
|
|
833
|
+
lines.push(` said by ${lf.answeredBy ?? "someone"} on ${lf.answeredOn}`);
|
|
834
|
+
} else if (lf.state === "due") {
|
|
835
|
+
lines.push(
|
|
836
|
+
` Due now its day was ${lf.checkOn}. Answer: harbor landfall "${project}" --answer yes|partly|no [--value N]`,
|
|
837
|
+
);
|
|
838
|
+
} else if (lf.state === "waiting") {
|
|
839
|
+
lines.push(` Check on ${lf.checkOn} (${lf.afterDays} days after Done)`);
|
|
840
|
+
} else {
|
|
841
|
+
lines.push(` Check ${lf.afterDays} days after the project is marked Done`);
|
|
842
|
+
}
|
|
843
|
+
return lines.join("\n");
|
|
844
|
+
}
|
|
845
|
+
|
|
818
846
|
export function notesText(project, notes) {
|
|
819
847
|
if (!notes?.length)
|
|
820
848
|
return `No notes for later on ${project}. Leave one: harbor note "${project}" "NOTE" --opens +30`;
|
|
@@ -1124,6 +1152,45 @@ export function fogText(r, { sweepHint = true } = {}) {
|
|
|
1124
1152
|
return lines.join("\n");
|
|
1125
1153
|
}
|
|
1126
1154
|
|
|
1155
|
+
/** harbor scuttle: projects that look adrift, with what stopping each would free. */
|
|
1156
|
+
export function scuttleText(r) {
|
|
1157
|
+
const where = r.program ? ` in ${r.program}` : r.portfolio ? ` in ${r.portfolio}` : "";
|
|
1158
|
+
if (!r.adrift)
|
|
1159
|
+
return `Nothing looks adrift${where}: every running project has moved lately, and none keeps slipping.`;
|
|
1160
|
+
const lines = [
|
|
1161
|
+
`${r.adrift} ${r.adrift === 1 ? "project looks" : "projects look"} adrift${where}. Keep each sailing, refit it, or stop it on purpose.`,
|
|
1162
|
+
"",
|
|
1163
|
+
];
|
|
1164
|
+
for (const p of r.projects) {
|
|
1165
|
+
lines.push(
|
|
1166
|
+
` ${(p.key ?? "").padEnd(5)} ${p.project}${p.owner ? ` · ${p.owner}` : ""} · ends ${p.endDate}`,
|
|
1167
|
+
);
|
|
1168
|
+
for (const s of p.signs) lines.push(` ${s.text}`);
|
|
1169
|
+
const frees = [
|
|
1170
|
+
`${p.openTasks} open task${p.openTasks === 1 ? "" : "s"}`,
|
|
1171
|
+
p.crew.length ? `with ${p.crew.map((c) => c.person).filter(Boolean).join(", ")}` : null,
|
|
1172
|
+
p.hoursPerWeek ? `${p.hoursPerWeek}h a week logged` : null,
|
|
1173
|
+
p.spendPerWeek ? `$${p.spendPerWeek.toLocaleString("en-US")} a week of spend` : null,
|
|
1174
|
+
];
|
|
1175
|
+
lines.push(` holds ${frees.filter(Boolean).join(" · ")}`);
|
|
1176
|
+
}
|
|
1177
|
+
if (r.more) lines.push("", `And ${r.adrift - r.projects.length} more.`);
|
|
1178
|
+
lines.push(
|
|
1179
|
+
"",
|
|
1180
|
+
'harbor scuttle sail PROJECT keeps one, harbor scuttle refit PROJECT --end DATE refits it, harbor scuttle PROJECT --why "TEXT" stops it.',
|
|
1181
|
+
);
|
|
1182
|
+
return lines.join("\n");
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
/** What harbor scuttle sail, refit or PROJECT --why did. */
|
|
1186
|
+
export function scuttleCallText(r) {
|
|
1187
|
+
if (r.call === "scuttle")
|
|
1188
|
+
return `Scuttled ${r.project}. ${r.sendOff} The why is on the register as a decision; harbor health auto --project "${r.project}" raises it again.`;
|
|
1189
|
+
if (r.call === "refit")
|
|
1190
|
+
return `Refitted ${r.project}: ${r.note ?? `ends ${r.endDate}`}. It's out of the line-up until ${r.quietUntil}.`;
|
|
1191
|
+
return `${r.project} sails on. It's out of the line-up until ${r.quietUntil}.`;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1127
1194
|
/** harbor lifeboats: work only one person knows, numbered for harbor lifeboats N. */
|
|
1128
1195
|
export function lifeboatsText(r) {
|
|
1129
1196
|
const where = r.program ? ` in ${r.program}` : "";
|