@harborppm/cli 0.31.0 → 0.32.0

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