@harborppm/cli 0.31.0 → 0.33.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 +6 -1
- package/dist/app.mjs +897 -92
- package/lib/cli.mjs +77 -0
- package/lib/format.mjs +27 -0
- package/package.json +1 -1
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
|
/**
|
|
@@ -762,6 +795,9 @@ function money(n) {
|
|
|
762
795
|
function shortDate(value) {
|
|
763
796
|
return format(parseDate$1(value), "MMM d");
|
|
764
797
|
}
|
|
798
|
+
function longDate(value) {
|
|
799
|
+
return format(parseDate$1(value), "EEE, MMM d");
|
|
800
|
+
}
|
|
765
801
|
function isOverdue(value, done = false) {
|
|
766
802
|
if (!value || done) return false;
|
|
767
803
|
try {
|
|
@@ -963,6 +999,18 @@ const PRIORITY_LABEL = {
|
|
|
963
999
|
high: "High",
|
|
964
1000
|
urgent: "Urgent"
|
|
965
1001
|
};
|
|
1002
|
+
const CONSTRAINT_KIND_LABEL = {
|
|
1003
|
+
resource: "A person",
|
|
1004
|
+
policy: "A policy",
|
|
1005
|
+
external: "Outside the building",
|
|
1006
|
+
collision: "A collision"
|
|
1007
|
+
};
|
|
1008
|
+
const CONSTRAINT_KIND_HINT = {
|
|
1009
|
+
resource: "Someone whose calendar sets the date.",
|
|
1010
|
+
policy: "A rule that creates the wait.",
|
|
1011
|
+
external: "A vendor, a city, a review board.",
|
|
1012
|
+
collision: "Two things sharing one window."
|
|
1013
|
+
};
|
|
966
1014
|
|
|
967
1015
|
//#endregion
|
|
968
1016
|
//#region src/tui/create.ts
|
|
@@ -987,6 +1035,7 @@ function nextSwatch(used) {
|
|
|
987
1035
|
return SWATCHES.find((s) => !used.includes(s)) ?? SWATCHES[0];
|
|
988
1036
|
}
|
|
989
1037
|
const CREATE_TITLE = {
|
|
1038
|
+
portfolio: "New portfolio",
|
|
990
1039
|
project: "New project",
|
|
991
1040
|
program: "New program",
|
|
992
1041
|
milestone: "New milestone",
|
|
@@ -1001,6 +1050,28 @@ function stepsFor(kind, data, ctx, me) {
|
|
|
1001
1050
|
}));
|
|
1002
1051
|
const meFirst = Math.max(0, people.findIndex((p) => p.value === me));
|
|
1003
1052
|
switch (kind) {
|
|
1053
|
+
case "portfolio": return [
|
|
1054
|
+
{
|
|
1055
|
+
key: "name",
|
|
1056
|
+
label: "Name",
|
|
1057
|
+
kind: "text",
|
|
1058
|
+
placeholder: "e.g. Outdoor gear"
|
|
1059
|
+
},
|
|
1060
|
+
{
|
|
1061
|
+
key: "ownerId",
|
|
1062
|
+
label: "Owner",
|
|
1063
|
+
kind: "pick",
|
|
1064
|
+
choices: people,
|
|
1065
|
+
initial: meFirst
|
|
1066
|
+
},
|
|
1067
|
+
{
|
|
1068
|
+
key: "theme",
|
|
1069
|
+
label: "Theme",
|
|
1070
|
+
kind: "text",
|
|
1071
|
+
placeholder: "What it's for, in a line",
|
|
1072
|
+
optional: true
|
|
1073
|
+
}
|
|
1074
|
+
];
|
|
1004
1075
|
case "project": return [
|
|
1005
1076
|
{
|
|
1006
1077
|
key: "name",
|
|
@@ -1158,6 +1229,14 @@ function commitCreate(store, kind, v, ctx, today) {
|
|
|
1158
1229
|
const s = store.getState();
|
|
1159
1230
|
const str = (k) => typeof v[k] === "string" ? v[k] : "";
|
|
1160
1231
|
switch (kind) {
|
|
1232
|
+
case "portfolio": return s.addPortfolio({
|
|
1233
|
+
name: str("name"),
|
|
1234
|
+
description: "",
|
|
1235
|
+
swatch: nextSwatch(s.portfolios.map((p) => p.swatch)),
|
|
1236
|
+
ownerId: str("ownerId"),
|
|
1237
|
+
budget: 0,
|
|
1238
|
+
...str("theme") ? { theme: str("theme") } : {}
|
|
1239
|
+
});
|
|
1161
1240
|
case "project": {
|
|
1162
1241
|
const programId = ctx.programId ?? str("programId");
|
|
1163
1242
|
const program = s.programs.find((p) => p.id === programId);
|
|
@@ -1569,7 +1648,7 @@ function cascadeSummary(counts, kind) {
|
|
|
1569
1648
|
|
|
1570
1649
|
//#endregion
|
|
1571
1650
|
//#region src/lib/forecast.ts
|
|
1572
|
-
const day$
|
|
1651
|
+
const day$4 = (iso) => /* @__PURE__ */ new Date(`${iso}T12:00:00`);
|
|
1573
1652
|
/**
|
|
1574
1653
|
* A straight-line estimate at completion from the burn so far: spend divided
|
|
1575
1654
|
* by the share of the schedule elapsed. Null before anything is spent or
|
|
@@ -1578,8 +1657,8 @@ const day$3 = (iso) => /* @__PURE__ */ new Date(`${iso}T12:00:00`);
|
|
|
1578
1657
|
*/
|
|
1579
1658
|
function forecastAtCompletion(item, now) {
|
|
1580
1659
|
if (item.spent <= 0) return null;
|
|
1581
|
-
const total = differenceInCalendarDays(day$
|
|
1582
|
-
const gone = differenceInCalendarDays(now, day$
|
|
1660
|
+
const total = differenceInCalendarDays(day$4(item.endDate), day$4(item.startDate)) + 1;
|
|
1661
|
+
const gone = differenceInCalendarDays(now, day$4(item.startDate)) + 1;
|
|
1583
1662
|
if (total <= 0 || gone <= 0) return null;
|
|
1584
1663
|
const elapsed = Math.min(1, gone / total);
|
|
1585
1664
|
const eac = Math.round(item.spent / elapsed);
|
|
@@ -1590,6 +1669,18 @@ function forecastAtCompletion(item, now) {
|
|
|
1590
1669
|
tone: item.budget <= 0 ? "over" : eac > item.budget * 1.05 ? "over" : eac > item.budget * .95 ? "tight" : "under"
|
|
1591
1670
|
};
|
|
1592
1671
|
}
|
|
1672
|
+
/** Sum of the children's estimates, using spend where a child has no forecast yet. */
|
|
1673
|
+
function forecastRollup(items, budget, now) {
|
|
1674
|
+
const eac = items.map((i) => forecastAtCompletion(i, now)?.eac ?? i.spent).reduce((a, b) => a + b, 0);
|
|
1675
|
+
if (eac <= 0) return null;
|
|
1676
|
+
const elapsed = Math.max(0, ...items.map((i) => forecastAtCompletion(i, now)?.elapsed ?? 0));
|
|
1677
|
+
return {
|
|
1678
|
+
eac,
|
|
1679
|
+
variance: budget - eac,
|
|
1680
|
+
elapsed,
|
|
1681
|
+
tone: budget <= 0 ? "over" : eac > budget * 1.05 ? "over" : eac > budget * .95 ? "tight" : "under"
|
|
1682
|
+
};
|
|
1683
|
+
}
|
|
1593
1684
|
|
|
1594
1685
|
//#endregion
|
|
1595
1686
|
//#region src/lib/tide.ts
|
|
@@ -1807,6 +1898,73 @@ function constraintIdle(data, constraint) {
|
|
|
1807
1898
|
return data.tasks.some((t) => t.assigneeId === constraint.ownerId && t.status === "doing" && !here.has(t.id));
|
|
1808
1899
|
}
|
|
1809
1900
|
|
|
1901
|
+
//#endregion
|
|
1902
|
+
//#region src/lib/phasing.ts
|
|
1903
|
+
const day$3 = (iso) => /* @__PURE__ */ new Date(`${iso}T12:00:00`);
|
|
1904
|
+
function monthKey(iso) {
|
|
1905
|
+
return iso.slice(0, 7);
|
|
1906
|
+
}
|
|
1907
|
+
function nextMonth(key) {
|
|
1908
|
+
const [y, m] = key.split("-").map(Number);
|
|
1909
|
+
return m === 12 ? `${y + 1}-01` : `${y}-${String(m + 1).padStart(2, "0")}`;
|
|
1910
|
+
}
|
|
1911
|
+
/** Every month from the one holding `from` to the one holding `to`, inclusive. Empty when `to` is earlier. */
|
|
1912
|
+
function monthsBetween(from, to) {
|
|
1913
|
+
const last = monthKey(to);
|
|
1914
|
+
const out = [];
|
|
1915
|
+
for (let k = monthKey(from); k <= last && out.length < 240; k = nextMonth(k)) out.push(k);
|
|
1916
|
+
return out;
|
|
1917
|
+
}
|
|
1918
|
+
function daysIn(key) {
|
|
1919
|
+
return getDaysInMonth(day$3(`${key}-01`));
|
|
1920
|
+
}
|
|
1921
|
+
/** How many of the schedule's days fall in `key`. */
|
|
1922
|
+
function overlapDays(key, start, end) {
|
|
1923
|
+
const first = `${key}-01`;
|
|
1924
|
+
const lastDay = `${key}-${String(daysIn(key)).padStart(2, "0")}`;
|
|
1925
|
+
const from = start > first ? start : first;
|
|
1926
|
+
const to = end < lastDay ? end : lastDay;
|
|
1927
|
+
return to < from ? 0 : differenceInCalendarDays(day$3(to), day$3(from)) + 1;
|
|
1928
|
+
}
|
|
1929
|
+
/**
|
|
1930
|
+
* The budget spread straight across the schedule: each month gets the share
|
|
1931
|
+
* of days it holds, in whole units, with the rounding left on the last month
|
|
1932
|
+
* so the months always add up to the budget.
|
|
1933
|
+
*/
|
|
1934
|
+
function evenPhasing(project) {
|
|
1935
|
+
const end = project.endDate < project.startDate ? project.startDate : project.endDate;
|
|
1936
|
+
const months = monthsBetween(project.startDate, end);
|
|
1937
|
+
const total = differenceInCalendarDays(day$3(end), day$3(project.startDate)) + 1;
|
|
1938
|
+
const out = {};
|
|
1939
|
+
let placed = 0;
|
|
1940
|
+
months.forEach((k, i) => {
|
|
1941
|
+
const amount = i === months.length - 1 ? project.budget - placed : Math.round(project.budget * overlapDays(k, project.startDate, end) / total);
|
|
1942
|
+
out[k] = amount;
|
|
1943
|
+
placed += amount;
|
|
1944
|
+
});
|
|
1945
|
+
return out;
|
|
1946
|
+
}
|
|
1947
|
+
/** True when the project carries a plan someone typed, rather than the even spread. */
|
|
1948
|
+
function hasOwnPhasing(project) {
|
|
1949
|
+
return Boolean(project.phasing && Object.keys(project.phasing).length > 0);
|
|
1950
|
+
}
|
|
1951
|
+
/** What the project plans to spend each month: its own phasing where set, else the even spread. */
|
|
1952
|
+
function plannedByMonth(project) {
|
|
1953
|
+
if (!hasOwnPhasing(project)) return evenPhasing(project);
|
|
1954
|
+
const out = {};
|
|
1955
|
+
for (const [k, v] of Object.entries(project.phasing)) if (/^\d{4}-\d{2}$/.test(k) && Number.isFinite(v) && v >= 0) out[k] = v;
|
|
1956
|
+
return out;
|
|
1957
|
+
}
|
|
1958
|
+
/** The plan with one month changed, starting from whatever the project plans now. Zero months are dropped. */
|
|
1959
|
+
function withMonth(project, key, amount) {
|
|
1960
|
+
const next = {
|
|
1961
|
+
...plannedByMonth(project),
|
|
1962
|
+
[key]: Math.max(0, Math.round(amount))
|
|
1963
|
+
};
|
|
1964
|
+
for (const k of Object.keys(next)) if (next[k] === 0) delete next[k];
|
|
1965
|
+
return next;
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1810
1968
|
//#endregion
|
|
1811
1969
|
//#region src/lib/spend.ts
|
|
1812
1970
|
function spendEntriesOf(data, projectId) {
|
|
@@ -1892,6 +2050,13 @@ function withBaseline(projects, reset = false) {
|
|
|
1892
2050
|
baselineEnd: p.endDate
|
|
1893
2051
|
});
|
|
1894
2052
|
}
|
|
2053
|
+
/** A caption for the timeline: "slipped 12 days", "12 days early", or null when on the baseline. */
|
|
2054
|
+
function slipLabel(p) {
|
|
2055
|
+
const slip = slipDays(p);
|
|
2056
|
+
if (slip == null || slip === 0) return null;
|
|
2057
|
+
const n = Math.abs(slip);
|
|
2058
|
+
return slip > 0 ? `slipped ${n} day${n === 1 ? "" : "s"}` : `${n} day${n === 1 ? "" : "s"} early`;
|
|
2059
|
+
}
|
|
1895
2060
|
|
|
1896
2061
|
//#endregion
|
|
1897
2062
|
//#region src/lib/roadmap.ts
|
|
@@ -2624,6 +2789,18 @@ function dueByDay(data) {
|
|
|
2624
2789
|
milestones
|
|
2625
2790
|
};
|
|
2626
2791
|
}
|
|
2792
|
+
/**
|
|
2793
|
+
* A Markdown letter (the status report) as plain lines for the terminal:
|
|
2794
|
+
* headings become plain lines after a blank one, bold marks and the top
|
|
2795
|
+
* title go (the screen's heading says it), and "- " lists become bullets.
|
|
2796
|
+
*/
|
|
2797
|
+
function markdownToPlain(md) {
|
|
2798
|
+
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();
|
|
2799
|
+
}
|
|
2800
|
+
/** A thing's health moves, newest first, as "Oct 12 At risk (set by hand: note)". */
|
|
2801
|
+
function healthMoves(history, today, max = 3) {
|
|
2802
|
+
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}` : ""})` : ""}`);
|
|
2803
|
+
}
|
|
2627
2804
|
|
|
2628
2805
|
//#endregion
|
|
2629
2806
|
//#region src/tui/theme.ts
|
|
@@ -2937,7 +3114,7 @@ function Editor({ label, edit, done }) {
|
|
|
2937
3114
|
* adds the screen's own (d to delete, space to tick); `below` shows under
|
|
2938
3115
|
* the fields.
|
|
2939
3116
|
*/
|
|
2940
|
-
function DetailPanel({ title, subtitle, fields, width, height, keys = {}, hints, below, active = true, onClose }) {
|
|
3117
|
+
function DetailPanel({ title, subtitle, fields, width, height, keys = {}, hints, below, belowRows = 0, active = true, onClose }) {
|
|
2941
3118
|
const [at, setAt] = useState(0);
|
|
2942
3119
|
const [editing, setEditing] = useState(null);
|
|
2943
3120
|
useInput((input, key) => {
|
|
@@ -2949,6 +3126,9 @@ function DetailPanel({ title, subtitle, fields, width, height, keys = {}, hints,
|
|
|
2949
3126
|
if (run) run();
|
|
2950
3127
|
}, { isActive: active && editing === null });
|
|
2951
3128
|
const field = editing != null ? fields[editing] : null;
|
|
3129
|
+
const editorRows = field?.edit ? field.edit.kind === "pick" ? Math.min(10, field.edit.choices.length) + 4 : 5 : 1 + belowRows;
|
|
3130
|
+
const room = Math.max(3, height - 2 - (subtitle ? 2 : 1) - 1 - editorRows);
|
|
3131
|
+
const view = windowOf(fields, at, room);
|
|
2952
3132
|
const pad = Math.min(22, Math.max(12, ...fields.map((f) => f.label.length + 1)));
|
|
2953
3133
|
return /* @__PURE__ */ jsxs(Box, {
|
|
2954
3134
|
flexDirection: "column",
|
|
@@ -2968,7 +3148,8 @@ function DetailPanel({ title, subtitle, fields, width, height, keys = {}, hints,
|
|
|
2968
3148
|
children: subtitle
|
|
2969
3149
|
}) : null,
|
|
2970
3150
|
/* @__PURE__ */ jsx(Text, { children: " " }),
|
|
2971
|
-
|
|
3151
|
+
view.items.map((f, n) => {
|
|
3152
|
+
const i = view.start + n;
|
|
2972
3153
|
const on = at === i;
|
|
2973
3154
|
return /* @__PURE__ */ jsxs(Text, {
|
|
2974
3155
|
wrap: "truncate-end",
|
|
@@ -2991,7 +3172,7 @@ function DetailPanel({ title, subtitle, fields, width, height, keys = {}, hints,
|
|
|
2991
3172
|
]
|
|
2992
3173
|
}, f.label);
|
|
2993
3174
|
}),
|
|
2994
|
-
below,
|
|
3175
|
+
field?.edit ? null : below,
|
|
2995
3176
|
/* @__PURE__ */ jsx(Box, { flexGrow: 1 }),
|
|
2996
3177
|
field?.edit ? /* @__PURE__ */ jsx(Editor, {
|
|
2997
3178
|
label: field.label,
|
|
@@ -3732,6 +3913,60 @@ function renderBriefText(brief, data, workspace = "Harbor") {
|
|
|
3732
3913
|
return composeBriefBodyFrom(brief, data, workspace);
|
|
3733
3914
|
}
|
|
3734
3915
|
|
|
3916
|
+
//#endregion
|
|
3917
|
+
//#region src/tui/views/letter.tsx
|
|
3918
|
+
/**
|
|
3919
|
+
* A letter Harbor wrote (the brief, a status report) to read in the
|
|
3920
|
+
* terminal: wrapped to the width, ↑↓ and space to read on, and its section
|
|
3921
|
+
* headings (a line after a blank one) picked out. `keys` adds the page's own.
|
|
3922
|
+
*/
|
|
3923
|
+
function Letter({ title, text, hints, width, height, active, keys = {}, onClose }) {
|
|
3924
|
+
const [top, setTop] = useState(0);
|
|
3925
|
+
const lines = useMemo(() => wrapLines(text, width - 2), [text, width]);
|
|
3926
|
+
const rows = Math.max(1, height - 2);
|
|
3927
|
+
const last = Math.max(0, lines.length - rows);
|
|
3928
|
+
const from = Math.min(top, last);
|
|
3929
|
+
const scroll = (by) => setTop((t) => Math.max(0, Math.min(last, t + by)));
|
|
3930
|
+
useInput((input, key) => {
|
|
3931
|
+
if (key.escape && onClose) onClose();
|
|
3932
|
+
else if (key.upArrow || input === "k") scroll(-1);
|
|
3933
|
+
else if (key.downArrow || input === "j") scroll(1);
|
|
3934
|
+
else if (key.pageDown || input === " ") scroll(rows - 1);
|
|
3935
|
+
else if (key.pageUp) scroll(-(rows - 1));
|
|
3936
|
+
else if (keys[input]) {
|
|
3937
|
+
setTop(0);
|
|
3938
|
+
keys[input]();
|
|
3939
|
+
}
|
|
3940
|
+
}, { isActive: active });
|
|
3941
|
+
const right = [
|
|
3942
|
+
lines.length > rows ? "↑↓ read on" : null,
|
|
3943
|
+
hints,
|
|
3944
|
+
onClose ? "Esc back" : null
|
|
3945
|
+
].filter(Boolean).join(" · ");
|
|
3946
|
+
return /* @__PURE__ */ jsxs(Box, {
|
|
3947
|
+
flexDirection: "column",
|
|
3948
|
+
width,
|
|
3949
|
+
height,
|
|
3950
|
+
paddingX: 1,
|
|
3951
|
+
children: [
|
|
3952
|
+
/* @__PURE__ */ jsx(Heading, {
|
|
3953
|
+
right,
|
|
3954
|
+
children: title
|
|
3955
|
+
}),
|
|
3956
|
+
/* @__PURE__ */ jsx(Text, { children: " " }),
|
|
3957
|
+
lines.slice(from, from + rows).map((line, i) => {
|
|
3958
|
+
const heading = line !== "" && !/^[ •]/.test(line) && lines[from + i - 1] === "";
|
|
3959
|
+
return /* @__PURE__ */ jsx(Text, {
|
|
3960
|
+
bold: heading,
|
|
3961
|
+
color: heading ? theme.accent : void 0,
|
|
3962
|
+
wrap: "truncate-end",
|
|
3963
|
+
children: line || " "
|
|
3964
|
+
}, i);
|
|
3965
|
+
})
|
|
3966
|
+
]
|
|
3967
|
+
});
|
|
3968
|
+
}
|
|
3969
|
+
|
|
3735
3970
|
//#endregion
|
|
3736
3971
|
//#region src/tui/views/brief.tsx
|
|
3737
3972
|
/**
|
|
@@ -3752,7 +3987,6 @@ function BriefView({ active, width, height, go }) {
|
|
|
3752
3987
|
const checkIns = useHarbor((s) => s.checkIns);
|
|
3753
3988
|
const constraints = useHarbor((s) => s.constraints);
|
|
3754
3989
|
const [portfolio, setPortfolio] = useState(null);
|
|
3755
|
-
const [top, setTop] = useState(0);
|
|
3756
3990
|
const day = today();
|
|
3757
3991
|
const brief = useMemo(() => {
|
|
3758
3992
|
const data = {
|
|
@@ -3788,48 +4022,20 @@ function BriefView({ active, width, height, go }) {
|
|
|
3788
4022
|
day,
|
|
3789
4023
|
harborName
|
|
3790
4024
|
]);
|
|
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?", [{
|
|
4025
|
+
return /* @__PURE__ */ jsx(Letter, {
|
|
4026
|
+
title: `Brief · ${brief.scope}`,
|
|
4027
|
+
text: brief.text.split("\n").slice(2).join("\n"),
|
|
4028
|
+
hints: "p portfolio",
|
|
4029
|
+
width,
|
|
4030
|
+
height,
|
|
4031
|
+
active,
|
|
4032
|
+
keys: { p: () => go.pick("Brief for which portfolio?", [{
|
|
3801
4033
|
label: "All portfolios",
|
|
3802
4034
|
value: null
|
|
3803
4035
|
}, ...portfolios.map((p) => ({
|
|
3804
4036
|
label: p.name,
|
|
3805
4037
|
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
|
-
]
|
|
4038
|
+
}))], (v) => setPortfolio(v ?? null)) }
|
|
3833
4039
|
});
|
|
3834
4040
|
}
|
|
3835
4041
|
|
|
@@ -5693,6 +5899,119 @@ function Palette({ width, base, open, onClose }) {
|
|
|
5693
5899
|
});
|
|
5694
5900
|
}
|
|
5695
5901
|
|
|
5902
|
+
//#endregion
|
|
5903
|
+
//#region src/lib/deps.ts
|
|
5904
|
+
/** True when making `id` wait on `candidate` would close a loop (or is `id` itself). */
|
|
5905
|
+
function wouldCycle(id, candidate, items) {
|
|
5906
|
+
const byId = new Map(items.map((n) => [n.id, n]));
|
|
5907
|
+
const stack = [candidate];
|
|
5908
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5909
|
+
while (stack.length) {
|
|
5910
|
+
const cursor = stack.pop();
|
|
5911
|
+
if (cursor === id) return true;
|
|
5912
|
+
if (seen.has(cursor)) continue;
|
|
5913
|
+
seen.add(cursor);
|
|
5914
|
+
const node = byId.get(cursor);
|
|
5915
|
+
if (node) stack.push(...blockerIds(node));
|
|
5916
|
+
}
|
|
5917
|
+
return false;
|
|
5918
|
+
}
|
|
5919
|
+
/**
|
|
5920
|
+
* Everything that waits on `id`, directly or through others: the items that
|
|
5921
|
+
* would close a loop if `id` waited on them. One walk backwards from `id`,
|
|
5922
|
+
* instead of a walk forwards from every candidate.
|
|
5923
|
+
*/
|
|
5924
|
+
function waitingOnChain(items, id) {
|
|
5925
|
+
const waiters = /* @__PURE__ */ new Map();
|
|
5926
|
+
for (const n of items) for (const b of blockerIds(n)) {
|
|
5927
|
+
const list = waiters.get(b);
|
|
5928
|
+
if (list) list.push(n.id);
|
|
5929
|
+
else waiters.set(b, [n.id]);
|
|
5930
|
+
}
|
|
5931
|
+
const out = /* @__PURE__ */ new Set();
|
|
5932
|
+
const stack = [...waiters.get(id) ?? []];
|
|
5933
|
+
while (stack.length) {
|
|
5934
|
+
const cursor = stack.pop();
|
|
5935
|
+
if (out.has(cursor)) continue;
|
|
5936
|
+
out.add(cursor);
|
|
5937
|
+
stack.push(...waiters.get(cursor) ?? []);
|
|
5938
|
+
}
|
|
5939
|
+
return out;
|
|
5940
|
+
}
|
|
5941
|
+
/** The things `id` may wait on: everything except itself and anything already waiting on it, directly or through others. */
|
|
5942
|
+
function blockerOptions(items, id) {
|
|
5943
|
+
const loops = waitingOnChain(items, id);
|
|
5944
|
+
return items.filter((n) => n.id !== id && !loops.has(n.id));
|
|
5945
|
+
}
|
|
5946
|
+
|
|
5947
|
+
//#endregion
|
|
5948
|
+
//#region src/lib/status-report.ts
|
|
5949
|
+
/**
|
|
5950
|
+
* A program's status report as Markdown: what a lead pastes into a Friday
|
|
5951
|
+
* update or an exec thread. Everything in it is already on the program page;
|
|
5952
|
+
* this is the one-page version.
|
|
5953
|
+
*/
|
|
5954
|
+
function composeStatusReport(data, program, now = /* @__PURE__ */ new Date()) {
|
|
5955
|
+
const projects = projectsInProgram(data, program.id);
|
|
5956
|
+
const tasks = tasksInProgramOf(data, program.id);
|
|
5957
|
+
const done = tasks.filter((t) => t.status === "done").length;
|
|
5958
|
+
const late = lateTasksOf(tasks, now);
|
|
5959
|
+
const milestones = data.milestones.filter((m) => m.programId === program.id);
|
|
5960
|
+
const lateMilestones = lateMilestonesOf(milestones, now);
|
|
5961
|
+
const upcoming = milestones.filter((m) => !m.done && !lateMilestones.includes(m)).sort((a, b) => a.dueDate.localeCompare(b.dueDate)).slice(0, 3);
|
|
5962
|
+
const spent = programSpent(data, program.id);
|
|
5963
|
+
const allocated = programBudgetAllocated(data, program.id);
|
|
5964
|
+
const forecast = forecastRollup(projects, program.budget, now);
|
|
5965
|
+
const owner = data.people.find((p) => p.id === program.ownerId)?.name ?? "Unassigned";
|
|
5966
|
+
const live = sortRaid(programRaidItems(data, program.id).filter((r) => r.kind !== "decision" && isActive(r)));
|
|
5967
|
+
const lines = [];
|
|
5968
|
+
lines.push(`# ${program.name} — status ${shortDate(todayISO(now))}`);
|
|
5969
|
+
lines.push("");
|
|
5970
|
+
lines.push(`**Health:** ${HEALTH_LABEL$1[program.health]}${program.healthNote ? ` — ${program.healthNote}` : ""}`);
|
|
5971
|
+
lines.push(`**Owner:** ${owner} · **Dates:** ${shortDate(program.startDate)} – ${shortDate(program.endDate)}`);
|
|
5972
|
+
lines.push(`**Progress:** ${done} of ${tasks.length} tasks done${late.length ? `, ${late.length} late` : ""}`);
|
|
5973
|
+
if (program.nextMove) lines.push(`**Next move:** ${program.nextMove}`);
|
|
5974
|
+
lines.push("");
|
|
5975
|
+
lines.push("## Budget");
|
|
5976
|
+
if (program.budget > 0 || spent > 0) {
|
|
5977
|
+
lines.push(`- ${money(spent)} spent of ${money(program.budget)}${allocated > program.budget ? ` (projects allocate ${money(allocated)})` : ""}`);
|
|
5978
|
+
if (forecast) {
|
|
5979
|
+
const verdict = forecast.tone === "over" ? `over by ${money(-forecast.variance)}` : forecast.tone === "tight" ? "within 5% of budget" : `under by ${money(forecast.variance)}`;
|
|
5980
|
+
lines.push(`- Forecast at completion ${money(forecast.eac)}, ${verdict}`);
|
|
5981
|
+
}
|
|
5982
|
+
} else lines.push("- No budget set");
|
|
5983
|
+
lines.push("");
|
|
5984
|
+
lines.push("## Milestones");
|
|
5985
|
+
if (milestones.length === 0) lines.push("- None yet");
|
|
5986
|
+
for (const m of lateMilestones) lines.push(`- **Late:** ${m.name}, due ${shortDate(m.dueDate)}`);
|
|
5987
|
+
for (const m of upcoming) {
|
|
5988
|
+
const risk = milestoneRisks(m, data)[0];
|
|
5989
|
+
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`;
|
|
5990
|
+
lines.push(`- ${m.name}, due ${shortDate(m.dueDate)}${note}`);
|
|
5991
|
+
}
|
|
5992
|
+
const doneCount = milestones.filter((m) => m.done).length;
|
|
5993
|
+
if (doneCount) lines.push(`- ${doneCount} of ${milestones.length} reached`);
|
|
5994
|
+
lines.push("");
|
|
5995
|
+
lines.push("## Projects");
|
|
5996
|
+
if (projects.length === 0) lines.push("- None yet");
|
|
5997
|
+
for (const p of projects) {
|
|
5998
|
+
const own = tasks.filter((t) => t.projectId === p.id);
|
|
5999
|
+
const ownDone = own.filter((t) => t.status === "done").length;
|
|
6000
|
+
lines.push(`- ${p.name}: ${HEALTH_LABEL$1[p.health]}, ${ownDone}/${own.length} done, ${shortDate(p.startDate)} – ${shortDate(p.endDate)}`);
|
|
6001
|
+
}
|
|
6002
|
+
lines.push("");
|
|
6003
|
+
lines.push("## Risks and issues");
|
|
6004
|
+
if (live.length === 0) lines.push("- Nothing open");
|
|
6005
|
+
for (const r of live.slice(0, 8)) {
|
|
6006
|
+
const score = scoreOf(r);
|
|
6007
|
+
const band = score != null ? ` (${scoreBand(score)}, ${score})` : "";
|
|
6008
|
+
const who = data.people.find((p) => p.id === r.ownerId)?.name;
|
|
6009
|
+
lines.push(`- ${r.kind === "risk" ? "Risk" : "Issue"}: ${r.title}${band}${who ? ` — ${who}` : ""}`);
|
|
6010
|
+
}
|
|
6011
|
+
lines.push("");
|
|
6012
|
+
return lines.join("\n");
|
|
6013
|
+
}
|
|
6014
|
+
|
|
5696
6015
|
//#endregion
|
|
5697
6016
|
//#region src/tui/views/panels.tsx
|
|
5698
6017
|
/**
|
|
@@ -5739,13 +6058,75 @@ function healthField(kind, item, go, set, clear, finished = false) {
|
|
|
5739
6058
|
}
|
|
5740
6059
|
};
|
|
5741
6060
|
}
|
|
6061
|
+
/**
|
|
6062
|
+
* What a thing waits on, picked from `options`: Enter on one adds it or
|
|
6063
|
+
* takes it off, and one that already waits on this (a loop) is refused.
|
|
6064
|
+
*/
|
|
6065
|
+
function waitsField(label, self, options, go, save, loops = true) {
|
|
6066
|
+
const on = blockerIds(self);
|
|
6067
|
+
const names = options.filter((o) => on.includes(o.id)).map((o) => o.name);
|
|
6068
|
+
const choices = options.filter((o) => o.id !== self.id).map((o) => ({
|
|
6069
|
+
label: `${on.includes(o.id) ? "✓" : " "} ${o.name}`,
|
|
6070
|
+
value: o.id,
|
|
6071
|
+
hint: on.includes(o.id) ? "Enter stops waiting" : void 0
|
|
6072
|
+
}));
|
|
6073
|
+
return {
|
|
6074
|
+
label,
|
|
6075
|
+
value: names.length ? names.join(", ") : "Nothing",
|
|
6076
|
+
edit: choices.length ? {
|
|
6077
|
+
kind: "pick",
|
|
6078
|
+
title: `${label}: Enter adds or takes off one`,
|
|
6079
|
+
choices,
|
|
6080
|
+
onPick: (v) => {
|
|
6081
|
+
const picked = v;
|
|
6082
|
+
if (on.includes(picked)) return save(blockersPatch(on.filter((x) => x !== picked)));
|
|
6083
|
+
if (loops && wouldCycle(self.id, picked, options)) return go.notify("That one already waits on this, so it would go round in a loop.");
|
|
6084
|
+
save(blockersPatch([...on, picked]));
|
|
6085
|
+
}
|
|
6086
|
+
} : void 0
|
|
6087
|
+
};
|
|
6088
|
+
}
|
|
6089
|
+
/** The lines HealthMoves takes. */
|
|
6090
|
+
const movesRows = (history, today) => {
|
|
6091
|
+
const n = healthMoves(history, today).length;
|
|
6092
|
+
return n ? n + 2 : 0;
|
|
6093
|
+
};
|
|
6094
|
+
/** The last few moves of a thing's health, under its fields. */
|
|
6095
|
+
function HealthMoves({ history, today }) {
|
|
6096
|
+
const moves = healthMoves(history, today);
|
|
6097
|
+
if (!moves.length) return null;
|
|
6098
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
6099
|
+
/* @__PURE__ */ jsx(Text, { children: " " }),
|
|
6100
|
+
/* @__PURE__ */ jsxs(Text, {
|
|
6101
|
+
color: theme.muted,
|
|
6102
|
+
children: [" ", "Health history"]
|
|
6103
|
+
}),
|
|
6104
|
+
moves.map((m, i) => /* @__PURE__ */ jsxs(Text, {
|
|
6105
|
+
wrap: "truncate-end",
|
|
6106
|
+
children: [" ", m]
|
|
6107
|
+
}, i))
|
|
6108
|
+
] });
|
|
6109
|
+
}
|
|
6110
|
+
const CONSTRAINT_KINDS = Object.keys(CONSTRAINT_KIND_LABEL);
|
|
5742
6111
|
function ProgramPanel({ id, width, height, go, active, onClose }) {
|
|
5743
6112
|
const { store, today } = useDeps();
|
|
5744
6113
|
const program = useHarbor((s) => s.programs.find((p) => p.id === id));
|
|
5745
6114
|
const people = useHarbor((s) => s.people);
|
|
5746
6115
|
const portfolios = useHarbor((s) => s.portfolios);
|
|
6116
|
+
const programs = useHarbor((s) => s.programs);
|
|
6117
|
+
const projects = useHarbor((s) => s.projects);
|
|
6118
|
+
const constraint = useHarbor((s) => s.constraints.find((c) => c.programId === id && c.status === "active"));
|
|
5747
6119
|
if (!program) return null;
|
|
5748
6120
|
const s = store.getState();
|
|
6121
|
+
const inProgram = projects.filter((p) => p.programId === id);
|
|
6122
|
+
const baselined = inProgram.filter(hasBaseline);
|
|
6123
|
+
const holding = (patch) => store.getState().setConstraint(id, {
|
|
6124
|
+
title: constraint?.title ?? "",
|
|
6125
|
+
kind: constraint?.kind ?? "resource",
|
|
6126
|
+
ownerId: constraint?.ownerId ?? null,
|
|
6127
|
+
note: constraint?.note ?? "",
|
|
6128
|
+
...patch
|
|
6129
|
+
});
|
|
5749
6130
|
const fields = [
|
|
5750
6131
|
{
|
|
5751
6132
|
label: "Name",
|
|
@@ -5808,6 +6189,79 @@ function ProgramPanel({ id, width, height, go, active, onClose }) {
|
|
|
5808
6189
|
onPick: (v) => store.getState().updateProgram(id, { portfolioId: v })
|
|
5809
6190
|
}
|
|
5810
6191
|
},
|
|
6192
|
+
{
|
|
6193
|
+
label: "Budget",
|
|
6194
|
+
value: money(program.budget),
|
|
6195
|
+
edit: {
|
|
6196
|
+
kind: "number",
|
|
6197
|
+
initial: program.budget,
|
|
6198
|
+
min: 0,
|
|
6199
|
+
onSave: (v) => store.getState().updateProgram(id, { budget: v ?? 0 })
|
|
6200
|
+
}
|
|
6201
|
+
},
|
|
6202
|
+
{
|
|
6203
|
+
label: "Impact",
|
|
6204
|
+
value: `${program.impact ?? 5} of 10${program.impact == null ? " (not set)" : ""}`,
|
|
6205
|
+
edit: {
|
|
6206
|
+
kind: "number",
|
|
6207
|
+
initial: program.impact ?? 5,
|
|
6208
|
+
min: 1,
|
|
6209
|
+
max: 10,
|
|
6210
|
+
onSave: (v) => v != null && store.getState().updateProgram(id, { impact: Math.round(v) })
|
|
6211
|
+
}
|
|
6212
|
+
},
|
|
6213
|
+
{
|
|
6214
|
+
label: "Holding it back",
|
|
6215
|
+
value: constraint ? `${constraint.title} (${CONSTRAINT_KIND_LABEL[constraint.kind].toLowerCase()})` : "Nothing named",
|
|
6216
|
+
edit: {
|
|
6217
|
+
kind: "text",
|
|
6218
|
+
initial: constraint?.title ?? "",
|
|
6219
|
+
placeholder: "The one thing that sets the pace; empty when it's cleared",
|
|
6220
|
+
onSave: (v) => v.trim() ? holding({ title: v.trim() }) : constraint && store.getState().resolveConstraint(constraint.id)
|
|
6221
|
+
}
|
|
6222
|
+
},
|
|
6223
|
+
...constraint ? [{
|
|
6224
|
+
label: "What kind",
|
|
6225
|
+
value: CONSTRAINT_KIND_LABEL[constraint.kind],
|
|
6226
|
+
edit: {
|
|
6227
|
+
kind: "pick",
|
|
6228
|
+
choices: CONSTRAINT_KINDS.map((k) => ({
|
|
6229
|
+
label: CONSTRAINT_KIND_LABEL[k],
|
|
6230
|
+
value: k,
|
|
6231
|
+
hint: CONSTRAINT_KIND_HINT[k]
|
|
6232
|
+
})),
|
|
6233
|
+
initial: CONSTRAINT_KINDS.indexOf(constraint.kind),
|
|
6234
|
+
onPick: (v) => holding({ kind: v })
|
|
6235
|
+
}
|
|
6236
|
+
}, {
|
|
6237
|
+
label: "Who clears it",
|
|
6238
|
+
value: people.find((p) => p.id === constraint.ownerId)?.name ?? "Nobody yet",
|
|
6239
|
+
edit: {
|
|
6240
|
+
kind: "pick",
|
|
6241
|
+
choices: peopleChoices(people, true),
|
|
6242
|
+
initial: Math.max(0, people.findIndex((p) => p.id === constraint.ownerId) + 1),
|
|
6243
|
+
onPick: (v) => holding({ ownerId: v })
|
|
6244
|
+
}
|
|
6245
|
+
}] : [],
|
|
6246
|
+
waitsField("Waits on", program, programs, go, (patch) => store.getState().updateProgram(id, patch)),
|
|
6247
|
+
{
|
|
6248
|
+
label: "Baseline",
|
|
6249
|
+
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",
|
|
6250
|
+
edit: inProgram.length ? {
|
|
6251
|
+
kind: "pick",
|
|
6252
|
+
title: "The plan to measure slips against",
|
|
6253
|
+
choices: [...baselined.length < inProgram.length ? [{
|
|
6254
|
+
label: "Set it where it's missing",
|
|
6255
|
+
value: "set",
|
|
6256
|
+
hint: "today's dates become the plan"
|
|
6257
|
+
}] : [], ...baselined.length ? [{
|
|
6258
|
+
label: "Reset every project to today's dates",
|
|
6259
|
+
value: "reset",
|
|
6260
|
+
hint: "slips start from zero"
|
|
6261
|
+
}] : []],
|
|
6262
|
+
onPick: (v) => store.getState().snapshotBaseline(id, v === "reset")
|
|
6263
|
+
} : void 0
|
|
6264
|
+
},
|
|
5811
6265
|
{
|
|
5812
6266
|
label: "About",
|
|
5813
6267
|
value: program.description.replace(/\s+/g, " "),
|
|
@@ -5827,23 +6281,33 @@ function ProgramPanel({ id, width, height, go, active, onClose }) {
|
|
|
5827
6281
|
fields,
|
|
5828
6282
|
width,
|
|
5829
6283
|
height,
|
|
5830
|
-
hints: "d delete",
|
|
5831
|
-
|
|
5832
|
-
|
|
5833
|
-
|
|
5834
|
-
|
|
5835
|
-
|
|
5836
|
-
|
|
5837
|
-
|
|
6284
|
+
hints: "r status report · d delete",
|
|
6285
|
+
below: /* @__PURE__ */ jsx(HealthMoves, {
|
|
6286
|
+
history: program.healthHistory,
|
|
6287
|
+
today: today()
|
|
6288
|
+
}),
|
|
6289
|
+
belowRows: movesRows(program.healthHistory, today()),
|
|
6290
|
+
keys: {
|
|
6291
|
+
r: () => go.read(`Status report · ${program.name}`, markdownToPlain(composeStatusReport(store.getState(), program))),
|
|
6292
|
+
d: () => {
|
|
6293
|
+
const what = deleteSummary(store.getState(), "program", id);
|
|
6294
|
+
go.confirm(`Delete the program “${program.name}”${what ? ` and ${what}` : ""}? u undoes it.`, () => {
|
|
6295
|
+
store.getState().deleteEntity("program", id);
|
|
6296
|
+
onClose();
|
|
6297
|
+
}, "Delete");
|
|
6298
|
+
}
|
|
6299
|
+
},
|
|
5838
6300
|
active,
|
|
5839
6301
|
onClose
|
|
5840
6302
|
});
|
|
5841
6303
|
}
|
|
5842
6304
|
function PortfolioPanel({ id, width, height, go, active, onClose }) {
|
|
5843
|
-
const { store } = useDeps();
|
|
6305
|
+
const { store, today } = useDeps();
|
|
6306
|
+
const stored = useHarbor((s) => s.priorityRules);
|
|
5844
6307
|
const portfolio = useHarbor((s) => s.portfolios.find((p) => p.id === id));
|
|
5845
6308
|
const people = useHarbor((s) => s.people);
|
|
5846
6309
|
if (!portfolio) return null;
|
|
6310
|
+
const rules = normalizeRules(stored);
|
|
5847
6311
|
const fields = [
|
|
5848
6312
|
{
|
|
5849
6313
|
label: "Name",
|
|
@@ -5884,6 +6348,34 @@ function PortfolioPanel({ id, width, height, go, active, onClose }) {
|
|
|
5884
6348
|
min: 0,
|
|
5885
6349
|
onSave: (v) => store.getState().updatePortfolio(id, { budget: v ?? 0 })
|
|
5886
6350
|
}
|
|
6351
|
+
},
|
|
6352
|
+
{
|
|
6353
|
+
label: "High impact from",
|
|
6354
|
+
value: `${rules.impactThreshold} of 10${stored ? "" : " (the usual)"}`,
|
|
6355
|
+
edit: {
|
|
6356
|
+
kind: "number",
|
|
6357
|
+
initial: rules.impactThreshold,
|
|
6358
|
+
min: 1,
|
|
6359
|
+
max: 10,
|
|
6360
|
+
onSave: (v) => store.getState().setPriorityRules({
|
|
6361
|
+
...rules,
|
|
6362
|
+
impactThreshold: v ?? DEFAULT_RULES.impactThreshold
|
|
6363
|
+
})
|
|
6364
|
+
}
|
|
6365
|
+
},
|
|
6366
|
+
{
|
|
6367
|
+
label: "High budget from",
|
|
6368
|
+
value: `${Math.round(rules.budgetShare * 100)}% of the largest program's${stored ? "" : " (the usual)"}`,
|
|
6369
|
+
edit: {
|
|
6370
|
+
kind: "number",
|
|
6371
|
+
initial: Math.round(rules.budgetShare * 100),
|
|
6372
|
+
min: 5,
|
|
6373
|
+
max: 100,
|
|
6374
|
+
onSave: (v) => store.getState().setPriorityRules({
|
|
6375
|
+
...rules,
|
|
6376
|
+
budgetShare: (v ?? DEFAULT_RULES.budgetShare * 100) / 100
|
|
6377
|
+
})
|
|
6378
|
+
}
|
|
5887
6379
|
}
|
|
5888
6380
|
];
|
|
5889
6381
|
return /* @__PURE__ */ jsx(DetailPanel, {
|
|
@@ -5896,6 +6388,11 @@ function PortfolioPanel({ id, width, height, go, active, onClose }) {
|
|
|
5896
6388
|
width,
|
|
5897
6389
|
height,
|
|
5898
6390
|
hints: "d delete",
|
|
6391
|
+
below: /* @__PURE__ */ jsx(HealthMoves, {
|
|
6392
|
+
history: portfolio.healthHistory,
|
|
6393
|
+
today: today()
|
|
6394
|
+
}),
|
|
6395
|
+
belowRows: movesRows(portfolio.healthHistory, today()),
|
|
5899
6396
|
keys: { d: () => {
|
|
5900
6397
|
const what = deleteSummary(store.getState(), "portfolio", id);
|
|
5901
6398
|
go.confirm(`Delete the portfolio “${portfolio.name}”${what ? ` and ${what}` : ""}? u undoes it.`, () => {
|
|
@@ -5913,8 +6410,14 @@ function ProjectPanel({ id, width, height, go, active, onClose }) {
|
|
|
5913
6410
|
const tasks = useHarbor((s) => s.tasks);
|
|
5914
6411
|
const people = useHarbor((s) => s.people);
|
|
5915
6412
|
const programs = useHarbor((s) => s.programs);
|
|
6413
|
+
const projects = useHarbor((s) => s.projects);
|
|
5916
6414
|
if (!project) return null;
|
|
5917
6415
|
const up = (patch) => store.getState().updateProject(id, patch);
|
|
6416
|
+
const planned = plannedByMonth(project);
|
|
6417
|
+
const months = monthsBetween(project.startDate, project.endDate);
|
|
6418
|
+
const monthName = (key) => format(parseIso(`${key}-01`), "MMM yyyy");
|
|
6419
|
+
const waits = blockerIds(project);
|
|
6420
|
+
const slip = slipLabel(project);
|
|
5918
6421
|
const fields = [
|
|
5919
6422
|
{
|
|
5920
6423
|
label: "Name",
|
|
@@ -5987,6 +6490,45 @@ function ProjectPanel({ id, width, height, go, active, onClose }) {
|
|
|
5987
6490
|
onPick: (v) => store.getState().moveProjects([id], v)
|
|
5988
6491
|
}
|
|
5989
6492
|
},
|
|
6493
|
+
{
|
|
6494
|
+
label: "Plan by month",
|
|
6495
|
+
value: hasOwnPhasing(project) ? "Set by hand" : "Spread evenly",
|
|
6496
|
+
edit: months.length ? {
|
|
6497
|
+
kind: "pick",
|
|
6498
|
+
title: "Pick a month to change what it plans to spend",
|
|
6499
|
+
choices: [...months.map((k) => ({
|
|
6500
|
+
label: `${monthName(k)} ${money(planned[k] ?? 0)}`,
|
|
6501
|
+
value: k
|
|
6502
|
+
})), ...hasOwnPhasing(project) ? [{
|
|
6503
|
+
label: "Back to an even spread",
|
|
6504
|
+
value: null
|
|
6505
|
+
}] : []],
|
|
6506
|
+
onPick: (v) => v == null ? up({ phasing: null }) : go.ask(`Planned for ${monthName(v)}`, (text) => {
|
|
6507
|
+
const n = Number(text.replace(/[$,\s]/g, ""));
|
|
6508
|
+
if (!Number.isFinite(n) || n < 0) return go.notify(`“${text}” isn't an amount.`);
|
|
6509
|
+
up({ phasing: withMonth(project, v, n) });
|
|
6510
|
+
}, String(planned[v] ?? 0))
|
|
6511
|
+
} : void 0
|
|
6512
|
+
},
|
|
6513
|
+
waitsField("Waits on", project, projects, go, (patch) => up({
|
|
6514
|
+
...patch,
|
|
6515
|
+
lagDays: patch.blockedByIds.length ? project.lagDays : null
|
|
6516
|
+
})),
|
|
6517
|
+
...waits.length ? [{
|
|
6518
|
+
label: "Lag days",
|
|
6519
|
+
value: project.lagDays ? `${project.lagDays} after they finish` : "None",
|
|
6520
|
+
edit: {
|
|
6521
|
+
kind: "number",
|
|
6522
|
+
initial: project.lagDays ?? null,
|
|
6523
|
+
min: 0,
|
|
6524
|
+
onSave: (v) => up({ lagDays: v ? Math.round(v) : null })
|
|
6525
|
+
}
|
|
6526
|
+
}] : [],
|
|
6527
|
+
{
|
|
6528
|
+
label: "Baseline",
|
|
6529
|
+
value: hasBaseline(project) ? `${shortDay(project.baselineStart, today())} to ${shortDay(project.baselineEnd, today())}${slip ? `, ${slip}` : ""}` : "Not set (set it on the program)",
|
|
6530
|
+
color: slip?.startsWith("slipped") ? theme.bad : void 0
|
|
6531
|
+
},
|
|
5990
6532
|
{
|
|
5991
6533
|
label: "Key",
|
|
5992
6534
|
value: project.key ?? ""
|
|
@@ -6011,6 +6553,11 @@ function ProjectPanel({ id, width, height, go, active, onClose }) {
|
|
|
6011
6553
|
width,
|
|
6012
6554
|
height,
|
|
6013
6555
|
hints: "d delete",
|
|
6556
|
+
below: /* @__PURE__ */ jsx(HealthMoves, {
|
|
6557
|
+
history: project.healthHistory,
|
|
6558
|
+
today: today()
|
|
6559
|
+
}),
|
|
6560
|
+
belowRows: movesRows(project.healthHistory, today()),
|
|
6014
6561
|
keys: { d: () => {
|
|
6015
6562
|
const what = deleteSummary(store.getState(), "project", id);
|
|
6016
6563
|
go.confirm(`Delete the project “${project.name}”${what ? ` and ${what}` : ""}? u undoes it.`, () => {
|
|
@@ -6022,13 +6569,25 @@ function ProjectPanel({ id, width, height, go, active, onClose }) {
|
|
|
6022
6569
|
onClose
|
|
6023
6570
|
});
|
|
6024
6571
|
}
|
|
6572
|
+
const SIGN_OFF = {
|
|
6573
|
+
pending: "Waiting for sign-off",
|
|
6574
|
+
approved: "Approved",
|
|
6575
|
+
changes_requested: "Changes requested"
|
|
6576
|
+
};
|
|
6025
6577
|
function MilestonePanel({ id, width, height, go, active, onClose }) {
|
|
6026
6578
|
const { store, today } = useDeps();
|
|
6027
6579
|
const milestone = useHarbor((s) => s.milestones.find((m) => m.id === id));
|
|
6028
6580
|
const people = useHarbor((s) => s.people);
|
|
6581
|
+
const projects = useHarbor((s) => s.projects);
|
|
6582
|
+
const me = useHarbor((s) => s.currentUserId);
|
|
6029
6583
|
if (!milestone) return null;
|
|
6030
6584
|
const up = (patch) => store.getState().updateMilestone(id, patch);
|
|
6031
6585
|
const late = !milestone.done && milestone.dueDate < today();
|
|
6586
|
+
const gated = Boolean(milestone.approverId);
|
|
6587
|
+
const approver = people.find((p) => p.id === milestone.approverId);
|
|
6588
|
+
const status = milestone.approvalStatus ?? "pending";
|
|
6589
|
+
const inProgram = projects.filter((p) => p.programId === milestone.programId);
|
|
6590
|
+
const risks = milestoneRisks(milestone, store.getState());
|
|
6032
6591
|
const fields = [
|
|
6033
6592
|
{
|
|
6034
6593
|
label: "Name",
|
|
@@ -6051,8 +6610,8 @@ function MilestonePanel({ id, width, height, go, active, onClose }) {
|
|
|
6051
6610
|
},
|
|
6052
6611
|
{
|
|
6053
6612
|
label: "Done",
|
|
6054
|
-
value: milestone.done ? "Yes" : "Not yet",
|
|
6055
|
-
edit: {
|
|
6613
|
+
value: milestone.done ? "Yes" : gated ? "Not yet (the sign-off ticks it)" : "Not yet",
|
|
6614
|
+
edit: gated ? void 0 : {
|
|
6056
6615
|
kind: "pick",
|
|
6057
6616
|
choices: [{
|
|
6058
6617
|
label: "Not yet",
|
|
@@ -6067,14 +6626,49 @@ function MilestonePanel({ id, width, height, go, active, onClose }) {
|
|
|
6067
6626
|
},
|
|
6068
6627
|
{
|
|
6069
6628
|
label: "Signs off",
|
|
6070
|
-
value:
|
|
6629
|
+
value: approver?.name ?? "Nobody (no sign-off)",
|
|
6071
6630
|
edit: {
|
|
6072
6631
|
kind: "pick",
|
|
6632
|
+
title: "Who signs it off? Nobody else can tick it done.",
|
|
6073
6633
|
choices: peopleChoices(people, true),
|
|
6074
6634
|
initial: Math.max(0, people.findIndex((p) => p.id === milestone.approverId) + 1),
|
|
6075
|
-
onPick: (v) =>
|
|
6635
|
+
onPick: (v) => v ? v !== milestone.approverId && up({
|
|
6636
|
+
approverId: v,
|
|
6637
|
+
approvalStatus: "pending",
|
|
6638
|
+
done: false
|
|
6639
|
+
}) : up({
|
|
6640
|
+
approverId: null,
|
|
6641
|
+
approvalStatus: null,
|
|
6642
|
+
approvalNote: null
|
|
6643
|
+
})
|
|
6076
6644
|
}
|
|
6077
|
-
}
|
|
6645
|
+
},
|
|
6646
|
+
...gated ? [{
|
|
6647
|
+
label: "Sign-off",
|
|
6648
|
+
value: `${SIGN_OFF[status]}${status === "changes_requested" && milestone.approvalNote ? `: ${milestone.approvalNote}` : ""}`,
|
|
6649
|
+
color: status === "approved" ? theme.good : status === "pending" ? void 0 : theme.bad,
|
|
6650
|
+
edit: milestone.approverId === me && status !== "approved" ? {
|
|
6651
|
+
kind: "pick",
|
|
6652
|
+
title: "Your sign-off",
|
|
6653
|
+
choices: [{
|
|
6654
|
+
label: "Approve",
|
|
6655
|
+
value: "approve",
|
|
6656
|
+
hint: "ticks it done"
|
|
6657
|
+
}, {
|
|
6658
|
+
label: "Request changes",
|
|
6659
|
+
value: "changes"
|
|
6660
|
+
}],
|
|
6661
|
+
onPick: (v) => v === "approve" ? up({
|
|
6662
|
+
approvalStatus: "approved",
|
|
6663
|
+
done: true
|
|
6664
|
+
}) : go.ask("What needs changing?", (note) => up({
|
|
6665
|
+
approvalStatus: "changes_requested",
|
|
6666
|
+
approvalNote: note.trim() || null,
|
|
6667
|
+
done: false
|
|
6668
|
+
}))
|
|
6669
|
+
} : void 0
|
|
6670
|
+
}] : [],
|
|
6671
|
+
waitsField("Waits on", milestone, inProgram, go, (patch) => up(patch), false)
|
|
6078
6672
|
];
|
|
6079
6673
|
return /* @__PURE__ */ jsx(DetailPanel, {
|
|
6080
6674
|
title: /* @__PURE__ */ jsx(Text, {
|
|
@@ -6085,14 +6679,25 @@ function MilestonePanel({ id, width, height, go, active, onClose }) {
|
|
|
6085
6679
|
fields,
|
|
6086
6680
|
width,
|
|
6087
6681
|
height,
|
|
6088
|
-
hints: "space done · d delete",
|
|
6682
|
+
hints: gated ? "d delete" : "space done · d delete",
|
|
6089
6683
|
keys: {
|
|
6090
|
-
" ": () => store.getState().toggleMilestone(id),
|
|
6684
|
+
" ": () => gated ? go.notify(`${approver?.name ?? "The approver"} ticks this off by signing it off.`) : store.getState().toggleMilestone(id),
|
|
6091
6685
|
d: () => go.confirm(`Delete the milestone “${milestone.name}”? u undoes it.`, () => {
|
|
6092
6686
|
store.getState().deleteMilestone(id);
|
|
6093
6687
|
onClose();
|
|
6094
6688
|
}, "Delete")
|
|
6095
6689
|
},
|
|
6690
|
+
belowRows: risks.length ? risks.length + 1 : 0,
|
|
6691
|
+
below: risks.length ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Text, { children: " " }), risks.map((r) => /* @__PURE__ */ jsxs(Text, {
|
|
6692
|
+
color: theme.bad,
|
|
6693
|
+
wrap: "truncate-end",
|
|
6694
|
+
children: [
|
|
6695
|
+
" ",
|
|
6696
|
+
"At risk:",
|
|
6697
|
+
" ",
|
|
6698
|
+
r.reason === "ends-after" ? `${r.project.name} ends ${shortDay(r.project.endDate, today())}` : `${r.project.name} is off track`
|
|
6699
|
+
]
|
|
6700
|
+
}, r.project.id))] }) : null,
|
|
6096
6701
|
active,
|
|
6097
6702
|
onClose
|
|
6098
6703
|
});
|
|
@@ -7709,36 +8314,6 @@ function RoadmapView({ active, width, height, go }) {
|
|
|
7709
8314
|
});
|
|
7710
8315
|
}
|
|
7711
8316
|
|
|
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
8317
|
//#endregion
|
|
7743
8318
|
//#region src/lib/recurrence.ts
|
|
7744
8319
|
const REPEAT_LABEL = {
|
|
@@ -8589,6 +9164,11 @@ function App({ ui }) {
|
|
|
8589
9164
|
choices,
|
|
8590
9165
|
then
|
|
8591
9166
|
}),
|
|
9167
|
+
read: (title, text) => setOverlay({
|
|
9168
|
+
kind: "read",
|
|
9169
|
+
title,
|
|
9170
|
+
text
|
|
9171
|
+
}),
|
|
8592
9172
|
notify
|
|
8593
9173
|
};
|
|
8594
9174
|
const goScreen = (next) => {
|
|
@@ -8632,6 +9212,11 @@ function App({ ui }) {
|
|
|
8632
9212
|
hint: "make",
|
|
8633
9213
|
run: () => go.create("program")
|
|
8634
9214
|
},
|
|
9215
|
+
{
|
|
9216
|
+
label: "New portfolio",
|
|
9217
|
+
hint: "make",
|
|
9218
|
+
run: () => go.create("portfolio")
|
|
9219
|
+
},
|
|
8635
9220
|
{
|
|
8636
9221
|
label: "Log a risk, issue or decision",
|
|
8637
9222
|
hint: "make",
|
|
@@ -8754,7 +9339,10 @@ function App({ ui }) {
|
|
|
8754
9339
|
close();
|
|
8755
9340
|
const id = commitCreate(store, what, values, ctx, today());
|
|
8756
9341
|
if (!id) return notify("There's nowhere to put that yet.");
|
|
8757
|
-
if (what === "
|
|
9342
|
+
if (what === "portfolio") {
|
|
9343
|
+
notify(`Made ${String(values.name)}.`);
|
|
9344
|
+
go.openPortfolio(id);
|
|
9345
|
+
} else if (what === "project") {
|
|
8758
9346
|
notify(`Made ${String(values.name)}.`);
|
|
8759
9347
|
go.openProject(id);
|
|
8760
9348
|
} else if (what === "program") {
|
|
@@ -8804,7 +9392,15 @@ function App({ ui }) {
|
|
|
8804
9392
|
}
|
|
8805
9393
|
})
|
|
8806
9394
|
});
|
|
8807
|
-
} else if (overlay?.kind === "
|
|
9395
|
+
} else if (overlay?.kind === "read") overlayNode = /* @__PURE__ */ jsx(Letter, {
|
|
9396
|
+
title: overlay.title,
|
|
9397
|
+
text: overlay.text,
|
|
9398
|
+
width: mainWidth,
|
|
9399
|
+
height: bodyHeight,
|
|
9400
|
+
active: true,
|
|
9401
|
+
onClose: close
|
|
9402
|
+
});
|
|
9403
|
+
else if (overlay?.kind === "pick") {
|
|
8808
9404
|
const { title, choices, then } = overlay;
|
|
8809
9405
|
overlayNode = /* @__PURE__ */ jsx(Box, {
|
|
8810
9406
|
width: mainWidth,
|
|
@@ -9815,6 +10411,38 @@ function hierarchySlice({ get, write }) {
|
|
|
9815
10411
|
get().log(`added the sample project`, `/projects/${id}`);
|
|
9816
10412
|
return id;
|
|
9817
10413
|
},
|
|
10414
|
+
addShelfPlan: (rows) => {
|
|
10415
|
+
const state = get();
|
|
10416
|
+
const starter = starterProgram(state);
|
|
10417
|
+
const program = starter ? {
|
|
10418
|
+
...starter,
|
|
10419
|
+
...rows.program,
|
|
10420
|
+
id: starter.id,
|
|
10421
|
+
portfolioId: starter.portfolioId,
|
|
10422
|
+
health: starter.health
|
|
10423
|
+
} : rows.program;
|
|
10424
|
+
const milestones = rows.milestones.map((m) => ({
|
|
10425
|
+
...m,
|
|
10426
|
+
programId: program.id
|
|
10427
|
+
}));
|
|
10428
|
+
const project = {
|
|
10429
|
+
...rows.project,
|
|
10430
|
+
programId: program.id
|
|
10431
|
+
};
|
|
10432
|
+
write({
|
|
10433
|
+
programs: starter ? state.programs.map((g) => g.id === starter.id ? program : g) : [...state.programs, program],
|
|
10434
|
+
milestones: [...state.milestones, ...milestones],
|
|
10435
|
+
projects: [...state.projects, project],
|
|
10436
|
+
tasks: [...state.tasks, ...rows.tasks],
|
|
10437
|
+
raidItems: [...state.raidItems, ...rows.raidItems],
|
|
10438
|
+
boardConfigs: {
|
|
10439
|
+
...state.boardConfigs ?? {},
|
|
10440
|
+
[project.id]: defaultBoardConfig()
|
|
10441
|
+
}
|
|
10442
|
+
});
|
|
10443
|
+
get().log(`opened the ${rows.program.name} plan`, `/programs/${program.id}`);
|
|
10444
|
+
return program.id;
|
|
10445
|
+
},
|
|
9818
10446
|
updatePortfolio: (id, patch) => {
|
|
9819
10447
|
write({ portfolios: get().portfolios.map((p) => p.id === id ? {
|
|
9820
10448
|
...p,
|
|
@@ -10774,6 +11402,42 @@ function milestonesRaidSlice({ set, get, write }) {
|
|
|
10774
11402
|
};
|
|
10775
11403
|
}
|
|
10776
11404
|
|
|
11405
|
+
//#endregion
|
|
11406
|
+
//#region src/lib/handover.ts
|
|
11407
|
+
/** The line that travels with each handed item: who it came from, to whom, until when, and the note. */
|
|
11408
|
+
function handoverLine(opts) {
|
|
11409
|
+
const until = opts.until && opts.untilText ? `, until ${opts.untilText}` : "";
|
|
11410
|
+
const note = opts.note.trim();
|
|
11411
|
+
return `Handed over from ${opts.fromName} to ${opts.toName}${until}.${note ? ` ${note}` : ""}`;
|
|
11412
|
+
}
|
|
11413
|
+
/** What can go back after time away: moves whose item is still with the person who took it. */
|
|
11414
|
+
function returnable(data, person) {
|
|
11415
|
+
const moves = person.away?.moves ?? [];
|
|
11416
|
+
const tasks = byId(data.tasks);
|
|
11417
|
+
const milestones = byId(data.milestones);
|
|
11418
|
+
const raid = byId(data.raidItems);
|
|
11419
|
+
const projects = byId(data.projects);
|
|
11420
|
+
const programs = byId(data.programs);
|
|
11421
|
+
return moves.filter((m) => {
|
|
11422
|
+
switch (m.kind) {
|
|
11423
|
+
case "task": {
|
|
11424
|
+
const t = tasks.get(m.id);
|
|
11425
|
+
return Boolean(t && t.assigneeId === m.toId && t.status !== "done");
|
|
11426
|
+
}
|
|
11427
|
+
case "signoff": {
|
|
11428
|
+
const ms = milestones.get(m.id);
|
|
11429
|
+
return Boolean(ms && ms.approverId === m.toId && !ms.done);
|
|
11430
|
+
}
|
|
11431
|
+
case "raid": {
|
|
11432
|
+
const r = raid.get(m.id);
|
|
11433
|
+
return Boolean(r && r.ownerId === m.toId && isActive(r));
|
|
11434
|
+
}
|
|
11435
|
+
case "project": return projects.get(m.id)?.ownerId === m.toId;
|
|
11436
|
+
case "program": return programs.get(m.id)?.ownerId === m.toId;
|
|
11437
|
+
}
|
|
11438
|
+
});
|
|
11439
|
+
}
|
|
11440
|
+
|
|
10777
11441
|
//#endregion
|
|
10778
11442
|
//#region src/lib/allocation.ts
|
|
10779
11443
|
/** Allocations with `programId` set to `percent`; 0 or null removes the line. */
|
|
@@ -10797,6 +11461,57 @@ function peopleSpendSlice({ set, get, write }) {
|
|
|
10797
11461
|
allocations: withAllocation(p.allocations, programId, percent)
|
|
10798
11462
|
} : p) });
|
|
10799
11463
|
},
|
|
11464
|
+
handOver: (fromId, moves, opts) => {
|
|
11465
|
+
const from = get().people.find((p) => p.id === fromId);
|
|
11466
|
+
if (!from) return 0;
|
|
11467
|
+
const until = opts.until || null;
|
|
11468
|
+
const applied = applyMoves(get(), moves, (m) => ({
|
|
11469
|
+
fromName: from.name,
|
|
11470
|
+
toName: get().people.find((p) => p.id === m.toId)?.name ?? "someone",
|
|
11471
|
+
until,
|
|
11472
|
+
untilText: until ? longDate(until) : null,
|
|
11473
|
+
note: opts.note ?? ""
|
|
11474
|
+
}));
|
|
11475
|
+
write({
|
|
11476
|
+
...applied.patch,
|
|
11477
|
+
people: get().people.map((p) => p.id === fromId ? {
|
|
11478
|
+
...p,
|
|
11479
|
+
away: until ? {
|
|
11480
|
+
until,
|
|
11481
|
+
note: opts.note?.trim() || null,
|
|
11482
|
+
since: stamp(),
|
|
11483
|
+
moves: applied.moved
|
|
11484
|
+
} : null
|
|
11485
|
+
} : p)
|
|
11486
|
+
});
|
|
11487
|
+
get().log(until ? `handed over ${from.name}’s work until ${longDate(until)}` : `handed over ${from.name}’s work`, "/people");
|
|
11488
|
+
return applied.moved.length;
|
|
11489
|
+
},
|
|
11490
|
+
handBack: (personId) => {
|
|
11491
|
+
const person = get().people.find((p) => p.id === personId);
|
|
11492
|
+
if (!person?.away) return 0;
|
|
11493
|
+
const back = returnable(get(), person).map((m) => ({
|
|
11494
|
+
...m,
|
|
11495
|
+
toId: personId,
|
|
11496
|
+
fromId: m.toId
|
|
11497
|
+
}));
|
|
11498
|
+
const applied = applyMoves(get(), back, (m) => ({
|
|
11499
|
+
fromName: get().people.find((p) => p.id === m.fromId)?.name ?? "someone",
|
|
11500
|
+
toName: person.name,
|
|
11501
|
+
until: null,
|
|
11502
|
+
untilText: null,
|
|
11503
|
+
note: "Back from time away."
|
|
11504
|
+
}));
|
|
11505
|
+
write({
|
|
11506
|
+
...applied.patch,
|
|
11507
|
+
people: get().people.map((p) => p.id === personId ? {
|
|
11508
|
+
...p,
|
|
11509
|
+
away: null
|
|
11510
|
+
} : p)
|
|
11511
|
+
});
|
|
11512
|
+
get().log(`handed ${person.name}’s work back`, "/people");
|
|
11513
|
+
return applied.moved.length;
|
|
11514
|
+
},
|
|
10800
11515
|
updatePerson: (id, patch) => {
|
|
10801
11516
|
write({ people: get().people.map((p) => p.id === id ? {
|
|
10802
11517
|
...p,
|
|
@@ -10891,6 +11606,95 @@ function peopleSpendSlice({ set, get, write }) {
|
|
|
10891
11606
|
}
|
|
10892
11607
|
};
|
|
10893
11608
|
}
|
|
11609
|
+
/**
|
|
11610
|
+
* One handover's changes as a single write: each item to its new person, the
|
|
11611
|
+
* line that travels with it (a comment on a task, an update on a RAID item),
|
|
11612
|
+
* and the moves that actually applied. Done or missing items are skipped.
|
|
11613
|
+
*/
|
|
11614
|
+
function applyMoves(s, moves, lineFor) {
|
|
11615
|
+
const by = s.currentUserId;
|
|
11616
|
+
const at = stamp();
|
|
11617
|
+
const want = (kind) => new Map(moves.filter((m) => m.kind === kind).map((m) => [m.id, m]));
|
|
11618
|
+
const tasksTo = want("task");
|
|
11619
|
+
const signoffsTo = want("signoff");
|
|
11620
|
+
const raidTo = want("raid");
|
|
11621
|
+
const projectsTo = want("project");
|
|
11622
|
+
const programsTo = want("program");
|
|
11623
|
+
const moved = [];
|
|
11624
|
+
const done = (m) => moved.push({
|
|
11625
|
+
kind: m.kind,
|
|
11626
|
+
id: m.id,
|
|
11627
|
+
toId: m.toId
|
|
11628
|
+
});
|
|
11629
|
+
const comments = [];
|
|
11630
|
+
const patch = {};
|
|
11631
|
+
if (tasksTo.size) patch.tasks = s.tasks.map((t) => {
|
|
11632
|
+
const m = tasksTo.get(t.id);
|
|
11633
|
+
if (!m || t.status === "done" || t.assigneeId === m.toId) return t;
|
|
11634
|
+
done(m);
|
|
11635
|
+
comments.push({
|
|
11636
|
+
id: uid("c"),
|
|
11637
|
+
taskId: t.id,
|
|
11638
|
+
authorId: by,
|
|
11639
|
+
body: handoverLine(lineFor(m)),
|
|
11640
|
+
createdAt: at,
|
|
11641
|
+
mentionedIds: []
|
|
11642
|
+
});
|
|
11643
|
+
return stampAssignment(t, {
|
|
11644
|
+
...t,
|
|
11645
|
+
assigneeId: m.toId
|
|
11646
|
+
}, by, at);
|
|
11647
|
+
});
|
|
11648
|
+
if (comments.length) patch.comments = [...s.comments, ...comments];
|
|
11649
|
+
if (signoffsTo.size) patch.milestones = s.milestones.map((ms) => {
|
|
11650
|
+
const m = signoffsTo.get(ms.id);
|
|
11651
|
+
if (!m || ms.done || ms.approverId === m.toId) return ms;
|
|
11652
|
+
done(m);
|
|
11653
|
+
return {
|
|
11654
|
+
...ms,
|
|
11655
|
+
approverId: m.toId
|
|
11656
|
+
};
|
|
11657
|
+
});
|
|
11658
|
+
if (raidTo.size) patch.raidItems = s.raidItems.map((r) => {
|
|
11659
|
+
const m = raidTo.get(r.id);
|
|
11660
|
+
if (!m || r.ownerId === m.toId) return r;
|
|
11661
|
+
done(m);
|
|
11662
|
+
const update = {
|
|
11663
|
+
id: uid("ru"),
|
|
11664
|
+
authorId: by,
|
|
11665
|
+
body: handoverLine(lineFor(m)),
|
|
11666
|
+
createdAt: at
|
|
11667
|
+
};
|
|
11668
|
+
return {
|
|
11669
|
+
...r,
|
|
11670
|
+
ownerId: m.toId,
|
|
11671
|
+
updates: [...r.updates, update],
|
|
11672
|
+
updatedAt: at
|
|
11673
|
+
};
|
|
11674
|
+
});
|
|
11675
|
+
if (projectsTo.size) patch.projects = s.projects.map((p) => {
|
|
11676
|
+
const m = projectsTo.get(p.id);
|
|
11677
|
+
if (!m || p.ownerId === m.toId) return p;
|
|
11678
|
+
done(m);
|
|
11679
|
+
return {
|
|
11680
|
+
...p,
|
|
11681
|
+
ownerId: m.toId
|
|
11682
|
+
};
|
|
11683
|
+
});
|
|
11684
|
+
if (programsTo.size) patch.programs = s.programs.map((p) => {
|
|
11685
|
+
const m = programsTo.get(p.id);
|
|
11686
|
+
if (!m || p.ownerId === m.toId) return p;
|
|
11687
|
+
done(m);
|
|
11688
|
+
return {
|
|
11689
|
+
...p,
|
|
11690
|
+
ownerId: m.toId
|
|
11691
|
+
};
|
|
11692
|
+
});
|
|
11693
|
+
return {
|
|
11694
|
+
patch,
|
|
11695
|
+
moved
|
|
11696
|
+
};
|
|
11697
|
+
}
|
|
10894
11698
|
|
|
10895
11699
|
//#endregion
|
|
10896
11700
|
//#region src/tui/engine/store.ts
|
|
@@ -11172,6 +11976,7 @@ const FIELD = {
|
|
|
11172
11976
|
role: "role",
|
|
11173
11977
|
weeklyCapacity: "weekly capacity",
|
|
11174
11978
|
allocations: "time split",
|
|
11979
|
+
away: "time away",
|
|
11175
11980
|
location: "location",
|
|
11176
11981
|
note: "note"
|
|
11177
11982
|
};
|