@harborppm/cli 0.96.0 → 0.97.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 +519 -53
- package/lib/cli.mjs +70 -0
- package/lib/format.mjs +39 -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
|
}));
|
|
@@ -5942,18 +5942,18 @@ init_tide();
|
|
|
5942
5942
|
const BURN_DAYS = 56;
|
|
5943
5943
|
/** A window shorter than a week says too little to read a rate from. */
|
|
5944
5944
|
const MIN_DAYS = 7;
|
|
5945
|
-
const noon$
|
|
5945
|
+
const noon$2 = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
|
|
5946
5946
|
/**
|
|
5947
5947
|
* Spend a day over the last BURN_DAYS (or the project's life, when it's
|
|
5948
5948
|
* younger), from the ledger; null when nothing was spent in that time.
|
|
5949
5949
|
*/
|
|
5950
5950
|
function burnPerDay(entries, startDate, now) {
|
|
5951
|
-
const today = noon$
|
|
5952
|
-
const age = differenceInCalendarDays(today, noon$
|
|
5951
|
+
const today = noon$2(todayISO(now));
|
|
5952
|
+
const age = differenceInCalendarDays(today, noon$2(startDate)) + 1;
|
|
5953
5953
|
const span = Math.max(MIN_DAYS, Math.min(56, age));
|
|
5954
5954
|
let sum = 0;
|
|
5955
5955
|
for (const e of entries) {
|
|
5956
|
-
const back = differenceInCalendarDays(today, noon$
|
|
5956
|
+
const back = differenceInCalendarDays(today, noon$2(e.date));
|
|
5957
5957
|
if (back >= 0 && back < span) sum += e.amount;
|
|
5958
5958
|
}
|
|
5959
5959
|
return sum > 0 ? sum / span : null;
|
|
@@ -5975,8 +5975,8 @@ function slipCost(project, tide, entries, now) {
|
|
|
5975
5975
|
const from = project.endDate > today ? project.endDate : today;
|
|
5976
5976
|
const point = (date) => {
|
|
5977
5977
|
if (!date) return null;
|
|
5978
|
-
const daysLate = Math.max(0, differenceInCalendarDays(noon$
|
|
5979
|
-
const ahead = Math.max(0, differenceInCalendarDays(noon$
|
|
5978
|
+
const daysLate = Math.max(0, differenceInCalendarDays(noon$2(date), noon$2(from)));
|
|
5979
|
+
const ahead = Math.max(0, differenceInCalendarDays(noon$2(date), noon$2(today)));
|
|
5980
5980
|
return {
|
|
5981
5981
|
date,
|
|
5982
5982
|
daysLate,
|
|
@@ -10136,11 +10136,11 @@ function hillText(project) {
|
|
|
10136
10136
|
if (at === 100) return "Over the hill: done";
|
|
10137
10137
|
return `${HILL_PHASE_LABEL[hillPhase(at)]} (${at} of 100)`;
|
|
10138
10138
|
}
|
|
10139
|
-
var HILL_TOP, STILL_DAYS, HILL_PHASE_LABEL;
|
|
10139
|
+
var HILL_TOP, STILL_DAYS$1, HILL_PHASE_LABEL;
|
|
10140
10140
|
var init_hill = __esmMin((() => {
|
|
10141
10141
|
init_date_fns();
|
|
10142
10142
|
HILL_TOP = 50;
|
|
10143
|
-
STILL_DAYS = 14;
|
|
10143
|
+
STILL_DAYS$1 = 14;
|
|
10144
10144
|
HILL_PHASE_LABEL = {
|
|
10145
10145
|
figuring: "Figuring it out",
|
|
10146
10146
|
doing: "Getting it done"
|
|
@@ -14807,6 +14807,12 @@ function taskMinutes(task) {
|
|
|
14807
14807
|
function entriesNewestFirst(task) {
|
|
14808
14808
|
return [...task.time ?? []].sort((a, b) => (b.on + b.at).localeCompare(a.on + a.at));
|
|
14809
14809
|
}
|
|
14810
|
+
/** The ISO day `n` days before `iso`. */
|
|
14811
|
+
function daysBefore(iso, n) {
|
|
14812
|
+
const d = /* @__PURE__ */ new Date(`${iso}T12:00:00Z`);
|
|
14813
|
+
d.setUTCDate(d.getUTCDate() - n);
|
|
14814
|
+
return d.toISOString().slice(0, 10);
|
|
14815
|
+
}
|
|
14810
14816
|
|
|
14811
14817
|
// src/lib/goals.ts
|
|
14812
14818
|
init_indexes();
|
|
@@ -20783,8 +20789,8 @@ init_date_fns();
|
|
|
20783
20789
|
init_blockers();
|
|
20784
20790
|
init_format();
|
|
20785
20791
|
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));
|
|
20792
|
+
const noon$1 = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
|
|
20793
|
+
const days$2 = (from, to) => differenceInCalendarDays(noon$1(to), noon$1(from));
|
|
20788
20794
|
const later = (a, b) => !a ? b : !b ? a : a >= b ? a : b;
|
|
20789
20795
|
const open = (t) => t.status !== "done";
|
|
20790
20796
|
/** True when `a` finishes later than `b`, or as late behind a longer line. */
|
|
@@ -21815,8 +21821,349 @@ var init_fog = __esmMin((() => {
|
|
|
21815
21821
|
fileCache = /* @__PURE__ */ new WeakMap();
|
|
21816
21822
|
}));
|
|
21817
21823
|
|
|
21818
|
-
// src/lib/
|
|
21824
|
+
// src/lib/assignment.ts
|
|
21825
|
+
/**
|
|
21826
|
+
* Record who set a task's assignee and when. Only a change of assignee
|
|
21827
|
+
* stamps; clearing the assignee clears the stamp.
|
|
21828
|
+
*/
|
|
21829
|
+
function stampAssignment(before, next, by, at) {
|
|
21830
|
+
if (before && before.assigneeId === next.assigneeId) return next;
|
|
21831
|
+
if (!next.assigneeId) return {
|
|
21832
|
+
...next,
|
|
21833
|
+
assignedBy: null,
|
|
21834
|
+
assignedAt: null
|
|
21835
|
+
};
|
|
21836
|
+
return {
|
|
21837
|
+
...next,
|
|
21838
|
+
assignedBy: by,
|
|
21839
|
+
assignedAt: at
|
|
21840
|
+
};
|
|
21841
|
+
}
|
|
21842
|
+
var init_assignment = __esmMin((() => {}));
|
|
21843
|
+
|
|
21844
|
+
// src/lib/scuttle-rows.ts
|
|
21845
|
+
/** Whether it's scuttled: closed through Scuttle or sail and not raised since. */
|
|
21846
|
+
function isScuttled(project) {
|
|
21847
|
+
return Boolean(project.scuttled) && project.healthOverride === "done";
|
|
21848
|
+
}
|
|
21849
|
+
/**
|
|
21850
|
+
* Scuttle a project: Done by hand with the reason as its note, its open
|
|
21851
|
+
* tasks unassigned and undated (kept in `scuttled.freed`), and a decision on
|
|
21852
|
+
* the register. Null for a project that's already scuttled or gone, or a
|
|
21853
|
+
* blank why.
|
|
21854
|
+
*/
|
|
21855
|
+
function scuttleRows(data, projectId, why, byId, at, decisionId, context = "Closed through Scuttle or sail.") {
|
|
21856
|
+
const project = data.projects.find((p) => p.id === projectId);
|
|
21857
|
+
const reason = why.trim().slice(0, 600);
|
|
21858
|
+
if (!project || !reason || isScuttled(project)) return null;
|
|
21859
|
+
const freed = [];
|
|
21860
|
+
const tasks = [];
|
|
21861
|
+
for (const t of tasksOf(data.tasks, projectId)) {
|
|
21862
|
+
if (t.status === "done" || !t.assigneeId && !t.dueDate) continue;
|
|
21863
|
+
freed.push({
|
|
21864
|
+
taskId: t.id,
|
|
21865
|
+
assigneeId: t.assigneeId,
|
|
21866
|
+
dueDate: t.dueDate
|
|
21867
|
+
});
|
|
21868
|
+
tasks.push(stampAssignment(t, {
|
|
21869
|
+
...t,
|
|
21870
|
+
assigneeId: null,
|
|
21871
|
+
dueDate: null,
|
|
21872
|
+
touchedAt: at
|
|
21873
|
+
}, byId, at));
|
|
21874
|
+
}
|
|
21875
|
+
const decision = normalizeRaidItem({
|
|
21876
|
+
id: decisionId,
|
|
21877
|
+
kind: "decision",
|
|
21878
|
+
projectId,
|
|
21879
|
+
title: `Scuttle ${project.name}`,
|
|
21880
|
+
description: context,
|
|
21881
|
+
status: "decided",
|
|
21882
|
+
severity: "low",
|
|
21883
|
+
ownerId: byId,
|
|
21884
|
+
dueDate: null,
|
|
21885
|
+
resolution: "Stop the project. Its open tasks come off people’s lists.",
|
|
21886
|
+
rationale: reason,
|
|
21887
|
+
decidedById: byId,
|
|
21888
|
+
decidedAt: at,
|
|
21889
|
+
createdById: byId,
|
|
21890
|
+
createdAt: at,
|
|
21891
|
+
updatedAt: at,
|
|
21892
|
+
updates: []
|
|
21893
|
+
});
|
|
21894
|
+
return {
|
|
21895
|
+
project: {
|
|
21896
|
+
...project,
|
|
21897
|
+
health: "done",
|
|
21898
|
+
healthOverride: "done",
|
|
21899
|
+
healthNote: `Scuttled: ${reason}`,
|
|
21900
|
+
scuttled: {
|
|
21901
|
+
at,
|
|
21902
|
+
byId,
|
|
21903
|
+
why: reason,
|
|
21904
|
+
decisionId,
|
|
21905
|
+
freed
|
|
21906
|
+
}
|
|
21907
|
+
},
|
|
21908
|
+
tasks,
|
|
21909
|
+
decision
|
|
21910
|
+
};
|
|
21911
|
+
}
|
|
21912
|
+
/**
|
|
21913
|
+
* Raise a scuttled project: the scuttle cleared (the caller clears its Done), and each
|
|
21914
|
+
* freed task given back its person and date, unless someone has since given
|
|
21915
|
+
* it others, it's done, or the person has left the crew. Null when it isn't
|
|
21916
|
+
* scuttled.
|
|
21917
|
+
*/
|
|
21918
|
+
function raiseRows(data, projectId, at, byId) {
|
|
21919
|
+
const project = data.projects.find((p) => p.id === projectId);
|
|
21920
|
+
if (!project?.scuttled) return null;
|
|
21921
|
+
const people = new Set(data.people.map((p) => p.id));
|
|
21922
|
+
const byTask = new Map(project.scuttled.freed.map((f) => [f.taskId, f]));
|
|
21923
|
+
const tasks = [];
|
|
21924
|
+
for (const t of tasksOf(data.tasks, projectId)) {
|
|
21925
|
+
const f = byTask.get(t.id);
|
|
21926
|
+
if (!f || t.status === "done") continue;
|
|
21927
|
+
const assigneeId = !t.assigneeId && f.assigneeId && people.has(f.assigneeId) ? f.assigneeId : t.assigneeId;
|
|
21928
|
+
const dueDate = t.dueDate ?? f.dueDate;
|
|
21929
|
+
if (assigneeId === t.assigneeId && dueDate === t.dueDate) continue;
|
|
21930
|
+
tasks.push(stampAssignment(t, {
|
|
21931
|
+
...t,
|
|
21932
|
+
assigneeId,
|
|
21933
|
+
dueDate,
|
|
21934
|
+
touchedAt: at
|
|
21935
|
+
}, byId, at));
|
|
21936
|
+
}
|
|
21937
|
+
return {
|
|
21938
|
+
project: {
|
|
21939
|
+
...project,
|
|
21940
|
+
scuttled: null,
|
|
21941
|
+
voyageCall: {
|
|
21942
|
+
call: "sail",
|
|
21943
|
+
at,
|
|
21944
|
+
byId
|
|
21945
|
+
}
|
|
21946
|
+
},
|
|
21947
|
+
tasks
|
|
21948
|
+
};
|
|
21949
|
+
}
|
|
21950
|
+
var WHY_MAX;
|
|
21951
|
+
var init_scuttle_rows = __esmMin((() => {
|
|
21952
|
+
init_assignment();
|
|
21953
|
+
init_indexes();
|
|
21954
|
+
init_raid();
|
|
21955
|
+
WHY_MAX = 600;
|
|
21956
|
+
}));
|
|
21957
|
+
|
|
21958
|
+
// src/lib/scuttle.ts
|
|
21959
|
+
init_date_fns();
|
|
21819
21960
|
init_fog();
|
|
21961
|
+
init_indexes();
|
|
21962
|
+
init_scuttle_rows();
|
|
21963
|
+
init_spend();
|
|
21964
|
+
init_format();
|
|
21965
|
+
/** Days with nothing moving before a project reads as still. */
|
|
21966
|
+
const STILL_DAYS = 28;
|
|
21967
|
+
/** A project younger than this is left alone: it's still being set up. */
|
|
21968
|
+
const YOUNG_DAYS = 21;
|
|
21969
|
+
/** Sailing on or a refit keeps it out of the line-up this long. */
|
|
21970
|
+
const QUIET_DAYS = 90;
|
|
21971
|
+
/** Slips are counted over this window. */
|
|
21972
|
+
const SLIP_WINDOW_DAYS = 90;
|
|
21973
|
+
/** This many falls into trouble in the window reads as slipping again and again. */
|
|
21974
|
+
const SLIP_TIMES = 3;
|
|
21975
|
+
/** Its end date this far past the baseline reads as drifting. */
|
|
21976
|
+
const DRIFT_DAYS = 21;
|
|
21977
|
+
/** This far past its end date with work open. */
|
|
21978
|
+
const OVERDUE_DAYS = 14;
|
|
21979
|
+
/** Paused this long. */
|
|
21980
|
+
const PAUSED_DAYS = 30;
|
|
21981
|
+
const noon = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
|
|
21982
|
+
const daysSince = (iso, today) => differenceInCalendarDays(noon(today), noon(iso));
|
|
21983
|
+
const TRACK = {
|
|
21984
|
+
"on-track": 0,
|
|
21985
|
+
"at-risk": 1,
|
|
21986
|
+
"off-track": 2
|
|
21987
|
+
};
|
|
21988
|
+
/** When anything last moved on the project: its tasks (as fog reads them) and its hill dot. */
|
|
21989
|
+
function lastMoved(project, data) {
|
|
21990
|
+
let last = project.hillMovedAt ?? null;
|
|
21991
|
+
for (const t of tasksOf(data.tasks, project.id)) {
|
|
21992
|
+
const at = lastTouch(t, data);
|
|
21993
|
+
if (!last || at > last) last = at;
|
|
21994
|
+
}
|
|
21995
|
+
return last;
|
|
21996
|
+
}
|
|
21997
|
+
/** Times its health fell (on track to at risk or off track, at risk to off track) since `since`. */
|
|
21998
|
+
function slipsSince(project, since) {
|
|
21999
|
+
let times = 0;
|
|
22000
|
+
let prev;
|
|
22001
|
+
for (const line of project.healthHistory ?? []) {
|
|
22002
|
+
const rank = TRACK[line.health];
|
|
22003
|
+
if (rank === void 0) {
|
|
22004
|
+
prev = void 0;
|
|
22005
|
+
continue;
|
|
22006
|
+
}
|
|
22007
|
+
if (prev !== void 0 && rank > prev && line.at.slice(0, 10) >= since) times += 1;
|
|
22008
|
+
prev = rank;
|
|
22009
|
+
}
|
|
22010
|
+
return times;
|
|
22011
|
+
}
|
|
22012
|
+
/** Days it has been paused, from the history's latest line saying so; null when it isn't, or nobody knows since when. */
|
|
22013
|
+
function pausedDays(project, today) {
|
|
22014
|
+
if (project.health !== "paused") return null;
|
|
22015
|
+
const history = project.healthHistory ?? [];
|
|
22016
|
+
let since = null;
|
|
22017
|
+
for (let i = history.length - 1; i >= 0; i--) {
|
|
22018
|
+
if (history[i].health !== "paused") break;
|
|
22019
|
+
since = history[i].at;
|
|
22020
|
+
}
|
|
22021
|
+
return since ? Math.max(0, daysSince(since, today)) : null;
|
|
22022
|
+
}
|
|
22023
|
+
/** A goal's work key result names it or its program. Only asked when its portfolio has goals at all. */
|
|
22024
|
+
function aimless(project, data) {
|
|
22025
|
+
const program = data.programs.find((p) => p.id === project.programId);
|
|
22026
|
+
const goals = (program && data.portfolios.find((p) => p.id === program.portfolioId))?.goals ?? [];
|
|
22027
|
+
if (goals.length === 0) return false;
|
|
22028
|
+
return !goals.some((g) => g.keyResults.some((kr) => kr.kind === "work" && (kr.linkIds ?? []).some((id) => id === project.id || id === project.programId)));
|
|
22029
|
+
}
|
|
22030
|
+
const STRENGTH = {
|
|
22031
|
+
overdue: 0,
|
|
22032
|
+
still: 1,
|
|
22033
|
+
empty: 1,
|
|
22034
|
+
paused: 2,
|
|
22035
|
+
slipping: 3,
|
|
22036
|
+
drifted: 4,
|
|
22037
|
+
aimless: 5
|
|
22038
|
+
};
|
|
22039
|
+
/** The signs that put a project in the line-up today, strongest first; empty when it's sailing fine. */
|
|
22040
|
+
function zombieSigns(project, data, today) {
|
|
22041
|
+
const signs = [];
|
|
22042
|
+
const tasks = tasksOf(data.tasks, project.id);
|
|
22043
|
+
const open = tasks.filter((t) => t.status !== "done").length;
|
|
22044
|
+
const age = daysSince(project.startDate, today);
|
|
22045
|
+
if (tasks.length === 0) {
|
|
22046
|
+
if (age >= 28) signs.push({
|
|
22047
|
+
kind: "empty",
|
|
22048
|
+
days: age
|
|
22049
|
+
});
|
|
22050
|
+
} else if (project.health !== "paused") {
|
|
22051
|
+
const moved = lastMoved(project, data);
|
|
22052
|
+
const still = moved ? daysSince(moved, today) : age;
|
|
22053
|
+
if (still >= 28) signs.push({
|
|
22054
|
+
kind: "still",
|
|
22055
|
+
days: still
|
|
22056
|
+
});
|
|
22057
|
+
}
|
|
22058
|
+
const paused = pausedDays(project, today);
|
|
22059
|
+
if (paused !== null && paused >= 30) signs.push({
|
|
22060
|
+
kind: "paused",
|
|
22061
|
+
days: paused
|
|
22062
|
+
});
|
|
22063
|
+
const late = daysSince(project.endDate, today);
|
|
22064
|
+
if (open > 0 && late >= 14 && project.health !== "paused") signs.push({
|
|
22065
|
+
kind: "overdue",
|
|
22066
|
+
days: late,
|
|
22067
|
+
open
|
|
22068
|
+
});
|
|
22069
|
+
const slips = slipsSince(project, daysBefore(today, 90));
|
|
22070
|
+
if (slips >= 3) signs.push({
|
|
22071
|
+
kind: "slipping",
|
|
22072
|
+
times: slips
|
|
22073
|
+
});
|
|
22074
|
+
if (project.baselineEnd) {
|
|
22075
|
+
const drift = differenceInCalendarDays(noon(project.endDate), noon(project.baselineEnd));
|
|
22076
|
+
if (drift >= 21) signs.push({
|
|
22077
|
+
kind: "drifted",
|
|
22078
|
+
days: drift
|
|
22079
|
+
});
|
|
22080
|
+
}
|
|
22081
|
+
if (signs.length > 0 && aimless(project, data)) signs.push({ kind: "aimless" });
|
|
22082
|
+
return signs.sort((a, b) => STRENGTH[a.kind] - STRENGTH[b.kind]);
|
|
22083
|
+
}
|
|
22084
|
+
/** Left out of the line-up: finished, scuttled, too young, or answered within the quarter. */
|
|
22085
|
+
function restingFromReview(project, today) {
|
|
22086
|
+
if (project.health === "done" || isScuttled(project)) return true;
|
|
22087
|
+
if (project.startDate > today || daysSince(project.startDate, today) < 21) return true;
|
|
22088
|
+
const call = project.voyageCall;
|
|
22089
|
+
return Boolean(call && daysSince(call.at, today) < 90);
|
|
22090
|
+
}
|
|
22091
|
+
/** What scuttling it would free: open tasks by person, logged hours and spend a week. */
|
|
22092
|
+
function whatItHolds(project, data, now) {
|
|
22093
|
+
const today = todayISO(now);
|
|
22094
|
+
const tasks = tasksOf(data.tasks, project.id);
|
|
22095
|
+
const by = /* @__PURE__ */ new Map();
|
|
22096
|
+
let openTasks = 0;
|
|
22097
|
+
let minutes = 0;
|
|
22098
|
+
const from = daysBefore(today, 27);
|
|
22099
|
+
for (const t of tasks) {
|
|
22100
|
+
for (const e of t.time ?? []) if (e.on >= from && e.on <= today) minutes += e.minutes;
|
|
22101
|
+
if (t.status === "done") continue;
|
|
22102
|
+
openTasks += 1;
|
|
22103
|
+
if (t.assigneeId) by.set(t.assigneeId, (by.get(t.assigneeId) ?? 0) + 1);
|
|
22104
|
+
}
|
|
22105
|
+
const crew = [...by].map(([personId, open]) => ({
|
|
22106
|
+
personId,
|
|
22107
|
+
open
|
|
22108
|
+
})).sort((a, b) => b.open - a.open);
|
|
22109
|
+
const burn = project.budget > 0 ? burnPerDay(spendEntriesOf({ spendEntries: data.spendEntries ?? [] }, project.id), project.startDate, now) : null;
|
|
22110
|
+
return {
|
|
22111
|
+
openTasks,
|
|
22112
|
+
crew,
|
|
22113
|
+
hoursPerWeek: minutes > 0 ? Math.round(minutes / 60 / 4 * 10) / 10 : null,
|
|
22114
|
+
spendPerWeek: burn ? Math.round(burn * 7) : null
|
|
22115
|
+
};
|
|
22116
|
+
}
|
|
22117
|
+
/**
|
|
22118
|
+
* The line-up: projects showing a zombie sign that aren't resting, the ones
|
|
22119
|
+
* with the most signs first, then the stillest. `projectIds` narrows it to a
|
|
22120
|
+
* portfolio's or program's; leave it out for the whole harbor.
|
|
22121
|
+
*/
|
|
22122
|
+
function scuttleLineUp(data, now, projectIds) {
|
|
22123
|
+
const today = todayISO(now);
|
|
22124
|
+
const out = [];
|
|
22125
|
+
for (const project of data.projects) {
|
|
22126
|
+
if (projectIds && !projectIds.has(project.id)) continue;
|
|
22127
|
+
if (restingFromReview(project, today)) continue;
|
|
22128
|
+
const signs = zombieSigns(project, data, today);
|
|
22129
|
+
if (signs.length === 0) continue;
|
|
22130
|
+
out.push({
|
|
22131
|
+
project,
|
|
22132
|
+
signs,
|
|
22133
|
+
lastMoved: lastMoved(project, data),
|
|
22134
|
+
...whatItHolds(project, data, now)
|
|
22135
|
+
});
|
|
22136
|
+
}
|
|
22137
|
+
const still = (z) => {
|
|
22138
|
+
const s = z.signs.find((x) => x.kind === "still" || x.kind === "empty");
|
|
22139
|
+
return s && "days" in s ? s.days : 0;
|
|
22140
|
+
};
|
|
22141
|
+
return out.sort((a, b) => b.signs.length - a.signs.length || still(b) - still(a) || a.project.name.localeCompare(b.project.name));
|
|
22142
|
+
}
|
|
22143
|
+
/** The projects in a portfolio or a program, for narrowing the line-up. */
|
|
22144
|
+
function projectIdsUnder(data, scope) {
|
|
22145
|
+
const programs = new Set(scope.programId ? [scope.programId] : data.programs.filter((p) => p.portfolioId === scope.portfolioId).map((p) => p.id));
|
|
22146
|
+
return new Set(data.projects.filter((p) => programs.has(p.programId)).map((p) => p.id));
|
|
22147
|
+
}
|
|
22148
|
+
/** "6 weeks", "10 days". */
|
|
22149
|
+
function spanText(days) {
|
|
22150
|
+
if (days >= 14) return `${Math.floor(days / 7)} weeks`;
|
|
22151
|
+
return `${days} day${days === 1 ? "" : "s"}`;
|
|
22152
|
+
}
|
|
22153
|
+
/** One sign in a short sentence. */
|
|
22154
|
+
function signText(sign) {
|
|
22155
|
+
switch (sign.kind) {
|
|
22156
|
+
case "still": return `Nothing has moved in ${spanText(sign.days)}`;
|
|
22157
|
+
case "empty": return `No tasks, ${spanText(sign.days)} after it started`;
|
|
22158
|
+
case "slipping": return `Slipped into trouble ${sign.times} times in three months`;
|
|
22159
|
+
case "drifted": return `Its end date has moved ${spanText(sign.days)} past the plan`;
|
|
22160
|
+
case "overdue": return `${spanText(sign.days)} past its end date, ${sign.open} task${sign.open === 1 ? "" : "s"} still open`;
|
|
22161
|
+
case "paused": return `Paused for ${spanText(sign.days)}`;
|
|
22162
|
+
case "aimless": return "No goal behind it";
|
|
22163
|
+
}
|
|
22164
|
+
}
|
|
22165
|
+
|
|
22166
|
+
// src/lib/task-approval.ts
|
|
21820
22167
|
/** Waits on its approver now: asked, not yet answered, and not done. */
|
|
21821
22168
|
function awaitingApproval(task) {
|
|
21822
22169
|
return Boolean(task?.approval && task.approval.status === "pending" && task.status !== "done");
|
|
@@ -21853,6 +22200,7 @@ init_handover();
|
|
|
21853
22200
|
init_agents();
|
|
21854
22201
|
init_work();
|
|
21855
22202
|
init_task_people();
|
|
22203
|
+
init_fog();
|
|
21856
22204
|
init_passage();
|
|
21857
22205
|
init_board();
|
|
21858
22206
|
init_holidays();
|
|
@@ -23693,6 +24041,53 @@ const HARBOR_TOOLS = [
|
|
|
23693
24041
|
};
|
|
23694
24042
|
}
|
|
23695
24043
|
}),
|
|
24044
|
+
tool({
|
|
24045
|
+
name: "scuttle_or_sail",
|
|
24046
|
+
title: "Projects that look adrift",
|
|
24047
|
+
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,
|
|
24048
|
+
input: object({
|
|
24049
|
+
harbor: harborArg,
|
|
24050
|
+
portfolio: string().max(200).optional().describe("Portfolio id or name, for its projects only."),
|
|
24051
|
+
program: string().max(200).optional().describe("Program id or name, for its projects only."),
|
|
24052
|
+
limit: limitArg(100, 25),
|
|
24053
|
+
offset: offsetArg
|
|
24054
|
+
}),
|
|
24055
|
+
run: (ctx, input) => {
|
|
24056
|
+
const { data } = ctx;
|
|
24057
|
+
const program = input.program ? findOne(data.programs, input.program, "program") : null;
|
|
24058
|
+
const portfolio = !program && input.portfolio ? findOne(data.portfolios, input.portfolio, "portfolio") : null;
|
|
24059
|
+
const ids = program || portfolio ? projectIdsUnder(data, {
|
|
24060
|
+
programId: program?.id,
|
|
24061
|
+
portfolioId: portfolio?.id
|
|
24062
|
+
}) : void 0;
|
|
24063
|
+
const lineUp = scuttleLineUp(data, ctx.now, ids);
|
|
24064
|
+
const people = byId(data.people);
|
|
24065
|
+
return {
|
|
24066
|
+
...program ? { program: program.name } : portfolio ? { portfolio: portfolio.name } : {},
|
|
24067
|
+
adrift: lineUp.length,
|
|
24068
|
+
projects: lineUp.slice(input.offset, input.offset + input.limit).map((z) => ({
|
|
24069
|
+
project: z.project.name,
|
|
24070
|
+
...z.project.key ? { key: z.project.key } : {},
|
|
24071
|
+
health: z.project.health,
|
|
24072
|
+
endDate: z.project.endDate,
|
|
24073
|
+
owner: people.get(z.project.ownerId)?.name ?? null,
|
|
24074
|
+
signs: z.signs.map((s) => ({
|
|
24075
|
+
kind: s.kind,
|
|
24076
|
+
text: signText(s)
|
|
24077
|
+
})),
|
|
24078
|
+
lastMoved: dayOf(z.lastMoved),
|
|
24079
|
+
openTasks: z.openTasks,
|
|
24080
|
+
crew: z.crew.map((c) => ({
|
|
24081
|
+
person: people.get(c.personId)?.name ?? null,
|
|
24082
|
+
openTasks: c.open
|
|
24083
|
+
})),
|
|
24084
|
+
hoursPerWeek: z.hoursPerWeek,
|
|
24085
|
+
spendPerWeek: z.spendPerWeek
|
|
24086
|
+
})),
|
|
24087
|
+
more: lineUp.length > input.offset + input.limit
|
|
24088
|
+
};
|
|
24089
|
+
}
|
|
24090
|
+
}),
|
|
23696
24091
|
tool({
|
|
23697
24092
|
name: "lifeboats",
|
|
23698
24093
|
title: "Work only one person knows",
|
|
@@ -24487,26 +24882,6 @@ var init_session = __esmMin((() => {
|
|
|
24487
24882
|
init_format();
|
|
24488
24883
|
}));
|
|
24489
24884
|
|
|
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
24885
|
// src/lib/first-week.ts
|
|
24511
24886
|
/** The program launch named for you, when nobody has renamed it yet. */
|
|
24512
24887
|
function starterProgram(data) {
|
|
@@ -24518,6 +24893,33 @@ var init_first_week = __esmMin((() => {
|
|
|
24518
24893
|
|
|
24519
24894
|
// src/lib/slices/hierarchy.ts
|
|
24520
24895
|
function hierarchySlice({ get, write }) {
|
|
24896
|
+
/**
|
|
24897
|
+
* A scuttled project being reopened (its Done cleared or changed): its
|
|
24898
|
+
* freed tasks get their people and dates back, and its decision says so.
|
|
24899
|
+
* Null when it isn't scuttled.
|
|
24900
|
+
*/
|
|
24901
|
+
const raise = (projectId, at) => {
|
|
24902
|
+
const s = get();
|
|
24903
|
+
const rows = raiseRows(s, projectId, at, s.currentUserId);
|
|
24904
|
+
if (!rows) return null;
|
|
24905
|
+
const changed = new Map(rows.tasks.map((t) => [t.id, t]));
|
|
24906
|
+
const decisionId = s.projects.find((p) => p.id === projectId)?.scuttled?.decisionId;
|
|
24907
|
+
const note = {
|
|
24908
|
+
id: uid("ru"),
|
|
24909
|
+
authorId: s.currentUserId,
|
|
24910
|
+
body: "Raised again: the project is back, and its tasks went back to their people.",
|
|
24911
|
+
createdAt: at
|
|
24912
|
+
};
|
|
24913
|
+
return {
|
|
24914
|
+
projects: s.projects.map((p) => p.id === projectId ? rows.project : p),
|
|
24915
|
+
tasks: s.tasks.map((t) => changed.get(t.id) ?? t),
|
|
24916
|
+
raidItems: s.raidItems.map((r) => r.id === decisionId ? {
|
|
24917
|
+
...r,
|
|
24918
|
+
updates: [...r.updates, note],
|
|
24919
|
+
updatedAt: at
|
|
24920
|
+
} : r)
|
|
24921
|
+
};
|
|
24922
|
+
};
|
|
24521
24923
|
return {
|
|
24522
24924
|
addPortfolio: (p) => {
|
|
24523
24925
|
const id = uid("pf");
|
|
@@ -24762,11 +25164,24 @@ function hierarchySlice({ get, write }) {
|
|
|
24762
25164
|
programs: get().programs.map((p) => p.id === id ? mark(p) : p),
|
|
24763
25165
|
projects: parked ? get().projects.map((p) => p.programId === id && (!p.healthOverride || p.healthOverride === "paused" || p.healthOverride === "done") ? mark(p) : p) : get().projects
|
|
24764
25166
|
});
|
|
24765
|
-
} else
|
|
25167
|
+
} else {
|
|
25168
|
+
const raised = health === "done" ? null : raise(id, at);
|
|
25169
|
+
write({
|
|
25170
|
+
projects: (raised?.projects ?? get().projects).map((p) => p.id === id ? mark(p) : p),
|
|
25171
|
+
...raised ? {
|
|
25172
|
+
tasks: raised.tasks,
|
|
25173
|
+
raidItems: raised.raidItems
|
|
25174
|
+
} : {}
|
|
25175
|
+
});
|
|
25176
|
+
}
|
|
24766
25177
|
},
|
|
24767
25178
|
clearHealthOverride: (kind, id) => {
|
|
24768
25179
|
const at = stamp();
|
|
24769
|
-
const
|
|
25180
|
+
const raised = kind === "project" ? raise(id, at) : null;
|
|
25181
|
+
const s = raised ? {
|
|
25182
|
+
...get(),
|
|
25183
|
+
...raised
|
|
25184
|
+
} : get();
|
|
24770
25185
|
const cleared = {
|
|
24771
25186
|
portfolio: /* @__PURE__ */ new Set(),
|
|
24772
25187
|
program: /* @__PURE__ */ new Set(),
|
|
@@ -24801,8 +25216,58 @@ function hierarchySlice({ get, write }) {
|
|
|
24801
25216
|
write({
|
|
24802
25217
|
portfolios: next.portfolios.map(byRules("portfolio")),
|
|
24803
25218
|
programs: next.programs.map(byRules("program")),
|
|
24804
|
-
projects: next.projects.map(byRules("project"))
|
|
25219
|
+
projects: next.projects.map(byRules("project")),
|
|
25220
|
+
...raised ? {
|
|
25221
|
+
tasks: raised.tasks,
|
|
25222
|
+
raidItems: raised.raidItems
|
|
25223
|
+
} : {}
|
|
25224
|
+
});
|
|
25225
|
+
},
|
|
25226
|
+
scuttleProject: (projectId, why, context) => {
|
|
25227
|
+
const s = get();
|
|
25228
|
+
const at = stamp();
|
|
25229
|
+
const decisionId = uid("raid");
|
|
25230
|
+
const rows = scuttleRows(s, projectId, why, s.currentUserId, at, decisionId, context);
|
|
25231
|
+
if (!rows) return null;
|
|
25232
|
+
const before = s.projects.find((p) => p.id === projectId);
|
|
25233
|
+
const changed = new Map(rows.tasks.map((t) => [t.id, t]));
|
|
25234
|
+
write({
|
|
25235
|
+
projects: s.projects.map((p) => p.id === projectId ? {
|
|
25236
|
+
...rows.project,
|
|
25237
|
+
healthHistory: recordHealth(p.healthHistory, "done", at, true, rows.project.healthNote)
|
|
25238
|
+
} : p),
|
|
25239
|
+
tasks: s.tasks.map((t) => changed.get(t.id) ?? t),
|
|
25240
|
+
raidItems: [...s.raidItems, rows.decision]
|
|
24805
25241
|
});
|
|
25242
|
+
get().log(`scuttled ${before.name}`, `/projects/${projectId}`);
|
|
25243
|
+
return {
|
|
25244
|
+
project: before,
|
|
25245
|
+
tasks: s.tasks.filter((t) => changed.has(t.id)),
|
|
25246
|
+
decisionId
|
|
25247
|
+
};
|
|
25248
|
+
},
|
|
25249
|
+
undoScuttle: (undo) => {
|
|
25250
|
+
const s = get();
|
|
25251
|
+
const back = new Map(undo.tasks.map((t) => [t.id, t]));
|
|
25252
|
+
write({
|
|
25253
|
+
projects: s.projects.map((p) => p.id === undo.project.id ? undo.project : p),
|
|
25254
|
+
tasks: s.tasks.map((t) => back.get(t.id) ?? t),
|
|
25255
|
+
raidItems: s.raidItems.filter((r) => r.id !== undo.decisionId)
|
|
25256
|
+
});
|
|
25257
|
+
},
|
|
25258
|
+
answerVoyage: (projectId, call, note) => {
|
|
25259
|
+
const at = stamp();
|
|
25260
|
+
const byId = get().currentUserId;
|
|
25261
|
+
const text = note?.trim().slice(0, 300);
|
|
25262
|
+
write({ projects: get().projects.map((p) => p.id === projectId ? {
|
|
25263
|
+
...p,
|
|
25264
|
+
voyageCall: {
|
|
25265
|
+
call,
|
|
25266
|
+
at,
|
|
25267
|
+
byId,
|
|
25268
|
+
...text ? { note: text } : {}
|
|
25269
|
+
}
|
|
25270
|
+
} : p) });
|
|
24806
25271
|
},
|
|
24807
25272
|
snapshotBaseline: (programId, reset = false) => {
|
|
24808
25273
|
const inProgram = new Set(get().projects.filter((p) => p.programId === programId).map((p) => p.id));
|
|
@@ -24961,6 +25426,7 @@ var init_hierarchy = __esmMin((() => {
|
|
|
24961
25426
|
init_derived_health();
|
|
24962
25427
|
init_shared();
|
|
24963
25428
|
init_assignment();
|
|
25429
|
+
init_scuttle_rows();
|
|
24964
25430
|
init_hill();
|
|
24965
25431
|
init_charter_lines();
|
|
24966
25432
|
init_format();
|
package/lib/cli.mjs
CHANGED
|
@@ -54,6 +54,8 @@ import {
|
|
|
54
54
|
catchupText,
|
|
55
55
|
handoverText,
|
|
56
56
|
fogText,
|
|
57
|
+
scuttleText,
|
|
58
|
+
scuttleCallText,
|
|
57
59
|
agentWorkText,
|
|
58
60
|
raidDetail,
|
|
59
61
|
raidList,
|
|
@@ -295,6 +297,13 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
295
297
|
harbor fog --sweep [--project P] one at a time: still needed, a
|
|
296
298
|
new date, or drop it
|
|
297
299
|
harbor fog keep TASK it's still needed: out of the fog
|
|
300
|
+
harbor scuttle [--program P|--portfolio P] projects that look adrift: still
|
|
301
|
+
for weeks, slipping, long late
|
|
302
|
+
harbor scuttle sail|refit PROJECT [--end DATE] [--note TEXT]
|
|
303
|
+
keep it, or refit it, for three
|
|
304
|
+
months out of the line-up
|
|
305
|
+
harbor scuttle PROJECT --why "TEXT" stop it on purpose: Done, on the
|
|
306
|
+
register, its tasks set free
|
|
298
307
|
harbor watch [--project P] [--bell] a live ticker of the harbor's
|
|
299
308
|
changes; yours bright, q stops
|
|
300
309
|
harbor watch --once [--since TIME] [--json] the latest changes (or all since
|
|
@@ -1149,6 +1158,24 @@ const DETAIL = {
|
|
|
1149
1158
|
" The sweep needs a terminal and full access (harbor login without",
|
|
1150
1159
|
" --read-only), since dropping a task deletes it.",
|
|
1151
1160
|
],
|
|
1161
|
+
scuttle: [
|
|
1162
|
+
" harbor scuttle [--program P] reads: running projects that look",
|
|
1163
|
+
" [--portfolio P] adrift (nothing moved for four",
|
|
1164
|
+
" weeks, slipping again and again, long",
|
|
1165
|
+
" past the end, paused for a month),",
|
|
1166
|
+
" and what stopping each would free",
|
|
1167
|
+
" harbor scuttle sail PROJECT writes: keep it sailing; it leaves",
|
|
1168
|
+
" the line-up for three months",
|
|
1169
|
+
" harbor scuttle refit PROJECT writes: a new end date and/or a line",
|
|
1170
|
+
' [--end DATE] [--note "TEXT"] on what changes',
|
|
1171
|
+
' harbor scuttle PROJECT --why "TEXT" writes: stop it. It closes as Done',
|
|
1172
|
+
" with the why as its note, a decision",
|
|
1173
|
+
" goes on the register, and its open",
|
|
1174
|
+
" tasks come off people's lists",
|
|
1175
|
+
"",
|
|
1176
|
+
" Nothing is deleted: harbor health auto --project P raises a scuttled project",
|
|
1177
|
+
" again and gives its tasks back.",
|
|
1178
|
+
],
|
|
1152
1179
|
watch: [
|
|
1153
1180
|
" harbor watch reads, live: each change to the harbor",
|
|
1154
1181
|
" as it lands, one line with its time",
|
|
@@ -3579,6 +3606,49 @@ export async function run(argv, io = {}) {
|
|
|
3579
3606
|
}
|
|
3580
3607
|
return call("fog", { harbor, project: values.project, limit: 100 }, (r) => fogText(r));
|
|
3581
3608
|
}
|
|
3609
|
+
case "scuttle": {
|
|
3610
|
+
const { positionals, values, harbor } = parse(
|
|
3611
|
+
spec(["list"], ["program", "portfolio", "end", "note", "why"]),
|
|
3612
|
+
);
|
|
3613
|
+
const [first, ...rest] = positionals;
|
|
3614
|
+
if (first === "sail" || first === "refit") {
|
|
3615
|
+
if (values.why !== undefined)
|
|
3616
|
+
throw new HarborError(`--why goes with scuttling: harbor scuttle PROJECT --why "TEXT".`);
|
|
3617
|
+
const project = one(rest, "project");
|
|
3618
|
+
if (first === "sail" && (values.end !== undefined || values.note !== undefined))
|
|
3619
|
+
throw new HarborError("--end and --note go with a refit: harbor scuttle refit PROJECT --end DATE.");
|
|
3620
|
+
if (first === "refit" && values.end === undefined && values.note === undefined)
|
|
3621
|
+
throw new HarborError('A refit needs --end DATE or --note "what changes".');
|
|
3622
|
+
return call(
|
|
3623
|
+
"answer_scuttle_or_sail",
|
|
3624
|
+
{
|
|
3625
|
+
harbor,
|
|
3626
|
+
project,
|
|
3627
|
+
call: first,
|
|
3628
|
+
...(values.end !== undefined ? { endDate: dueDate(values.end) } : {}),
|
|
3629
|
+
...(values.note !== undefined ? { note: values.note } : {}),
|
|
3630
|
+
},
|
|
3631
|
+
scuttleCallText,
|
|
3632
|
+
);
|
|
3633
|
+
}
|
|
3634
|
+
if (first !== undefined) {
|
|
3635
|
+
const project = one(positionals, "project");
|
|
3636
|
+
if (values.why === undefined || !values.why.trim())
|
|
3637
|
+
throw new HarborError(
|
|
3638
|
+
`Say why it stops: harbor scuttle ${project} --why "TEXT". To keep it: harbor scuttle sail ${project}.`,
|
|
3639
|
+
);
|
|
3640
|
+
return call(
|
|
3641
|
+
"answer_scuttle_or_sail",
|
|
3642
|
+
{ harbor, project, call: "scuttle", why: values.why },
|
|
3643
|
+
scuttleCallText,
|
|
3644
|
+
);
|
|
3645
|
+
}
|
|
3646
|
+
return call(
|
|
3647
|
+
"scuttle_or_sail",
|
|
3648
|
+
{ harbor, program: values.program, portfolio: values.portfolio, limit: 100 },
|
|
3649
|
+
scuttleText,
|
|
3650
|
+
);
|
|
3651
|
+
}
|
|
3582
3652
|
case "bridge": {
|
|
3583
3653
|
const { positionals, values, harbor } = parse(spec(["list"], ["program"]));
|
|
3584
3654
|
none(positionals);
|
package/lib/format.mjs
CHANGED
|
@@ -1124,6 +1124,45 @@ export function fogText(r, { sweepHint = true } = {}) {
|
|
|
1124
1124
|
return lines.join("\n");
|
|
1125
1125
|
}
|
|
1126
1126
|
|
|
1127
|
+
/** harbor scuttle: projects that look adrift, with what stopping each would free. */
|
|
1128
|
+
export function scuttleText(r) {
|
|
1129
|
+
const where = r.program ? ` in ${r.program}` : r.portfolio ? ` in ${r.portfolio}` : "";
|
|
1130
|
+
if (!r.adrift)
|
|
1131
|
+
return `Nothing looks adrift${where}: every running project has moved lately, and none keeps slipping.`;
|
|
1132
|
+
const lines = [
|
|
1133
|
+
`${r.adrift} ${r.adrift === 1 ? "project looks" : "projects look"} adrift${where}. Keep each sailing, refit it, or stop it on purpose.`,
|
|
1134
|
+
"",
|
|
1135
|
+
];
|
|
1136
|
+
for (const p of r.projects) {
|
|
1137
|
+
lines.push(
|
|
1138
|
+
` ${(p.key ?? "").padEnd(5)} ${p.project}${p.owner ? ` · ${p.owner}` : ""} · ends ${p.endDate}`,
|
|
1139
|
+
);
|
|
1140
|
+
for (const s of p.signs) lines.push(` ${s.text}`);
|
|
1141
|
+
const frees = [
|
|
1142
|
+
`${p.openTasks} open task${p.openTasks === 1 ? "" : "s"}`,
|
|
1143
|
+
p.crew.length ? `with ${p.crew.map((c) => c.person).filter(Boolean).join(", ")}` : null,
|
|
1144
|
+
p.hoursPerWeek ? `${p.hoursPerWeek}h a week logged` : null,
|
|
1145
|
+
p.spendPerWeek ? `$${p.spendPerWeek.toLocaleString("en-US")} a week of spend` : null,
|
|
1146
|
+
];
|
|
1147
|
+
lines.push(` holds ${frees.filter(Boolean).join(" · ")}`);
|
|
1148
|
+
}
|
|
1149
|
+
if (r.more) lines.push("", `And ${r.adrift - r.projects.length} more.`);
|
|
1150
|
+
lines.push(
|
|
1151
|
+
"",
|
|
1152
|
+
'harbor scuttle sail PROJECT keeps one, harbor scuttle refit PROJECT --end DATE refits it, harbor scuttle PROJECT --why "TEXT" stops it.',
|
|
1153
|
+
);
|
|
1154
|
+
return lines.join("\n");
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/** What harbor scuttle sail, refit or PROJECT --why did. */
|
|
1158
|
+
export function scuttleCallText(r) {
|
|
1159
|
+
if (r.call === "scuttle")
|
|
1160
|
+
return `Scuttled ${r.project}. ${r.sendOff} The why is on the register as a decision; harbor health auto --project "${r.project}" raises it again.`;
|
|
1161
|
+
if (r.call === "refit")
|
|
1162
|
+
return `Refitted ${r.project}: ${r.note ?? `ends ${r.endDate}`}. It's out of the line-up until ${r.quietUntil}.`;
|
|
1163
|
+
return `${r.project} sails on. It's out of the line-up until ${r.quietUntil}.`;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1127
1166
|
/** harbor lifeboats: work only one person knows, numbered for harbor lifeboats N. */
|
|
1128
1167
|
export function lifeboatsText(r) {
|
|
1129
1168
|
const where = r.program ? ` in ${r.program}` : "";
|