@harborppm/cli 0.31.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/dist/app.mjs +685 -92
- package/lib/cli.mjs +7 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ harbor # name your harbor and its first project, and you're in
|
|
|
29
29
|
1. **Today**: today's focus (`f` adds or takes off the task you're on, up to three) and your check-in (`c`); what's on you, late first; who's waiting on you; your risks and issues.
|
|
30
30
|
2. **Inbox**: the same pings as the website: waiting on you, sign-offs, mentions. Enter opens one; it's marked read.
|
|
31
31
|
3. **Projects**: every portfolio, program and project with its health. A project opens on its board (columns side by side; `f` and `p` show only someone's cards or one priority), its list, or its budget and spend ledger (`v`); `e` changes its details: lead, dates, budget, health by hand with a note.
|
|
32
|
-
4. **Programs**: the review: programs worst first, with their lead, end date, late work and where they sit on the priority matrix; `m` writes a next move, `o` and `p` show someone's or one portfolio's. A program shows its projects and milestones side by side (`m` adds a milestone, space ticks one), its next move and health; `v` reads it project by project, each with its bar across the months, open tasks by status and worst risks. A portfolio opens as a board (programs, with risks, next milestones and money beside them); `v` reads it program by program.
|
|
32
|
+
4. **Programs**: the review: programs worst first, with their lead, end date, late work and where they sit on the priority matrix; `m` writes a next move, `o` and `p` show someone's or one portfolio's. A program shows its projects and milestones side by side (`m` adds a milestone, space ticks one), its next move and health; `v` reads it project by project, each with its bar across the months, open tasks by status and worst risks. A portfolio opens as a board (programs, with risks, next milestones and money beside them); `v` reads it program by program. `e` on a program also names what's holding it back, what it waits on, its impact and its baseline, with its health history below, and `r` reads its status report. A milestone can wait on projects and need someone's sign-off (they approve it or ask for changes). A project's details set its plan by month and what it waits on; a portfolio's set the priority matrix's rules. `:` then "new portfolio" makes one.
|
|
33
33
|
5. **Risks**: the register, worst first; `t`, `o` and `p` narrow it by kind, owner or portfolio, `a` adds closed ones. Enter changes one with every field the website has (status, owner, likelihood, impact, mitigation, contingency, resolution, updates); `n` logs a risk, issue or decision.
|
|
34
34
|
6. **People**: open and late work and the next four weeks against each person's capacity; Enter changes their capacity, role, or the time they're promised to a program.
|
|
35
35
|
7. **Roadmap**: a portfolio's programs and projects across the months, in their health's colour, ◆ for milestones.
|
|
@@ -125,6 +125,7 @@ harbor issue "TITLE" --project P|--program P [--impact 1-5] [--owner NAME]
|
|
|
125
125
|
harbor decision "TITLE" --project P|--program P [--owner NAME] [--due DATE]
|
|
126
126
|
harbor risks [words] [--kind risk,issue,decision] [--project P] [--closed]
|
|
127
127
|
harbor brief [--portfolio P] [--say] the weekly brief, ready to send
|
|
128
|
+
harbor report PROGRAM a program's status report
|
|
128
129
|
harbor premortem PROJECT ["WHAT WENT WRONG"] [--start]
|
|
129
130
|
harbor voyage PROJECT ["YOUR LINE"]
|
|
130
131
|
harbor limit PROJECT [COLUMN N|none]
|
package/dist/app.mjs
CHANGED
|
@@ -688,6 +688,39 @@ function startOfMonth(date, options) {
|
|
|
688
688
|
return _date;
|
|
689
689
|
}
|
|
690
690
|
|
|
691
|
+
//#endregion
|
|
692
|
+
//#region node_modules/date-fns/getDaysInMonth.js
|
|
693
|
+
/**
|
|
694
|
+
* The {@link getDaysInMonth} function options.
|
|
695
|
+
*/
|
|
696
|
+
/**
|
|
697
|
+
* @name getDaysInMonth
|
|
698
|
+
* @category Month Helpers
|
|
699
|
+
* @summary Get the number of days in a month of the given date.
|
|
700
|
+
*
|
|
701
|
+
* @description
|
|
702
|
+
* Get the number of days in a month of the given date, considering the context if provided.
|
|
703
|
+
*
|
|
704
|
+
* @param date - The given date
|
|
705
|
+
* @param options - An object with options
|
|
706
|
+
*
|
|
707
|
+
* @returns The number of days in a month
|
|
708
|
+
*
|
|
709
|
+
* @example
|
|
710
|
+
* // How many days are in February 2000?
|
|
711
|
+
* const result = getDaysInMonth(new Date(2000, 1))
|
|
712
|
+
* //=> 29
|
|
713
|
+
*/
|
|
714
|
+
function getDaysInMonth(date, options) {
|
|
715
|
+
const _date = toDate(date, options?.in);
|
|
716
|
+
const year = _date.getFullYear();
|
|
717
|
+
const monthIndex = _date.getMonth();
|
|
718
|
+
const lastDayOfMonth = constructFrom(_date, 0);
|
|
719
|
+
lastDayOfMonth.setFullYear(year, monthIndex + 1, 0);
|
|
720
|
+
lastDayOfMonth.setHours(0, 0, 0, 0);
|
|
721
|
+
return lastDayOfMonth.getDate();
|
|
722
|
+
}
|
|
723
|
+
|
|
691
724
|
//#endregion
|
|
692
725
|
//#region node_modules/date-fns/isBefore.js
|
|
693
726
|
/**
|
|
@@ -963,6 +996,18 @@ const PRIORITY_LABEL = {
|
|
|
963
996
|
high: "High",
|
|
964
997
|
urgent: "Urgent"
|
|
965
998
|
};
|
|
999
|
+
const CONSTRAINT_KIND_LABEL = {
|
|
1000
|
+
resource: "A person",
|
|
1001
|
+
policy: "A policy",
|
|
1002
|
+
external: "Outside the building",
|
|
1003
|
+
collision: "A collision"
|
|
1004
|
+
};
|
|
1005
|
+
const CONSTRAINT_KIND_HINT = {
|
|
1006
|
+
resource: "Someone whose calendar sets the date.",
|
|
1007
|
+
policy: "A rule that creates the wait.",
|
|
1008
|
+
external: "A vendor, a city, a review board.",
|
|
1009
|
+
collision: "Two things sharing one window."
|
|
1010
|
+
};
|
|
966
1011
|
|
|
967
1012
|
//#endregion
|
|
968
1013
|
//#region src/tui/create.ts
|
|
@@ -987,6 +1032,7 @@ function nextSwatch(used) {
|
|
|
987
1032
|
return SWATCHES.find((s) => !used.includes(s)) ?? SWATCHES[0];
|
|
988
1033
|
}
|
|
989
1034
|
const CREATE_TITLE = {
|
|
1035
|
+
portfolio: "New portfolio",
|
|
990
1036
|
project: "New project",
|
|
991
1037
|
program: "New program",
|
|
992
1038
|
milestone: "New milestone",
|
|
@@ -1001,6 +1047,28 @@ function stepsFor(kind, data, ctx, me) {
|
|
|
1001
1047
|
}));
|
|
1002
1048
|
const meFirst = Math.max(0, people.findIndex((p) => p.value === me));
|
|
1003
1049
|
switch (kind) {
|
|
1050
|
+
case "portfolio": return [
|
|
1051
|
+
{
|
|
1052
|
+
key: "name",
|
|
1053
|
+
label: "Name",
|
|
1054
|
+
kind: "text",
|
|
1055
|
+
placeholder: "e.g. Outdoor gear"
|
|
1056
|
+
},
|
|
1057
|
+
{
|
|
1058
|
+
key: "ownerId",
|
|
1059
|
+
label: "Owner",
|
|
1060
|
+
kind: "pick",
|
|
1061
|
+
choices: people,
|
|
1062
|
+
initial: meFirst
|
|
1063
|
+
},
|
|
1064
|
+
{
|
|
1065
|
+
key: "theme",
|
|
1066
|
+
label: "Theme",
|
|
1067
|
+
kind: "text",
|
|
1068
|
+
placeholder: "What it's for, in a line",
|
|
1069
|
+
optional: true
|
|
1070
|
+
}
|
|
1071
|
+
];
|
|
1004
1072
|
case "project": return [
|
|
1005
1073
|
{
|
|
1006
1074
|
key: "name",
|
|
@@ -1158,6 +1226,14 @@ function commitCreate(store, kind, v, ctx, today) {
|
|
|
1158
1226
|
const s = store.getState();
|
|
1159
1227
|
const str = (k) => typeof v[k] === "string" ? v[k] : "";
|
|
1160
1228
|
switch (kind) {
|
|
1229
|
+
case "portfolio": return s.addPortfolio({
|
|
1230
|
+
name: str("name"),
|
|
1231
|
+
description: "",
|
|
1232
|
+
swatch: nextSwatch(s.portfolios.map((p) => p.swatch)),
|
|
1233
|
+
ownerId: str("ownerId"),
|
|
1234
|
+
budget: 0,
|
|
1235
|
+
...str("theme") ? { theme: str("theme") } : {}
|
|
1236
|
+
});
|
|
1161
1237
|
case "project": {
|
|
1162
1238
|
const programId = ctx.programId ?? str("programId");
|
|
1163
1239
|
const program = s.programs.find((p) => p.id === programId);
|
|
@@ -1569,7 +1645,7 @@ function cascadeSummary(counts, kind) {
|
|
|
1569
1645
|
|
|
1570
1646
|
//#endregion
|
|
1571
1647
|
//#region src/lib/forecast.ts
|
|
1572
|
-
const day$
|
|
1648
|
+
const day$4 = (iso) => /* @__PURE__ */ new Date(`${iso}T12:00:00`);
|
|
1573
1649
|
/**
|
|
1574
1650
|
* A straight-line estimate at completion from the burn so far: spend divided
|
|
1575
1651
|
* by the share of the schedule elapsed. Null before anything is spent or
|
|
@@ -1578,8 +1654,8 @@ const day$3 = (iso) => /* @__PURE__ */ new Date(`${iso}T12:00:00`);
|
|
|
1578
1654
|
*/
|
|
1579
1655
|
function forecastAtCompletion(item, now) {
|
|
1580
1656
|
if (item.spent <= 0) return null;
|
|
1581
|
-
const total = differenceInCalendarDays(day$
|
|
1582
|
-
const gone = differenceInCalendarDays(now, day$
|
|
1657
|
+
const total = differenceInCalendarDays(day$4(item.endDate), day$4(item.startDate)) + 1;
|
|
1658
|
+
const gone = differenceInCalendarDays(now, day$4(item.startDate)) + 1;
|
|
1583
1659
|
if (total <= 0 || gone <= 0) return null;
|
|
1584
1660
|
const elapsed = Math.min(1, gone / total);
|
|
1585
1661
|
const eac = Math.round(item.spent / elapsed);
|
|
@@ -1590,6 +1666,18 @@ function forecastAtCompletion(item, now) {
|
|
|
1590
1666
|
tone: item.budget <= 0 ? "over" : eac > item.budget * 1.05 ? "over" : eac > item.budget * .95 ? "tight" : "under"
|
|
1591
1667
|
};
|
|
1592
1668
|
}
|
|
1669
|
+
/** Sum of the children's estimates, using spend where a child has no forecast yet. */
|
|
1670
|
+
function forecastRollup(items, budget, now) {
|
|
1671
|
+
const eac = items.map((i) => forecastAtCompletion(i, now)?.eac ?? i.spent).reduce((a, b) => a + b, 0);
|
|
1672
|
+
if (eac <= 0) return null;
|
|
1673
|
+
const elapsed = Math.max(0, ...items.map((i) => forecastAtCompletion(i, now)?.elapsed ?? 0));
|
|
1674
|
+
return {
|
|
1675
|
+
eac,
|
|
1676
|
+
variance: budget - eac,
|
|
1677
|
+
elapsed,
|
|
1678
|
+
tone: budget <= 0 ? "over" : eac > budget * 1.05 ? "over" : eac > budget * .95 ? "tight" : "under"
|
|
1679
|
+
};
|
|
1680
|
+
}
|
|
1593
1681
|
|
|
1594
1682
|
//#endregion
|
|
1595
1683
|
//#region src/lib/tide.ts
|
|
@@ -1807,6 +1895,73 @@ function constraintIdle(data, constraint) {
|
|
|
1807
1895
|
return data.tasks.some((t) => t.assigneeId === constraint.ownerId && t.status === "doing" && !here.has(t.id));
|
|
1808
1896
|
}
|
|
1809
1897
|
|
|
1898
|
+
//#endregion
|
|
1899
|
+
//#region src/lib/phasing.ts
|
|
1900
|
+
const day$3 = (iso) => /* @__PURE__ */ new Date(`${iso}T12:00:00`);
|
|
1901
|
+
function monthKey(iso) {
|
|
1902
|
+
return iso.slice(0, 7);
|
|
1903
|
+
}
|
|
1904
|
+
function nextMonth(key) {
|
|
1905
|
+
const [y, m] = key.split("-").map(Number);
|
|
1906
|
+
return m === 12 ? `${y + 1}-01` : `${y}-${String(m + 1).padStart(2, "0")}`;
|
|
1907
|
+
}
|
|
1908
|
+
/** Every month from the one holding `from` to the one holding `to`, inclusive. Empty when `to` is earlier. */
|
|
1909
|
+
function monthsBetween(from, to) {
|
|
1910
|
+
const last = monthKey(to);
|
|
1911
|
+
const out = [];
|
|
1912
|
+
for (let k = monthKey(from); k <= last && out.length < 240; k = nextMonth(k)) out.push(k);
|
|
1913
|
+
return out;
|
|
1914
|
+
}
|
|
1915
|
+
function daysIn(key) {
|
|
1916
|
+
return getDaysInMonth(day$3(`${key}-01`));
|
|
1917
|
+
}
|
|
1918
|
+
/** How many of the schedule's days fall in `key`. */
|
|
1919
|
+
function overlapDays(key, start, end) {
|
|
1920
|
+
const first = `${key}-01`;
|
|
1921
|
+
const lastDay = `${key}-${String(daysIn(key)).padStart(2, "0")}`;
|
|
1922
|
+
const from = start > first ? start : first;
|
|
1923
|
+
const to = end < lastDay ? end : lastDay;
|
|
1924
|
+
return to < from ? 0 : differenceInCalendarDays(day$3(to), day$3(from)) + 1;
|
|
1925
|
+
}
|
|
1926
|
+
/**
|
|
1927
|
+
* The budget spread straight across the schedule: each month gets the share
|
|
1928
|
+
* of days it holds, in whole units, with the rounding left on the last month
|
|
1929
|
+
* so the months always add up to the budget.
|
|
1930
|
+
*/
|
|
1931
|
+
function evenPhasing(project) {
|
|
1932
|
+
const end = project.endDate < project.startDate ? project.startDate : project.endDate;
|
|
1933
|
+
const months = monthsBetween(project.startDate, end);
|
|
1934
|
+
const total = differenceInCalendarDays(day$3(end), day$3(project.startDate)) + 1;
|
|
1935
|
+
const out = {};
|
|
1936
|
+
let placed = 0;
|
|
1937
|
+
months.forEach((k, i) => {
|
|
1938
|
+
const amount = i === months.length - 1 ? project.budget - placed : Math.round(project.budget * overlapDays(k, project.startDate, end) / total);
|
|
1939
|
+
out[k] = amount;
|
|
1940
|
+
placed += amount;
|
|
1941
|
+
});
|
|
1942
|
+
return out;
|
|
1943
|
+
}
|
|
1944
|
+
/** True when the project carries a plan someone typed, rather than the even spread. */
|
|
1945
|
+
function hasOwnPhasing(project) {
|
|
1946
|
+
return Boolean(project.phasing && Object.keys(project.phasing).length > 0);
|
|
1947
|
+
}
|
|
1948
|
+
/** What the project plans to spend each month: its own phasing where set, else the even spread. */
|
|
1949
|
+
function plannedByMonth(project) {
|
|
1950
|
+
if (!hasOwnPhasing(project)) return evenPhasing(project);
|
|
1951
|
+
const out = {};
|
|
1952
|
+
for (const [k, v] of Object.entries(project.phasing)) if (/^\d{4}-\d{2}$/.test(k) && Number.isFinite(v) && v >= 0) out[k] = v;
|
|
1953
|
+
return out;
|
|
1954
|
+
}
|
|
1955
|
+
/** The plan with one month changed, starting from whatever the project plans now. Zero months are dropped. */
|
|
1956
|
+
function withMonth(project, key, amount) {
|
|
1957
|
+
const next = {
|
|
1958
|
+
...plannedByMonth(project),
|
|
1959
|
+
[key]: Math.max(0, Math.round(amount))
|
|
1960
|
+
};
|
|
1961
|
+
for (const k of Object.keys(next)) if (next[k] === 0) delete next[k];
|
|
1962
|
+
return next;
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1810
1965
|
//#endregion
|
|
1811
1966
|
//#region src/lib/spend.ts
|
|
1812
1967
|
function spendEntriesOf(data, projectId) {
|
|
@@ -1892,6 +2047,13 @@ function withBaseline(projects, reset = false) {
|
|
|
1892
2047
|
baselineEnd: p.endDate
|
|
1893
2048
|
});
|
|
1894
2049
|
}
|
|
2050
|
+
/** A caption for the timeline: "slipped 12 days", "12 days early", or null when on the baseline. */
|
|
2051
|
+
function slipLabel(p) {
|
|
2052
|
+
const slip = slipDays(p);
|
|
2053
|
+
if (slip == null || slip === 0) return null;
|
|
2054
|
+
const n = Math.abs(slip);
|
|
2055
|
+
return slip > 0 ? `slipped ${n} day${n === 1 ? "" : "s"}` : `${n} day${n === 1 ? "" : "s"} early`;
|
|
2056
|
+
}
|
|
1895
2057
|
|
|
1896
2058
|
//#endregion
|
|
1897
2059
|
//#region src/lib/roadmap.ts
|
|
@@ -2624,6 +2786,18 @@ function dueByDay(data) {
|
|
|
2624
2786
|
milestones
|
|
2625
2787
|
};
|
|
2626
2788
|
}
|
|
2789
|
+
/**
|
|
2790
|
+
* A Markdown letter (the status report) as plain lines for the terminal:
|
|
2791
|
+
* headings become plain lines after a blank one, bold marks and the top
|
|
2792
|
+
* title go (the screen's heading says it), and "- " lists become bullets.
|
|
2793
|
+
*/
|
|
2794
|
+
function markdownToPlain(md) {
|
|
2795
|
+
return md.split("\n").filter((line, i) => !(i === 0 && line.startsWith("# "))).map((line) => line.replace(/^#{1,6} /, "").replace(/^- /, "• ").replace(/\*\*([^*]+)\*\*/g, "$1")).join("\n").replace(/^\n+/, "").trimEnd();
|
|
2796
|
+
}
|
|
2797
|
+
/** A thing's health moves, newest first, as "Oct 12 At risk (set by hand: note)". */
|
|
2798
|
+
function healthMoves(history, today, max = 3) {
|
|
2799
|
+
return [...history ?? []].slice(-max).reverse().map((h) => `${shortDay(h.at.slice(0, 10), today).padEnd(7)} ${HEALTH_LABEL[h.health]}${h.override ? ` (set by hand${h.note ? `: ${h.note}` : ""})` : ""}`);
|
|
2800
|
+
}
|
|
2627
2801
|
|
|
2628
2802
|
//#endregion
|
|
2629
2803
|
//#region src/tui/theme.ts
|
|
@@ -2937,7 +3111,7 @@ function Editor({ label, edit, done }) {
|
|
|
2937
3111
|
* adds the screen's own (d to delete, space to tick); `below` shows under
|
|
2938
3112
|
* the fields.
|
|
2939
3113
|
*/
|
|
2940
|
-
function DetailPanel({ title, subtitle, fields, width, height, keys = {}, hints, below, active = true, onClose }) {
|
|
3114
|
+
function DetailPanel({ title, subtitle, fields, width, height, keys = {}, hints, below, belowRows = 0, active = true, onClose }) {
|
|
2941
3115
|
const [at, setAt] = useState(0);
|
|
2942
3116
|
const [editing, setEditing] = useState(null);
|
|
2943
3117
|
useInput((input, key) => {
|
|
@@ -2949,6 +3123,9 @@ function DetailPanel({ title, subtitle, fields, width, height, keys = {}, hints,
|
|
|
2949
3123
|
if (run) run();
|
|
2950
3124
|
}, { isActive: active && editing === null });
|
|
2951
3125
|
const field = editing != null ? fields[editing] : null;
|
|
3126
|
+
const editorRows = field?.edit ? field.edit.kind === "pick" ? Math.min(10, field.edit.choices.length) + 4 : 5 : 1 + belowRows;
|
|
3127
|
+
const room = Math.max(3, height - 2 - (subtitle ? 2 : 1) - 1 - editorRows);
|
|
3128
|
+
const view = windowOf(fields, at, room);
|
|
2952
3129
|
const pad = Math.min(22, Math.max(12, ...fields.map((f) => f.label.length + 1)));
|
|
2953
3130
|
return /* @__PURE__ */ jsxs(Box, {
|
|
2954
3131
|
flexDirection: "column",
|
|
@@ -2968,7 +3145,8 @@ function DetailPanel({ title, subtitle, fields, width, height, keys = {}, hints,
|
|
|
2968
3145
|
children: subtitle
|
|
2969
3146
|
}) : null,
|
|
2970
3147
|
/* @__PURE__ */ jsx(Text, { children: " " }),
|
|
2971
|
-
|
|
3148
|
+
view.items.map((f, n) => {
|
|
3149
|
+
const i = view.start + n;
|
|
2972
3150
|
const on = at === i;
|
|
2973
3151
|
return /* @__PURE__ */ jsxs(Text, {
|
|
2974
3152
|
wrap: "truncate-end",
|
|
@@ -2991,7 +3169,7 @@ function DetailPanel({ title, subtitle, fields, width, height, keys = {}, hints,
|
|
|
2991
3169
|
]
|
|
2992
3170
|
}, f.label);
|
|
2993
3171
|
}),
|
|
2994
|
-
below,
|
|
3172
|
+
field?.edit ? null : below,
|
|
2995
3173
|
/* @__PURE__ */ jsx(Box, { flexGrow: 1 }),
|
|
2996
3174
|
field?.edit ? /* @__PURE__ */ jsx(Editor, {
|
|
2997
3175
|
label: field.label,
|
|
@@ -3732,6 +3910,60 @@ function renderBriefText(brief, data, workspace = "Harbor") {
|
|
|
3732
3910
|
return composeBriefBodyFrom(brief, data, workspace);
|
|
3733
3911
|
}
|
|
3734
3912
|
|
|
3913
|
+
//#endregion
|
|
3914
|
+
//#region src/tui/views/letter.tsx
|
|
3915
|
+
/**
|
|
3916
|
+
* A letter Harbor wrote (the brief, a status report) to read in the
|
|
3917
|
+
* terminal: wrapped to the width, ↑↓ and space to read on, and its section
|
|
3918
|
+
* headings (a line after a blank one) picked out. `keys` adds the page's own.
|
|
3919
|
+
*/
|
|
3920
|
+
function Letter({ title, text, hints, width, height, active, keys = {}, onClose }) {
|
|
3921
|
+
const [top, setTop] = useState(0);
|
|
3922
|
+
const lines = useMemo(() => wrapLines(text, width - 2), [text, width]);
|
|
3923
|
+
const rows = Math.max(1, height - 2);
|
|
3924
|
+
const last = Math.max(0, lines.length - rows);
|
|
3925
|
+
const from = Math.min(top, last);
|
|
3926
|
+
const scroll = (by) => setTop((t) => Math.max(0, Math.min(last, t + by)));
|
|
3927
|
+
useInput((input, key) => {
|
|
3928
|
+
if (key.escape && onClose) onClose();
|
|
3929
|
+
else if (key.upArrow || input === "k") scroll(-1);
|
|
3930
|
+
else if (key.downArrow || input === "j") scroll(1);
|
|
3931
|
+
else if (key.pageDown || input === " ") scroll(rows - 1);
|
|
3932
|
+
else if (key.pageUp) scroll(-(rows - 1));
|
|
3933
|
+
else if (keys[input]) {
|
|
3934
|
+
setTop(0);
|
|
3935
|
+
keys[input]();
|
|
3936
|
+
}
|
|
3937
|
+
}, { isActive: active });
|
|
3938
|
+
const right = [
|
|
3939
|
+
lines.length > rows ? "↑↓ read on" : null,
|
|
3940
|
+
hints,
|
|
3941
|
+
onClose ? "Esc back" : null
|
|
3942
|
+
].filter(Boolean).join(" · ");
|
|
3943
|
+
return /* @__PURE__ */ jsxs(Box, {
|
|
3944
|
+
flexDirection: "column",
|
|
3945
|
+
width,
|
|
3946
|
+
height,
|
|
3947
|
+
paddingX: 1,
|
|
3948
|
+
children: [
|
|
3949
|
+
/* @__PURE__ */ jsx(Heading, {
|
|
3950
|
+
right,
|
|
3951
|
+
children: title
|
|
3952
|
+
}),
|
|
3953
|
+
/* @__PURE__ */ jsx(Text, { children: " " }),
|
|
3954
|
+
lines.slice(from, from + rows).map((line, i) => {
|
|
3955
|
+
const heading = line !== "" && !/^[ •]/.test(line) && lines[from + i - 1] === "";
|
|
3956
|
+
return /* @__PURE__ */ jsx(Text, {
|
|
3957
|
+
bold: heading,
|
|
3958
|
+
color: heading ? theme.accent : void 0,
|
|
3959
|
+
wrap: "truncate-end",
|
|
3960
|
+
children: line || " "
|
|
3961
|
+
}, i);
|
|
3962
|
+
})
|
|
3963
|
+
]
|
|
3964
|
+
});
|
|
3965
|
+
}
|
|
3966
|
+
|
|
3735
3967
|
//#endregion
|
|
3736
3968
|
//#region src/tui/views/brief.tsx
|
|
3737
3969
|
/**
|
|
@@ -3752,7 +3984,6 @@ function BriefView({ active, width, height, go }) {
|
|
|
3752
3984
|
const checkIns = useHarbor((s) => s.checkIns);
|
|
3753
3985
|
const constraints = useHarbor((s) => s.constraints);
|
|
3754
3986
|
const [portfolio, setPortfolio] = useState(null);
|
|
3755
|
-
const [top, setTop] = useState(0);
|
|
3756
3987
|
const day = today();
|
|
3757
3988
|
const brief = useMemo(() => {
|
|
3758
3989
|
const data = {
|
|
@@ -3788,48 +4019,20 @@ function BriefView({ active, width, height, go }) {
|
|
|
3788
4019
|
day,
|
|
3789
4020
|
harborName
|
|
3790
4021
|
]);
|
|
3791
|
-
|
|
3792
|
-
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
|
|
3799
|
-
else if (key.pageUp) scroll(-(rows - 1));
|
|
3800
|
-
else if (input === "p") go.pick("Brief for which portfolio?", [{
|
|
4022
|
+
return /* @__PURE__ */ jsx(Letter, {
|
|
4023
|
+
title: `Brief · ${brief.scope}`,
|
|
4024
|
+
text: brief.text.split("\n").slice(2).join("\n"),
|
|
4025
|
+
hints: "p portfolio",
|
|
4026
|
+
width,
|
|
4027
|
+
height,
|
|
4028
|
+
active,
|
|
4029
|
+
keys: { p: () => go.pick("Brief for which portfolio?", [{
|
|
3801
4030
|
label: "All portfolios",
|
|
3802
4031
|
value: null
|
|
3803
4032
|
}, ...portfolios.map((p) => ({
|
|
3804
4033
|
label: p.name,
|
|
3805
4034
|
value: p.id
|
|
3806
|
-
}))], (v) =>
|
|
3807
|
-
setPortfolio(v ?? null);
|
|
3808
|
-
setTop(0);
|
|
3809
|
-
});
|
|
3810
|
-
}, { isActive: active });
|
|
3811
|
-
const shown = lines.slice(Math.min(top, last), Math.min(top, last) + rows);
|
|
3812
|
-
return /* @__PURE__ */ jsxs(Box, {
|
|
3813
|
-
flexDirection: "column",
|
|
3814
|
-
width,
|
|
3815
|
-
height,
|
|
3816
|
-
paddingX: 1,
|
|
3817
|
-
children: [
|
|
3818
|
-
/* @__PURE__ */ jsxs(Heading, {
|
|
3819
|
-
right: `${lines.length > rows ? "↑↓ read on · " : ""}p portfolio`,
|
|
3820
|
-
children: ["Brief · ", brief.scope]
|
|
3821
|
-
}),
|
|
3822
|
-
/* @__PURE__ */ jsx(Text, { children: " " }),
|
|
3823
|
-
shown.map((line, i) => {
|
|
3824
|
-
const heading = line !== "" && !/^[ •]/.test(line) && lines[Math.min(top, last) + i - 1] === "";
|
|
3825
|
-
return /* @__PURE__ */ jsx(Text, {
|
|
3826
|
-
bold: heading,
|
|
3827
|
-
color: heading ? theme.accent : void 0,
|
|
3828
|
-
wrap: "truncate-end",
|
|
3829
|
-
children: line || " "
|
|
3830
|
-
}, i);
|
|
3831
|
-
})
|
|
3832
|
-
]
|
|
4035
|
+
}))], (v) => setPortfolio(v ?? null)) }
|
|
3833
4036
|
});
|
|
3834
4037
|
}
|
|
3835
4038
|
|
|
@@ -5693,6 +5896,119 @@ function Palette({ width, base, open, onClose }) {
|
|
|
5693
5896
|
});
|
|
5694
5897
|
}
|
|
5695
5898
|
|
|
5899
|
+
//#endregion
|
|
5900
|
+
//#region src/lib/deps.ts
|
|
5901
|
+
/** True when making `id` wait on `candidate` would close a loop (or is `id` itself). */
|
|
5902
|
+
function wouldCycle(id, candidate, items) {
|
|
5903
|
+
const byId = new Map(items.map((n) => [n.id, n]));
|
|
5904
|
+
const stack = [candidate];
|
|
5905
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5906
|
+
while (stack.length) {
|
|
5907
|
+
const cursor = stack.pop();
|
|
5908
|
+
if (cursor === id) return true;
|
|
5909
|
+
if (seen.has(cursor)) continue;
|
|
5910
|
+
seen.add(cursor);
|
|
5911
|
+
const node = byId.get(cursor);
|
|
5912
|
+
if (node) stack.push(...blockerIds(node));
|
|
5913
|
+
}
|
|
5914
|
+
return false;
|
|
5915
|
+
}
|
|
5916
|
+
/**
|
|
5917
|
+
* Everything that waits on `id`, directly or through others: the items that
|
|
5918
|
+
* would close a loop if `id` waited on them. One walk backwards from `id`,
|
|
5919
|
+
* instead of a walk forwards from every candidate.
|
|
5920
|
+
*/
|
|
5921
|
+
function waitingOnChain(items, id) {
|
|
5922
|
+
const waiters = /* @__PURE__ */ new Map();
|
|
5923
|
+
for (const n of items) for (const b of blockerIds(n)) {
|
|
5924
|
+
const list = waiters.get(b);
|
|
5925
|
+
if (list) list.push(n.id);
|
|
5926
|
+
else waiters.set(b, [n.id]);
|
|
5927
|
+
}
|
|
5928
|
+
const out = /* @__PURE__ */ new Set();
|
|
5929
|
+
const stack = [...waiters.get(id) ?? []];
|
|
5930
|
+
while (stack.length) {
|
|
5931
|
+
const cursor = stack.pop();
|
|
5932
|
+
if (out.has(cursor)) continue;
|
|
5933
|
+
out.add(cursor);
|
|
5934
|
+
stack.push(...waiters.get(cursor) ?? []);
|
|
5935
|
+
}
|
|
5936
|
+
return out;
|
|
5937
|
+
}
|
|
5938
|
+
/** The things `id` may wait on: everything except itself and anything already waiting on it, directly or through others. */
|
|
5939
|
+
function blockerOptions(items, id) {
|
|
5940
|
+
const loops = waitingOnChain(items, id);
|
|
5941
|
+
return items.filter((n) => n.id !== id && !loops.has(n.id));
|
|
5942
|
+
}
|
|
5943
|
+
|
|
5944
|
+
//#endregion
|
|
5945
|
+
//#region src/lib/status-report.ts
|
|
5946
|
+
/**
|
|
5947
|
+
* A program's status report as Markdown: what a lead pastes into a Friday
|
|
5948
|
+
* update or an exec thread. Everything in it is already on the program page;
|
|
5949
|
+
* this is the one-page version.
|
|
5950
|
+
*/
|
|
5951
|
+
function composeStatusReport(data, program, now = /* @__PURE__ */ new Date()) {
|
|
5952
|
+
const projects = projectsInProgram(data, program.id);
|
|
5953
|
+
const tasks = tasksInProgramOf(data, program.id);
|
|
5954
|
+
const done = tasks.filter((t) => t.status === "done").length;
|
|
5955
|
+
const late = lateTasksOf(tasks, now);
|
|
5956
|
+
const milestones = data.milestones.filter((m) => m.programId === program.id);
|
|
5957
|
+
const lateMilestones = lateMilestonesOf(milestones, now);
|
|
5958
|
+
const upcoming = milestones.filter((m) => !m.done && !lateMilestones.includes(m)).sort((a, b) => a.dueDate.localeCompare(b.dueDate)).slice(0, 3);
|
|
5959
|
+
const spent = programSpent(data, program.id);
|
|
5960
|
+
const allocated = programBudgetAllocated(data, program.id);
|
|
5961
|
+
const forecast = forecastRollup(projects, program.budget, now);
|
|
5962
|
+
const owner = data.people.find((p) => p.id === program.ownerId)?.name ?? "Unassigned";
|
|
5963
|
+
const live = sortRaid(programRaidItems(data, program.id).filter((r) => r.kind !== "decision" && isActive(r)));
|
|
5964
|
+
const lines = [];
|
|
5965
|
+
lines.push(`# ${program.name} — status ${shortDate(todayISO(now))}`);
|
|
5966
|
+
lines.push("");
|
|
5967
|
+
lines.push(`**Health:** ${HEALTH_LABEL$1[program.health]}${program.healthNote ? ` — ${program.healthNote}` : ""}`);
|
|
5968
|
+
lines.push(`**Owner:** ${owner} · **Dates:** ${shortDate(program.startDate)} – ${shortDate(program.endDate)}`);
|
|
5969
|
+
lines.push(`**Progress:** ${done} of ${tasks.length} tasks done${late.length ? `, ${late.length} late` : ""}`);
|
|
5970
|
+
if (program.nextMove) lines.push(`**Next move:** ${program.nextMove}`);
|
|
5971
|
+
lines.push("");
|
|
5972
|
+
lines.push("## Budget");
|
|
5973
|
+
if (program.budget > 0 || spent > 0) {
|
|
5974
|
+
lines.push(`- ${money(spent)} spent of ${money(program.budget)}${allocated > program.budget ? ` (projects allocate ${money(allocated)})` : ""}`);
|
|
5975
|
+
if (forecast) {
|
|
5976
|
+
const verdict = forecast.tone === "over" ? `over by ${money(-forecast.variance)}` : forecast.tone === "tight" ? "within 5% of budget" : `under by ${money(forecast.variance)}`;
|
|
5977
|
+
lines.push(`- Forecast at completion ${money(forecast.eac)}, ${verdict}`);
|
|
5978
|
+
}
|
|
5979
|
+
} else lines.push("- No budget set");
|
|
5980
|
+
lines.push("");
|
|
5981
|
+
lines.push("## Milestones");
|
|
5982
|
+
if (milestones.length === 0) lines.push("- None yet");
|
|
5983
|
+
for (const m of lateMilestones) lines.push(`- **Late:** ${m.name}, due ${shortDate(m.dueDate)}`);
|
|
5984
|
+
for (const m of upcoming) {
|
|
5985
|
+
const risk = milestoneRisks(m, data)[0];
|
|
5986
|
+
const note = !risk ? "" : risk.reason === "ends-after" ? ` — **at risk:** waits on ${risk.project.name}, which ends ${shortDate(risk.project.endDate)}` : ` — **at risk:** waits on ${risk.project.name}, which is off track`;
|
|
5987
|
+
lines.push(`- ${m.name}, due ${shortDate(m.dueDate)}${note}`);
|
|
5988
|
+
}
|
|
5989
|
+
const doneCount = milestones.filter((m) => m.done).length;
|
|
5990
|
+
if (doneCount) lines.push(`- ${doneCount} of ${milestones.length} reached`);
|
|
5991
|
+
lines.push("");
|
|
5992
|
+
lines.push("## Projects");
|
|
5993
|
+
if (projects.length === 0) lines.push("- None yet");
|
|
5994
|
+
for (const p of projects) {
|
|
5995
|
+
const own = tasks.filter((t) => t.projectId === p.id);
|
|
5996
|
+
const ownDone = own.filter((t) => t.status === "done").length;
|
|
5997
|
+
lines.push(`- ${p.name}: ${HEALTH_LABEL$1[p.health]}, ${ownDone}/${own.length} done, ${shortDate(p.startDate)} – ${shortDate(p.endDate)}`);
|
|
5998
|
+
}
|
|
5999
|
+
lines.push("");
|
|
6000
|
+
lines.push("## Risks and issues");
|
|
6001
|
+
if (live.length === 0) lines.push("- Nothing open");
|
|
6002
|
+
for (const r of live.slice(0, 8)) {
|
|
6003
|
+
const score = scoreOf(r);
|
|
6004
|
+
const band = score != null ? ` (${scoreBand(score)}, ${score})` : "";
|
|
6005
|
+
const who = data.people.find((p) => p.id === r.ownerId)?.name;
|
|
6006
|
+
lines.push(`- ${r.kind === "risk" ? "Risk" : "Issue"}: ${r.title}${band}${who ? ` — ${who}` : ""}`);
|
|
6007
|
+
}
|
|
6008
|
+
lines.push("");
|
|
6009
|
+
return lines.join("\n");
|
|
6010
|
+
}
|
|
6011
|
+
|
|
5696
6012
|
//#endregion
|
|
5697
6013
|
//#region src/tui/views/panels.tsx
|
|
5698
6014
|
/**
|
|
@@ -5739,13 +6055,75 @@ function healthField(kind, item, go, set, clear, finished = false) {
|
|
|
5739
6055
|
}
|
|
5740
6056
|
};
|
|
5741
6057
|
}
|
|
6058
|
+
/**
|
|
6059
|
+
* What a thing waits on, picked from `options`: Enter on one adds it or
|
|
6060
|
+
* takes it off, and one that already waits on this (a loop) is refused.
|
|
6061
|
+
*/
|
|
6062
|
+
function waitsField(label, self, options, go, save, loops = true) {
|
|
6063
|
+
const on = blockerIds(self);
|
|
6064
|
+
const names = options.filter((o) => on.includes(o.id)).map((o) => o.name);
|
|
6065
|
+
const choices = options.filter((o) => o.id !== self.id).map((o) => ({
|
|
6066
|
+
label: `${on.includes(o.id) ? "✓" : " "} ${o.name}`,
|
|
6067
|
+
value: o.id,
|
|
6068
|
+
hint: on.includes(o.id) ? "Enter stops waiting" : void 0
|
|
6069
|
+
}));
|
|
6070
|
+
return {
|
|
6071
|
+
label,
|
|
6072
|
+
value: names.length ? names.join(", ") : "Nothing",
|
|
6073
|
+
edit: choices.length ? {
|
|
6074
|
+
kind: "pick",
|
|
6075
|
+
title: `${label}: Enter adds or takes off one`,
|
|
6076
|
+
choices,
|
|
6077
|
+
onPick: (v) => {
|
|
6078
|
+
const picked = v;
|
|
6079
|
+
if (on.includes(picked)) return save(blockersPatch(on.filter((x) => x !== picked)));
|
|
6080
|
+
if (loops && wouldCycle(self.id, picked, options)) return go.notify("That one already waits on this, so it would go round in a loop.");
|
|
6081
|
+
save(blockersPatch([...on, picked]));
|
|
6082
|
+
}
|
|
6083
|
+
} : void 0
|
|
6084
|
+
};
|
|
6085
|
+
}
|
|
6086
|
+
/** The lines HealthMoves takes. */
|
|
6087
|
+
const movesRows = (history, today) => {
|
|
6088
|
+
const n = healthMoves(history, today).length;
|
|
6089
|
+
return n ? n + 2 : 0;
|
|
6090
|
+
};
|
|
6091
|
+
/** The last few moves of a thing's health, under its fields. */
|
|
6092
|
+
function HealthMoves({ history, today }) {
|
|
6093
|
+
const moves = healthMoves(history, today);
|
|
6094
|
+
if (!moves.length) return null;
|
|
6095
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
6096
|
+
/* @__PURE__ */ jsx(Text, { children: " " }),
|
|
6097
|
+
/* @__PURE__ */ jsxs(Text, {
|
|
6098
|
+
color: theme.muted,
|
|
6099
|
+
children: [" ", "Health history"]
|
|
6100
|
+
}),
|
|
6101
|
+
moves.map((m, i) => /* @__PURE__ */ jsxs(Text, {
|
|
6102
|
+
wrap: "truncate-end",
|
|
6103
|
+
children: [" ", m]
|
|
6104
|
+
}, i))
|
|
6105
|
+
] });
|
|
6106
|
+
}
|
|
6107
|
+
const CONSTRAINT_KINDS = Object.keys(CONSTRAINT_KIND_LABEL);
|
|
5742
6108
|
function ProgramPanel({ id, width, height, go, active, onClose }) {
|
|
5743
6109
|
const { store, today } = useDeps();
|
|
5744
6110
|
const program = useHarbor((s) => s.programs.find((p) => p.id === id));
|
|
5745
6111
|
const people = useHarbor((s) => s.people);
|
|
5746
6112
|
const portfolios = useHarbor((s) => s.portfolios);
|
|
6113
|
+
const programs = useHarbor((s) => s.programs);
|
|
6114
|
+
const projects = useHarbor((s) => s.projects);
|
|
6115
|
+
const constraint = useHarbor((s) => s.constraints.find((c) => c.programId === id && c.status === "active"));
|
|
5747
6116
|
if (!program) return null;
|
|
5748
6117
|
const s = store.getState();
|
|
6118
|
+
const inProgram = projects.filter((p) => p.programId === id);
|
|
6119
|
+
const baselined = inProgram.filter(hasBaseline);
|
|
6120
|
+
const holding = (patch) => store.getState().setConstraint(id, {
|
|
6121
|
+
title: constraint?.title ?? "",
|
|
6122
|
+
kind: constraint?.kind ?? "resource",
|
|
6123
|
+
ownerId: constraint?.ownerId ?? null,
|
|
6124
|
+
note: constraint?.note ?? "",
|
|
6125
|
+
...patch
|
|
6126
|
+
});
|
|
5749
6127
|
const fields = [
|
|
5750
6128
|
{
|
|
5751
6129
|
label: "Name",
|
|
@@ -5808,6 +6186,79 @@ function ProgramPanel({ id, width, height, go, active, onClose }) {
|
|
|
5808
6186
|
onPick: (v) => store.getState().updateProgram(id, { portfolioId: v })
|
|
5809
6187
|
}
|
|
5810
6188
|
},
|
|
6189
|
+
{
|
|
6190
|
+
label: "Budget",
|
|
6191
|
+
value: money(program.budget),
|
|
6192
|
+
edit: {
|
|
6193
|
+
kind: "number",
|
|
6194
|
+
initial: program.budget,
|
|
6195
|
+
min: 0,
|
|
6196
|
+
onSave: (v) => store.getState().updateProgram(id, { budget: v ?? 0 })
|
|
6197
|
+
}
|
|
6198
|
+
},
|
|
6199
|
+
{
|
|
6200
|
+
label: "Impact",
|
|
6201
|
+
value: `${program.impact ?? 5} of 10${program.impact == null ? " (not set)" : ""}`,
|
|
6202
|
+
edit: {
|
|
6203
|
+
kind: "number",
|
|
6204
|
+
initial: program.impact ?? 5,
|
|
6205
|
+
min: 1,
|
|
6206
|
+
max: 10,
|
|
6207
|
+
onSave: (v) => v != null && store.getState().updateProgram(id, { impact: Math.round(v) })
|
|
6208
|
+
}
|
|
6209
|
+
},
|
|
6210
|
+
{
|
|
6211
|
+
label: "Holding it back",
|
|
6212
|
+
value: constraint ? `${constraint.title} (${CONSTRAINT_KIND_LABEL[constraint.kind].toLowerCase()})` : "Nothing named",
|
|
6213
|
+
edit: {
|
|
6214
|
+
kind: "text",
|
|
6215
|
+
initial: constraint?.title ?? "",
|
|
6216
|
+
placeholder: "The one thing that sets the pace; empty when it's cleared",
|
|
6217
|
+
onSave: (v) => v.trim() ? holding({ title: v.trim() }) : constraint && store.getState().resolveConstraint(constraint.id)
|
|
6218
|
+
}
|
|
6219
|
+
},
|
|
6220
|
+
...constraint ? [{
|
|
6221
|
+
label: "What kind",
|
|
6222
|
+
value: CONSTRAINT_KIND_LABEL[constraint.kind],
|
|
6223
|
+
edit: {
|
|
6224
|
+
kind: "pick",
|
|
6225
|
+
choices: CONSTRAINT_KINDS.map((k) => ({
|
|
6226
|
+
label: CONSTRAINT_KIND_LABEL[k],
|
|
6227
|
+
value: k,
|
|
6228
|
+
hint: CONSTRAINT_KIND_HINT[k]
|
|
6229
|
+
})),
|
|
6230
|
+
initial: CONSTRAINT_KINDS.indexOf(constraint.kind),
|
|
6231
|
+
onPick: (v) => holding({ kind: v })
|
|
6232
|
+
}
|
|
6233
|
+
}, {
|
|
6234
|
+
label: "Who clears it",
|
|
6235
|
+
value: people.find((p) => p.id === constraint.ownerId)?.name ?? "Nobody yet",
|
|
6236
|
+
edit: {
|
|
6237
|
+
kind: "pick",
|
|
6238
|
+
choices: peopleChoices(people, true),
|
|
6239
|
+
initial: Math.max(0, people.findIndex((p) => p.id === constraint.ownerId) + 1),
|
|
6240
|
+
onPick: (v) => holding({ ownerId: v })
|
|
6241
|
+
}
|
|
6242
|
+
}] : [],
|
|
6243
|
+
waitsField("Waits on", program, programs, go, (patch) => store.getState().updateProgram(id, patch)),
|
|
6244
|
+
{
|
|
6245
|
+
label: "Baseline",
|
|
6246
|
+
value: !inProgram.length ? "No projects yet" : baselined.length === inProgram.length ? "Set on every project" : baselined.length ? `Set on ${baselined.length} of ${inProgram.length} projects` : "Not set",
|
|
6247
|
+
edit: inProgram.length ? {
|
|
6248
|
+
kind: "pick",
|
|
6249
|
+
title: "The plan to measure slips against",
|
|
6250
|
+
choices: [...baselined.length < inProgram.length ? [{
|
|
6251
|
+
label: "Set it where it's missing",
|
|
6252
|
+
value: "set",
|
|
6253
|
+
hint: "today's dates become the plan"
|
|
6254
|
+
}] : [], ...baselined.length ? [{
|
|
6255
|
+
label: "Reset every project to today's dates",
|
|
6256
|
+
value: "reset",
|
|
6257
|
+
hint: "slips start from zero"
|
|
6258
|
+
}] : []],
|
|
6259
|
+
onPick: (v) => store.getState().snapshotBaseline(id, v === "reset")
|
|
6260
|
+
} : void 0
|
|
6261
|
+
},
|
|
5811
6262
|
{
|
|
5812
6263
|
label: "About",
|
|
5813
6264
|
value: program.description.replace(/\s+/g, " "),
|
|
@@ -5827,23 +6278,33 @@ function ProgramPanel({ id, width, height, go, active, onClose }) {
|
|
|
5827
6278
|
fields,
|
|
5828
6279
|
width,
|
|
5829
6280
|
height,
|
|
5830
|
-
hints: "d delete",
|
|
5831
|
-
|
|
5832
|
-
|
|
5833
|
-
|
|
5834
|
-
|
|
5835
|
-
|
|
5836
|
-
|
|
5837
|
-
|
|
6281
|
+
hints: "r status report · d delete",
|
|
6282
|
+
below: /* @__PURE__ */ jsx(HealthMoves, {
|
|
6283
|
+
history: program.healthHistory,
|
|
6284
|
+
today: today()
|
|
6285
|
+
}),
|
|
6286
|
+
belowRows: movesRows(program.healthHistory, today()),
|
|
6287
|
+
keys: {
|
|
6288
|
+
r: () => go.read(`Status report · ${program.name}`, markdownToPlain(composeStatusReport(store.getState(), program))),
|
|
6289
|
+
d: () => {
|
|
6290
|
+
const what = deleteSummary(store.getState(), "program", id);
|
|
6291
|
+
go.confirm(`Delete the program “${program.name}”${what ? ` and ${what}` : ""}? u undoes it.`, () => {
|
|
6292
|
+
store.getState().deleteEntity("program", id);
|
|
6293
|
+
onClose();
|
|
6294
|
+
}, "Delete");
|
|
6295
|
+
}
|
|
6296
|
+
},
|
|
5838
6297
|
active,
|
|
5839
6298
|
onClose
|
|
5840
6299
|
});
|
|
5841
6300
|
}
|
|
5842
6301
|
function PortfolioPanel({ id, width, height, go, active, onClose }) {
|
|
5843
|
-
const { store } = useDeps();
|
|
6302
|
+
const { store, today } = useDeps();
|
|
6303
|
+
const stored = useHarbor((s) => s.priorityRules);
|
|
5844
6304
|
const portfolio = useHarbor((s) => s.portfolios.find((p) => p.id === id));
|
|
5845
6305
|
const people = useHarbor((s) => s.people);
|
|
5846
6306
|
if (!portfolio) return null;
|
|
6307
|
+
const rules = normalizeRules(stored);
|
|
5847
6308
|
const fields = [
|
|
5848
6309
|
{
|
|
5849
6310
|
label: "Name",
|
|
@@ -5884,6 +6345,34 @@ function PortfolioPanel({ id, width, height, go, active, onClose }) {
|
|
|
5884
6345
|
min: 0,
|
|
5885
6346
|
onSave: (v) => store.getState().updatePortfolio(id, { budget: v ?? 0 })
|
|
5886
6347
|
}
|
|
6348
|
+
},
|
|
6349
|
+
{
|
|
6350
|
+
label: "High impact from",
|
|
6351
|
+
value: `${rules.impactThreshold} of 10${stored ? "" : " (the usual)"}`,
|
|
6352
|
+
edit: {
|
|
6353
|
+
kind: "number",
|
|
6354
|
+
initial: rules.impactThreshold,
|
|
6355
|
+
min: 1,
|
|
6356
|
+
max: 10,
|
|
6357
|
+
onSave: (v) => store.getState().setPriorityRules({
|
|
6358
|
+
...rules,
|
|
6359
|
+
impactThreshold: v ?? DEFAULT_RULES.impactThreshold
|
|
6360
|
+
})
|
|
6361
|
+
}
|
|
6362
|
+
},
|
|
6363
|
+
{
|
|
6364
|
+
label: "High budget from",
|
|
6365
|
+
value: `${Math.round(rules.budgetShare * 100)}% of the largest program's${stored ? "" : " (the usual)"}`,
|
|
6366
|
+
edit: {
|
|
6367
|
+
kind: "number",
|
|
6368
|
+
initial: Math.round(rules.budgetShare * 100),
|
|
6369
|
+
min: 5,
|
|
6370
|
+
max: 100,
|
|
6371
|
+
onSave: (v) => store.getState().setPriorityRules({
|
|
6372
|
+
...rules,
|
|
6373
|
+
budgetShare: (v ?? DEFAULT_RULES.budgetShare * 100) / 100
|
|
6374
|
+
})
|
|
6375
|
+
}
|
|
5887
6376
|
}
|
|
5888
6377
|
];
|
|
5889
6378
|
return /* @__PURE__ */ jsx(DetailPanel, {
|
|
@@ -5896,6 +6385,11 @@ function PortfolioPanel({ id, width, height, go, active, onClose }) {
|
|
|
5896
6385
|
width,
|
|
5897
6386
|
height,
|
|
5898
6387
|
hints: "d delete",
|
|
6388
|
+
below: /* @__PURE__ */ jsx(HealthMoves, {
|
|
6389
|
+
history: portfolio.healthHistory,
|
|
6390
|
+
today: today()
|
|
6391
|
+
}),
|
|
6392
|
+
belowRows: movesRows(portfolio.healthHistory, today()),
|
|
5899
6393
|
keys: { d: () => {
|
|
5900
6394
|
const what = deleteSummary(store.getState(), "portfolio", id);
|
|
5901
6395
|
go.confirm(`Delete the portfolio “${portfolio.name}”${what ? ` and ${what}` : ""}? u undoes it.`, () => {
|
|
@@ -5913,8 +6407,14 @@ function ProjectPanel({ id, width, height, go, active, onClose }) {
|
|
|
5913
6407
|
const tasks = useHarbor((s) => s.tasks);
|
|
5914
6408
|
const people = useHarbor((s) => s.people);
|
|
5915
6409
|
const programs = useHarbor((s) => s.programs);
|
|
6410
|
+
const projects = useHarbor((s) => s.projects);
|
|
5916
6411
|
if (!project) return null;
|
|
5917
6412
|
const up = (patch) => store.getState().updateProject(id, patch);
|
|
6413
|
+
const planned = plannedByMonth(project);
|
|
6414
|
+
const months = monthsBetween(project.startDate, project.endDate);
|
|
6415
|
+
const monthName = (key) => format(parseIso(`${key}-01`), "MMM yyyy");
|
|
6416
|
+
const waits = blockerIds(project);
|
|
6417
|
+
const slip = slipLabel(project);
|
|
5918
6418
|
const fields = [
|
|
5919
6419
|
{
|
|
5920
6420
|
label: "Name",
|
|
@@ -5987,6 +6487,45 @@ function ProjectPanel({ id, width, height, go, active, onClose }) {
|
|
|
5987
6487
|
onPick: (v) => store.getState().moveProjects([id], v)
|
|
5988
6488
|
}
|
|
5989
6489
|
},
|
|
6490
|
+
{
|
|
6491
|
+
label: "Plan by month",
|
|
6492
|
+
value: hasOwnPhasing(project) ? "Set by hand" : "Spread evenly",
|
|
6493
|
+
edit: months.length ? {
|
|
6494
|
+
kind: "pick",
|
|
6495
|
+
title: "Pick a month to change what it plans to spend",
|
|
6496
|
+
choices: [...months.map((k) => ({
|
|
6497
|
+
label: `${monthName(k)} ${money(planned[k] ?? 0)}`,
|
|
6498
|
+
value: k
|
|
6499
|
+
})), ...hasOwnPhasing(project) ? [{
|
|
6500
|
+
label: "Back to an even spread",
|
|
6501
|
+
value: null
|
|
6502
|
+
}] : []],
|
|
6503
|
+
onPick: (v) => v == null ? up({ phasing: null }) : go.ask(`Planned for ${monthName(v)}`, (text) => {
|
|
6504
|
+
const n = Number(text.replace(/[$,\s]/g, ""));
|
|
6505
|
+
if (!Number.isFinite(n) || n < 0) return go.notify(`“${text}” isn't an amount.`);
|
|
6506
|
+
up({ phasing: withMonth(project, v, n) });
|
|
6507
|
+
}, String(planned[v] ?? 0))
|
|
6508
|
+
} : void 0
|
|
6509
|
+
},
|
|
6510
|
+
waitsField("Waits on", project, projects, go, (patch) => up({
|
|
6511
|
+
...patch,
|
|
6512
|
+
lagDays: patch.blockedByIds.length ? project.lagDays : null
|
|
6513
|
+
})),
|
|
6514
|
+
...waits.length ? [{
|
|
6515
|
+
label: "Lag days",
|
|
6516
|
+
value: project.lagDays ? `${project.lagDays} after they finish` : "None",
|
|
6517
|
+
edit: {
|
|
6518
|
+
kind: "number",
|
|
6519
|
+
initial: project.lagDays ?? null,
|
|
6520
|
+
min: 0,
|
|
6521
|
+
onSave: (v) => up({ lagDays: v ? Math.round(v) : null })
|
|
6522
|
+
}
|
|
6523
|
+
}] : [],
|
|
6524
|
+
{
|
|
6525
|
+
label: "Baseline",
|
|
6526
|
+
value: hasBaseline(project) ? `${shortDay(project.baselineStart, today())} to ${shortDay(project.baselineEnd, today())}${slip ? `, ${slip}` : ""}` : "Not set (set it on the program)",
|
|
6527
|
+
color: slip?.startsWith("slipped") ? theme.bad : void 0
|
|
6528
|
+
},
|
|
5990
6529
|
{
|
|
5991
6530
|
label: "Key",
|
|
5992
6531
|
value: project.key ?? ""
|
|
@@ -6011,6 +6550,11 @@ function ProjectPanel({ id, width, height, go, active, onClose }) {
|
|
|
6011
6550
|
width,
|
|
6012
6551
|
height,
|
|
6013
6552
|
hints: "d delete",
|
|
6553
|
+
below: /* @__PURE__ */ jsx(HealthMoves, {
|
|
6554
|
+
history: project.healthHistory,
|
|
6555
|
+
today: today()
|
|
6556
|
+
}),
|
|
6557
|
+
belowRows: movesRows(project.healthHistory, today()),
|
|
6014
6558
|
keys: { d: () => {
|
|
6015
6559
|
const what = deleteSummary(store.getState(), "project", id);
|
|
6016
6560
|
go.confirm(`Delete the project “${project.name}”${what ? ` and ${what}` : ""}? u undoes it.`, () => {
|
|
@@ -6022,13 +6566,25 @@ function ProjectPanel({ id, width, height, go, active, onClose }) {
|
|
|
6022
6566
|
onClose
|
|
6023
6567
|
});
|
|
6024
6568
|
}
|
|
6569
|
+
const SIGN_OFF = {
|
|
6570
|
+
pending: "Waiting for sign-off",
|
|
6571
|
+
approved: "Approved",
|
|
6572
|
+
changes_requested: "Changes requested"
|
|
6573
|
+
};
|
|
6025
6574
|
function MilestonePanel({ id, width, height, go, active, onClose }) {
|
|
6026
6575
|
const { store, today } = useDeps();
|
|
6027
6576
|
const milestone = useHarbor((s) => s.milestones.find((m) => m.id === id));
|
|
6028
6577
|
const people = useHarbor((s) => s.people);
|
|
6578
|
+
const projects = useHarbor((s) => s.projects);
|
|
6579
|
+
const me = useHarbor((s) => s.currentUserId);
|
|
6029
6580
|
if (!milestone) return null;
|
|
6030
6581
|
const up = (patch) => store.getState().updateMilestone(id, patch);
|
|
6031
6582
|
const late = !milestone.done && milestone.dueDate < today();
|
|
6583
|
+
const gated = Boolean(milestone.approverId);
|
|
6584
|
+
const approver = people.find((p) => p.id === milestone.approverId);
|
|
6585
|
+
const status = milestone.approvalStatus ?? "pending";
|
|
6586
|
+
const inProgram = projects.filter((p) => p.programId === milestone.programId);
|
|
6587
|
+
const risks = milestoneRisks(milestone, store.getState());
|
|
6032
6588
|
const fields = [
|
|
6033
6589
|
{
|
|
6034
6590
|
label: "Name",
|
|
@@ -6051,8 +6607,8 @@ function MilestonePanel({ id, width, height, go, active, onClose }) {
|
|
|
6051
6607
|
},
|
|
6052
6608
|
{
|
|
6053
6609
|
label: "Done",
|
|
6054
|
-
value: milestone.done ? "Yes" : "Not yet",
|
|
6055
|
-
edit: {
|
|
6610
|
+
value: milestone.done ? "Yes" : gated ? "Not yet (the sign-off ticks it)" : "Not yet",
|
|
6611
|
+
edit: gated ? void 0 : {
|
|
6056
6612
|
kind: "pick",
|
|
6057
6613
|
choices: [{
|
|
6058
6614
|
label: "Not yet",
|
|
@@ -6067,14 +6623,49 @@ function MilestonePanel({ id, width, height, go, active, onClose }) {
|
|
|
6067
6623
|
},
|
|
6068
6624
|
{
|
|
6069
6625
|
label: "Signs off",
|
|
6070
|
-
value:
|
|
6626
|
+
value: approver?.name ?? "Nobody (no sign-off)",
|
|
6071
6627
|
edit: {
|
|
6072
6628
|
kind: "pick",
|
|
6629
|
+
title: "Who signs it off? Nobody else can tick it done.",
|
|
6073
6630
|
choices: peopleChoices(people, true),
|
|
6074
6631
|
initial: Math.max(0, people.findIndex((p) => p.id === milestone.approverId) + 1),
|
|
6075
|
-
onPick: (v) =>
|
|
6632
|
+
onPick: (v) => v ? v !== milestone.approverId && up({
|
|
6633
|
+
approverId: v,
|
|
6634
|
+
approvalStatus: "pending",
|
|
6635
|
+
done: false
|
|
6636
|
+
}) : up({
|
|
6637
|
+
approverId: null,
|
|
6638
|
+
approvalStatus: null,
|
|
6639
|
+
approvalNote: null
|
|
6640
|
+
})
|
|
6076
6641
|
}
|
|
6077
|
-
}
|
|
6642
|
+
},
|
|
6643
|
+
...gated ? [{
|
|
6644
|
+
label: "Sign-off",
|
|
6645
|
+
value: `${SIGN_OFF[status]}${status === "changes_requested" && milestone.approvalNote ? `: ${milestone.approvalNote}` : ""}`,
|
|
6646
|
+
color: status === "approved" ? theme.good : status === "pending" ? void 0 : theme.bad,
|
|
6647
|
+
edit: milestone.approverId === me && status !== "approved" ? {
|
|
6648
|
+
kind: "pick",
|
|
6649
|
+
title: "Your sign-off",
|
|
6650
|
+
choices: [{
|
|
6651
|
+
label: "Approve",
|
|
6652
|
+
value: "approve",
|
|
6653
|
+
hint: "ticks it done"
|
|
6654
|
+
}, {
|
|
6655
|
+
label: "Request changes",
|
|
6656
|
+
value: "changes"
|
|
6657
|
+
}],
|
|
6658
|
+
onPick: (v) => v === "approve" ? up({
|
|
6659
|
+
approvalStatus: "approved",
|
|
6660
|
+
done: true
|
|
6661
|
+
}) : go.ask("What needs changing?", (note) => up({
|
|
6662
|
+
approvalStatus: "changes_requested",
|
|
6663
|
+
approvalNote: note.trim() || null,
|
|
6664
|
+
done: false
|
|
6665
|
+
}))
|
|
6666
|
+
} : void 0
|
|
6667
|
+
}] : [],
|
|
6668
|
+
waitsField("Waits on", milestone, inProgram, go, (patch) => up(patch), false)
|
|
6078
6669
|
];
|
|
6079
6670
|
return /* @__PURE__ */ jsx(DetailPanel, {
|
|
6080
6671
|
title: /* @__PURE__ */ jsx(Text, {
|
|
@@ -6085,14 +6676,25 @@ function MilestonePanel({ id, width, height, go, active, onClose }) {
|
|
|
6085
6676
|
fields,
|
|
6086
6677
|
width,
|
|
6087
6678
|
height,
|
|
6088
|
-
hints: "space done · d delete",
|
|
6679
|
+
hints: gated ? "d delete" : "space done · d delete",
|
|
6089
6680
|
keys: {
|
|
6090
|
-
" ": () => store.getState().toggleMilestone(id),
|
|
6681
|
+
" ": () => gated ? go.notify(`${approver?.name ?? "The approver"} ticks this off by signing it off.`) : store.getState().toggleMilestone(id),
|
|
6091
6682
|
d: () => go.confirm(`Delete the milestone “${milestone.name}”? u undoes it.`, () => {
|
|
6092
6683
|
store.getState().deleteMilestone(id);
|
|
6093
6684
|
onClose();
|
|
6094
6685
|
}, "Delete")
|
|
6095
6686
|
},
|
|
6687
|
+
belowRows: risks.length ? risks.length + 1 : 0,
|
|
6688
|
+
below: risks.length ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Text, { children: " " }), risks.map((r) => /* @__PURE__ */ jsxs(Text, {
|
|
6689
|
+
color: theme.bad,
|
|
6690
|
+
wrap: "truncate-end",
|
|
6691
|
+
children: [
|
|
6692
|
+
" ",
|
|
6693
|
+
"At risk:",
|
|
6694
|
+
" ",
|
|
6695
|
+
r.reason === "ends-after" ? `${r.project.name} ends ${shortDay(r.project.endDate, today())}` : `${r.project.name} is off track`
|
|
6696
|
+
]
|
|
6697
|
+
}, r.project.id))] }) : null,
|
|
6096
6698
|
active,
|
|
6097
6699
|
onClose
|
|
6098
6700
|
});
|
|
@@ -7709,36 +8311,6 @@ function RoadmapView({ active, width, height, go }) {
|
|
|
7709
8311
|
});
|
|
7710
8312
|
}
|
|
7711
8313
|
|
|
7712
|
-
//#endregion
|
|
7713
|
-
//#region src/lib/deps.ts
|
|
7714
|
-
/**
|
|
7715
|
-
* Everything that waits on `id`, directly or through others: the items that
|
|
7716
|
-
* would close a loop if `id` waited on them. One walk backwards from `id`,
|
|
7717
|
-
* instead of a walk forwards from every candidate.
|
|
7718
|
-
*/
|
|
7719
|
-
function waitingOnChain(items, id) {
|
|
7720
|
-
const waiters = /* @__PURE__ */ new Map();
|
|
7721
|
-
for (const n of items) for (const b of blockerIds(n)) {
|
|
7722
|
-
const list = waiters.get(b);
|
|
7723
|
-
if (list) list.push(n.id);
|
|
7724
|
-
else waiters.set(b, [n.id]);
|
|
7725
|
-
}
|
|
7726
|
-
const out = /* @__PURE__ */ new Set();
|
|
7727
|
-
const stack = [...waiters.get(id) ?? []];
|
|
7728
|
-
while (stack.length) {
|
|
7729
|
-
const cursor = stack.pop();
|
|
7730
|
-
if (out.has(cursor)) continue;
|
|
7731
|
-
out.add(cursor);
|
|
7732
|
-
stack.push(...waiters.get(cursor) ?? []);
|
|
7733
|
-
}
|
|
7734
|
-
return out;
|
|
7735
|
-
}
|
|
7736
|
-
/** The things `id` may wait on: everything except itself and anything already waiting on it, directly or through others. */
|
|
7737
|
-
function blockerOptions(items, id) {
|
|
7738
|
-
const loops = waitingOnChain(items, id);
|
|
7739
|
-
return items.filter((n) => n.id !== id && !loops.has(n.id));
|
|
7740
|
-
}
|
|
7741
|
-
|
|
7742
8314
|
//#endregion
|
|
7743
8315
|
//#region src/lib/recurrence.ts
|
|
7744
8316
|
const REPEAT_LABEL = {
|
|
@@ -8589,6 +9161,11 @@ function App({ ui }) {
|
|
|
8589
9161
|
choices,
|
|
8590
9162
|
then
|
|
8591
9163
|
}),
|
|
9164
|
+
read: (title, text) => setOverlay({
|
|
9165
|
+
kind: "read",
|
|
9166
|
+
title,
|
|
9167
|
+
text
|
|
9168
|
+
}),
|
|
8592
9169
|
notify
|
|
8593
9170
|
};
|
|
8594
9171
|
const goScreen = (next) => {
|
|
@@ -8632,6 +9209,11 @@ function App({ ui }) {
|
|
|
8632
9209
|
hint: "make",
|
|
8633
9210
|
run: () => go.create("program")
|
|
8634
9211
|
},
|
|
9212
|
+
{
|
|
9213
|
+
label: "New portfolio",
|
|
9214
|
+
hint: "make",
|
|
9215
|
+
run: () => go.create("portfolio")
|
|
9216
|
+
},
|
|
8635
9217
|
{
|
|
8636
9218
|
label: "Log a risk, issue or decision",
|
|
8637
9219
|
hint: "make",
|
|
@@ -8754,7 +9336,10 @@ function App({ ui }) {
|
|
|
8754
9336
|
close();
|
|
8755
9337
|
const id = commitCreate(store, what, values, ctx, today());
|
|
8756
9338
|
if (!id) return notify("There's nowhere to put that yet.");
|
|
8757
|
-
if (what === "
|
|
9339
|
+
if (what === "portfolio") {
|
|
9340
|
+
notify(`Made ${String(values.name)}.`);
|
|
9341
|
+
go.openPortfolio(id);
|
|
9342
|
+
} else if (what === "project") {
|
|
8758
9343
|
notify(`Made ${String(values.name)}.`);
|
|
8759
9344
|
go.openProject(id);
|
|
8760
9345
|
} else if (what === "program") {
|
|
@@ -8804,7 +9389,15 @@ function App({ ui }) {
|
|
|
8804
9389
|
}
|
|
8805
9390
|
})
|
|
8806
9391
|
});
|
|
8807
|
-
} else if (overlay?.kind === "
|
|
9392
|
+
} else if (overlay?.kind === "read") overlayNode = /* @__PURE__ */ jsx(Letter, {
|
|
9393
|
+
title: overlay.title,
|
|
9394
|
+
text: overlay.text,
|
|
9395
|
+
width: mainWidth,
|
|
9396
|
+
height: bodyHeight,
|
|
9397
|
+
active: true,
|
|
9398
|
+
onClose: close
|
|
9399
|
+
});
|
|
9400
|
+
else if (overlay?.kind === "pick") {
|
|
8808
9401
|
const { title, choices, then } = overlay;
|
|
8809
9402
|
overlayNode = /* @__PURE__ */ jsx(Box, {
|
|
8810
9403
|
width: mainWidth,
|
package/lib/cli.mjs
CHANGED
|
@@ -110,6 +110,7 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
110
110
|
harbor brief [--portfolio P] [--say] the weekly brief, ready to send;
|
|
111
111
|
--say reads it aloud as a
|
|
112
112
|
shipping forecast
|
|
113
|
+
harbor report PROGRAM a program's status report
|
|
113
114
|
harbor premortem PROJECT ["WHAT WENT WRONG"] [--start]
|
|
114
115
|
a project's pre-mortem: start,
|
|
115
116
|
answer or read it
|
|
@@ -1266,6 +1267,12 @@ export async function run(argv, io = {}) {
|
|
|
1266
1267
|
(r) => raidList(r, positionals.join(" ")),
|
|
1267
1268
|
);
|
|
1268
1269
|
}
|
|
1270
|
+
case "report": {
|
|
1271
|
+
const { positionals, harbor } = parse(spec([]));
|
|
1272
|
+
const program = positionals.join(" ").trim();
|
|
1273
|
+
if (!program) throw new HarborError('Give a program: harbor report "NAME".');
|
|
1274
|
+
return call("status_report", { harbor, program }, (r) => r.markdown);
|
|
1275
|
+
}
|
|
1269
1276
|
case "premortem": {
|
|
1270
1277
|
const { positionals, values, harbor } = parse(spec(["start"]));
|
|
1271
1278
|
const [project, ...words] = positionals;
|