@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/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$3 = (iso) => /* @__PURE__ */ new Date(`${iso}T12:00:00`);
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$3(item.endDate), day$3(item.startDate)) + 1;
1582
- const gone = differenceInCalendarDays(now, day$3(item.startDate)) + 1;
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
- fields.map((f, i) => {
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
- const lines = useMemo(() => wrapLines(brief.text.split("\n").slice(2).join("\n"), width - 2), [brief.text, width]);
3792
- const rows = Math.max(1, height - 2);
3793
- const last = Math.max(0, lines.length - rows);
3794
- const scroll = (by) => setTop((t) => Math.max(0, Math.min(last, t + by)));
3795
- useInput((input, key) => {
3796
- if (key.upArrow || input === "k") scroll(-1);
3797
- else if (key.downArrow || input === "j") scroll(1);
3798
- else if (key.pageDown || input === " ") scroll(rows - 1);
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
- keys: { d: () => {
5832
- const what = deleteSummary(store.getState(), "program", id);
5833
- go.confirm(`Delete the program “${program.name}”${what ? ` and ${what}` : ""}? u undoes it.`, () => {
5834
- store.getState().deleteEntity("program", id);
5835
- onClose();
5836
- }, "Delete");
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: people.find((p) => p.id === milestone.approverId)?.name ?? "Nobody (no sign-off)",
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) => up({ approverId: 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 === "project") {
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 === "pick") {
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
  };