@harborppm/cli 0.12.0 → 0.13.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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/dist/app.mjs +640 -100
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -29,7 +29,7 @@ harbor # name your harbor and its first project, and you're in
29
29
  1. **Today**: today's focus (`f` adds or takes off the task you're on, up to three) and your check-in (`c`); what's on you, late first; who's waiting on you; your risks and issues.
30
30
  2. **Inbox**: the same pings as the website: waiting on you, sign-offs, mentions. Enter opens one; it's marked read.
31
31
  3. **Projects**: every portfolio, program and project with its health. A project opens on its board (columns side by side; `f` and `p` show only someone's cards or one priority), its list, or its budget and spend ledger (`v`); `e` changes its details: lead, dates, budget, health by hand with a note.
32
- 4. **Programs**: a program's projects and milestones side by side (`m` adds a milestone, space ticks one), its next move and health.
32
+ 4. **Programs**: a program's 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.
33
33
  5. **Risks**: the register, worst first. Enter changes one (status, owner, likelihood, impact, mitigation); `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.
package/dist/app.mjs CHANGED
@@ -1485,6 +1485,97 @@ function forecastAtCompletion(item, now) {
1485
1485
  };
1486
1486
  }
1487
1487
 
1488
+ //#endregion
1489
+ //#region src/lib/flow.ts
1490
+ function tasksInProgramOf(data, programId) {
1491
+ const ids = new Set(data.projects.filter((p) => p.programId === programId).map((p) => p.id));
1492
+ return data.tasks.filter((t) => ids.has(t.projectId));
1493
+ }
1494
+ function constraintOf(data, programId) {
1495
+ const rows = data.constraints ?? [];
1496
+ return rows.find((c) => c.programId === programId && c.status === "active") ?? rows.find((c) => c.programId === programId);
1497
+ }
1498
+ function activeConstraints(data) {
1499
+ return (data.constraints ?? []).filter((c) => c.status === "active");
1500
+ }
1501
+ function constraintsOwnedBy(data, personId) {
1502
+ return activeConstraints(data).filter((c) => c.ownerId === personId);
1503
+ }
1504
+ function constraintIdle(data, constraint) {
1505
+ if (constraint.status !== "active" || !constraint.ownerId) return false;
1506
+ const tasks = tasksInProgramOf(data, constraint.programId);
1507
+ if (!tasks.some((t) => t.status !== "done")) return false;
1508
+ if (tasks.some((t) => t.assigneeId === constraint.ownerId && t.status === "doing")) return false;
1509
+ const here = new Set(tasks.map((t) => t.id));
1510
+ return data.tasks.some((t) => t.assigneeId === constraint.ownerId && t.status === "doing" && !here.has(t.id));
1511
+ }
1512
+
1513
+ //#endregion
1514
+ //#region src/lib/spend.ts
1515
+ function spendEntriesOf(data, projectId) {
1516
+ return (data.spendEntries ?? []).filter((e) => e.projectId === projectId).sort((a, b) => a.date.localeCompare(b.date));
1517
+ }
1518
+ function spentFromEntries(entries) {
1519
+ return entries.reduce((sum, e) => sum + e.amount, 0);
1520
+ }
1521
+ /** The entry that makes the ledger agree with a total typed by hand. */
1522
+ function adjustmentEntry(projectId, oldSpent, newSpent, date, id) {
1523
+ const delta = Math.round((newSpent - oldSpent) * 100) / 100;
1524
+ if (delta === 0) return null;
1525
+ return {
1526
+ id,
1527
+ projectId,
1528
+ amount: delta,
1529
+ date,
1530
+ note: "Adjusted the total by hand"
1531
+ };
1532
+ }
1533
+
1534
+ //#endregion
1535
+ //#region src/lib/selectors.ts
1536
+ function programsIn(data, portfolioId) {
1537
+ return data.programs.filter((p) => p.portfolioId === portfolioId);
1538
+ }
1539
+ function projectsInProgram(data, programId) {
1540
+ return data.projects.filter((p) => p.programId === programId);
1541
+ }
1542
+ /** Projects (or programs) still running, and the ones set to Done, which lists tuck away until asked for. */
1543
+ function splitDone(items) {
1544
+ const open = [];
1545
+ const done = [];
1546
+ for (const item of items) (item.health === "done" ? done : open).push(item);
1547
+ return {
1548
+ open,
1549
+ done
1550
+ };
1551
+ }
1552
+ function projectsInPortfolio(data, portfolioId) {
1553
+ const ids = new Set(programsIn(data, portfolioId).map((p) => p.id));
1554
+ return data.projects.filter((p) => ids.has(p.programId));
1555
+ }
1556
+ function sum(values) {
1557
+ return values.reduce((a, b) => a + b, 0);
1558
+ }
1559
+ /** Projects are the leaf: their own `spent` field, no rollup needed. */
1560
+ function programSpent(data, programId) {
1561
+ return sum(projectsInProgram(data, programId).map((p) => p.spent));
1562
+ }
1563
+ /** Sum of a program's own projects' budgets — compare against the program's stated budget to see over/under-allocation. */
1564
+ function programBudgetAllocated(data, programId) {
1565
+ return sum(projectsInProgram(data, programId).map((p) => p.budget));
1566
+ }
1567
+ /** This program's own items plus everything raised in its projects. */
1568
+ function programRaidItems(data, programId) {
1569
+ const projectIds = new Set(projectsInProgram(data, programId).map((p) => p.id));
1570
+ return data.raidItems.filter((r) => r.programId === programId || r.projectId != null && projectIds.has(r.projectId));
1571
+ }
1572
+ /** This portfolio's own items plus everything raised in its programs and their projects. */
1573
+ function portfolioRaidItems(data, portfolioId) {
1574
+ const programIds = new Set(programsIn(data, portfolioId).map((p) => p.id));
1575
+ const projectIds = new Set(projectsInPortfolio(data, portfolioId).map((p) => p.id));
1576
+ return data.raidItems.filter((r) => r.portfolioId === portfolioId || r.programId != null && programIds.has(r.programId) || r.projectId != null && projectIds.has(r.projectId));
1577
+ }
1578
+
1488
1579
  //#endregion
1489
1580
  //#region src/lib/baseline.ts
1490
1581
  const day$2 = (iso) => /* @__PURE__ */ new Date(`${iso}T12:00:00`);
@@ -1823,27 +1914,6 @@ function searchTasks(tasks, projects, query, limit = 12) {
1823
1914
  }, limit);
1824
1915
  }
1825
1916
 
1826
- //#endregion
1827
- //#region src/lib/spend.ts
1828
- function spendEntriesOf(data, projectId) {
1829
- return (data.spendEntries ?? []).filter((e) => e.projectId === projectId).sort((a, b) => a.date.localeCompare(b.date));
1830
- }
1831
- function spentFromEntries(entries) {
1832
- return entries.reduce((sum, e) => sum + e.amount, 0);
1833
- }
1834
- /** The entry that makes the ledger agree with a total typed by hand. */
1835
- function adjustmentEntry(projectId, oldSpent, newSpent, date, id) {
1836
- const delta = Math.round((newSpent - oldSpent) * 100) / 100;
1837
- if (delta === 0) return null;
1838
- return {
1839
- id,
1840
- projectId,
1841
- amount: delta,
1842
- date,
1843
- note: "Adjusted the total by hand"
1844
- };
1845
- }
1846
-
1847
1917
  //#endregion
1848
1918
  //#region src/lib/work.ts
1849
1919
  /** Every task this one waits on that still exists, done or not. */
@@ -2096,6 +2166,74 @@ function filterTasks(tasks, f) {
2096
2166
  if (f.who === "any" && f.priority === "any") return tasks;
2097
2167
  return tasks.filter((t) => (f.who === "any" || (f.who === "none" ? t.assigneeId === null : t.assigneeId === f.who)) && (f.priority === "any" || t.priority === f.priority));
2098
2168
  }
2169
+ /**
2170
+ * Bands of lines, each line owned by one item (a program or a project):
2171
+ * the first line to show so the selected band fits, scrolling as little
2172
+ * as it can from the top.
2173
+ */
2174
+ function bandStart(owners, selected, rows) {
2175
+ const first = owners.indexOf(selected);
2176
+ if (first < 0) return 0;
2177
+ const last = owners.lastIndexOf(selected);
2178
+ return Math.max(0, Math.min(first, last - rows + 1));
2179
+ }
2180
+ /** The next milestones still to reach, soonest first. */
2181
+ function nextMarks(data, programId, n) {
2182
+ return data.milestones.filter((m) => m.programId === programId && !m.done).sort((a, b) => a.dueDate.localeCompare(b.dueDate)).slice(0, n);
2183
+ }
2184
+ /** Open risks and issues, worst first. */
2185
+ function worst(items, n) {
2186
+ return sortRaid(items.filter(isActive)).slice(0, n);
2187
+ }
2188
+ function projectRow(data, project, today) {
2189
+ const tasks = tasksOf(data.tasks, project.id);
2190
+ const open = {};
2191
+ let done = 0;
2192
+ let late = 0;
2193
+ for (const t of tasks) {
2194
+ if (t.status === "done") done++;
2195
+ else open[t.status] = (open[t.status] ?? 0) + 1;
2196
+ if (isLate(t, today)) late++;
2197
+ }
2198
+ return {
2199
+ project,
2200
+ progress: tasks.length ? Math.round(done / tasks.length * 100) : 0,
2201
+ late,
2202
+ open
2203
+ };
2204
+ }
2205
+ function activeConstraint(data, programId) {
2206
+ const c = constraintOf(data, programId);
2207
+ return c?.status === "active" ? c.title : null;
2208
+ }
2209
+ /** A portfolio read program by program, as the website's By program layout. */
2210
+ function programBands(data, portfolioId, today, showDone) {
2211
+ const split = splitDone(data.programs.filter((p) => p.portfolioId === portfolioId));
2212
+ return (showDone ? [...split.open, ...split.done] : split.open).map((program) => {
2213
+ const mine = splitDone(data.projects.filter((p) => p.programId === program.id));
2214
+ const rows = (showDone ? [...mine.open, ...mine.done] : mine.open).map((p) => projectRow(data, p, today));
2215
+ const all = [...mine.open, ...mine.done].map((p) => tasksOf(data.tasks, p.id));
2216
+ const total = all.reduce((n, t) => n + t.length, 0);
2217
+ const done = all.reduce((n, t) => n + t.filter((x) => x.status === "done").length, 0);
2218
+ return {
2219
+ program,
2220
+ progress: total ? Math.round(done / total * 100) : 0,
2221
+ constraint: activeConstraint(data, program.id),
2222
+ projects: rows,
2223
+ doneProjects: showDone ? 0 : mine.done.length,
2224
+ milestones: nextMarks(data, program.id, 2),
2225
+ risks: worst(programRaidItems(data, program.id), 2)
2226
+ };
2227
+ });
2228
+ }
2229
+ /** A program read project by project, as the website's By project layout. */
2230
+ function projectBands(data, programId, today, showDone) {
2231
+ const split = splitDone(data.projects.filter((p) => p.programId === programId));
2232
+ return (showDone ? [...split.open, ...split.done] : split.open).map((p) => ({
2233
+ ...projectRow(data, p, today),
2234
+ risks: worst(data.raidItems.filter((r) => r.projectId === p.id), 2)
2235
+ }));
2236
+ }
2099
2237
 
2100
2238
  //#endregion
2101
2239
  //#region src/tui/theme.ts
@@ -2981,27 +3119,6 @@ function HistoryView({ active, width, height, go }) {
2981
3119
  });
2982
3120
  }
2983
3121
 
2984
- //#endregion
2985
- //#region src/lib/flow.ts
2986
- function tasksInProgramOf(data, programId) {
2987
- const ids = new Set(data.projects.filter((p) => p.programId === programId).map((p) => p.id));
2988
- return data.tasks.filter((t) => ids.has(t.projectId));
2989
- }
2990
- function activeConstraints(data) {
2991
- return (data.constraints ?? []).filter((c) => c.status === "active");
2992
- }
2993
- function constraintsOwnedBy(data, personId) {
2994
- return activeConstraints(data).filter((c) => c.ownerId === personId);
2995
- }
2996
- function constraintIdle(data, constraint) {
2997
- if (constraint.status !== "active" || !constraint.ownerId) return false;
2998
- const tasks = tasksInProgramOf(data, constraint.programId);
2999
- if (!tasks.some((t) => t.status !== "done")) return false;
3000
- if (tasks.some((t) => t.assigneeId === constraint.ownerId && t.status === "doing")) return false;
3001
- const here = new Set(tasks.map((t) => t.id));
3002
- return data.tasks.some((t) => t.assigneeId === constraint.ownerId && t.status === "doing" && !here.has(t.id));
3003
- }
3004
-
3005
3122
  //#endregion
3006
3123
  //#region src/lib/inbox.ts
3007
3124
  const KIND_RANK = {
@@ -3293,19 +3410,6 @@ function weeklyLoad(tasks, personId, now, weeks = 6) {
3293
3410
  };
3294
3411
  }
3295
3412
 
3296
- //#endregion
3297
- //#region src/lib/selectors.ts
3298
- /** Projects (or programs) still running, and the ones set to Done, which lists tuck away until asked for. */
3299
- function splitDone(items) {
3300
- const open = [];
3301
- const done = [];
3302
- for (const item of items) (item.health === "done" ? done : open).push(item);
3303
- return {
3304
- open,
3305
- done
3306
- };
3307
- }
3308
-
3309
3413
  //#endregion
3310
3414
  //#region src/tui/views/lists.tsx
3311
3415
  const count = (n, noun) => `${n} ${noun}${n === 1 ? "" : "s"}`;
@@ -4737,12 +4841,293 @@ function PersonPanel({ id, width, height, go, active, onClose }) {
4737
4841
  });
4738
4842
  }
4739
4843
 
4844
+ //#endregion
4845
+ //#region src/tui/views/bands.tsx
4846
+ /**
4847
+ * The website's band layouts in the terminal: a portfolio read program by
4848
+ * program, a program read project by project. Each band is a few lines; the
4849
+ * one you're on stays in view.
4850
+ */
4851
+ const NAME$1 = 24;
4852
+ function Lines({ lines, selected, rows }) {
4853
+ const start = bandStart(lines.map((l) => l.owner), selected, rows);
4854
+ const shown = lines.slice(start, start + rows);
4855
+ return /* @__PURE__ */ jsx(Box, {
4856
+ flexDirection: "column",
4857
+ height: rows,
4858
+ children: shown.map((l, i) => /* @__PURE__ */ jsx(Box, { children: l.node }, start + i))
4859
+ });
4860
+ }
4861
+ function fit(text, n) {
4862
+ return text.length > n ? `${text.slice(0, n - 1)}…` : text.padEnd(n);
4863
+ }
4864
+ function riskLine(r, today) {
4865
+ const score = scoreOf(r);
4866
+ return /* @__PURE__ */ jsxs(Text, {
4867
+ wrap: "truncate-end",
4868
+ children: [
4869
+ " ",
4870
+ /* @__PURE__ */ jsx(Text, {
4871
+ color: r.kind === "issue" ? theme.bad : r.kind === "risk" ? theme.warn : theme.muted,
4872
+ children: KIND_LABEL[r.kind].toLowerCase()
4873
+ }),
4874
+ " ",
4875
+ r.title,
4876
+ /* @__PURE__ */ jsxs(Text, {
4877
+ color: theme.muted,
4878
+ children: [score != null ? ` · score ${score}` : "", r.dueDate ? ` · review ${shortDay(r.dueDate, today)}` : ""]
4879
+ })
4880
+ ]
4881
+ });
4882
+ }
4883
+ function projectLine(row, w, bar, today, indent = 4) {
4884
+ const p = row.project;
4885
+ return /* @__PURE__ */ jsxs(Text, {
4886
+ wrap: "truncate-end",
4887
+ children: [
4888
+ " ".repeat(indent),
4889
+ /* @__PURE__ */ jsx(Text, {
4890
+ color: HEALTH_COLOR[p.health],
4891
+ children: "●"
4892
+ }),
4893
+ " ",
4894
+ fit(p.name, NAME$1 - indent),
4895
+ " ",
4896
+ /* @__PURE__ */ jsx(Text, {
4897
+ color: HEALTH_COLOR[p.health],
4898
+ children: roadmapCells(p.startDate, p.endDate, [], w, bar)
4899
+ }),
4900
+ " ",
4901
+ /* @__PURE__ */ jsxs(Text, {
4902
+ color: theme.muted,
4903
+ children: [String(row.progress).padStart(3), "%"]
4904
+ }),
4905
+ row.late ? /* @__PURE__ */ jsxs(Text, {
4906
+ color: theme.bad,
4907
+ children: [
4908
+ " ",
4909
+ row.late,
4910
+ " late"
4911
+ ]
4912
+ }) : null
4913
+ ]
4914
+ });
4915
+ }
4916
+ /** The months over the bars, lined up with them. */
4917
+ function Axis({ w, bar }) {
4918
+ return /* @__PURE__ */ jsxs(Text, {
4919
+ color: theme.muted,
4920
+ wrap: "truncate-end",
4921
+ children: [" ".repeat(27), roadmapHeader(w, bar)]
4922
+ });
4923
+ }
4924
+ /** A portfolio, program by program: each with its projects' bars, next milestones and worst risks. */
4925
+ function ProgramBands({ bands, at, active, width, height, w, today }) {
4926
+ const bar = Math.max(10, width - NAME$1 - 19);
4927
+ const lines = [];
4928
+ bands.forEach((b, i) => {
4929
+ const on = active && i === at;
4930
+ const p = b.program;
4931
+ lines.push({
4932
+ owner: i,
4933
+ node: /* @__PURE__ */ jsxs(Text, {
4934
+ wrap: "truncate-end",
4935
+ color: on ? theme.selected : void 0,
4936
+ bold: on,
4937
+ children: [
4938
+ on ? "› " : " ",
4939
+ /* @__PURE__ */ jsx(Text, {
4940
+ color: HEALTH_COLOR[p.health],
4941
+ children: "●"
4942
+ }),
4943
+ " ",
4944
+ p.name,
4945
+ " ",
4946
+ /* @__PURE__ */ jsxs(Text, {
4947
+ color: theme.muted,
4948
+ bold: false,
4949
+ children: [
4950
+ HEALTH_LABEL[p.health],
4951
+ " · ",
4952
+ b.progress,
4953
+ "% done",
4954
+ p.nextMove ? ` · next: ${p.nextMove}` : ""
4955
+ ]
4956
+ })
4957
+ ]
4958
+ })
4959
+ });
4960
+ if (b.constraint) lines.push({
4961
+ owner: i,
4962
+ node: /* @__PURE__ */ jsxs(Text, {
4963
+ color: theme.warn,
4964
+ wrap: "truncate-end",
4965
+ children: [
4966
+ " ",
4967
+ "Held back by ",
4968
+ b.constraint
4969
+ ]
4970
+ })
4971
+ });
4972
+ for (const row of b.projects) lines.push({
4973
+ owner: i,
4974
+ node: projectLine(row, w, bar, today)
4975
+ });
4976
+ if (b.projects.length === 0) lines.push({
4977
+ owner: i,
4978
+ node: /* @__PURE__ */ jsxs(Text, {
4979
+ color: theme.muted,
4980
+ children: [" ", b.doneProjects ? "Every project here is done." : "No projects yet."]
4981
+ })
4982
+ });
4983
+ else if (b.doneProjects) lines.push({
4984
+ owner: i,
4985
+ node: /* @__PURE__ */ jsxs(Text, {
4986
+ color: theme.muted,
4987
+ children: [
4988
+ " ",
4989
+ b.doneProjects,
4990
+ " done hidden (D)"
4991
+ ]
4992
+ })
4993
+ });
4994
+ for (const m of b.milestones) lines.push({
4995
+ owner: i,
4996
+ node: /* @__PURE__ */ jsxs(Text, {
4997
+ wrap: "truncate-end",
4998
+ children: [
4999
+ " ",
5000
+ /* @__PURE__ */ jsx(Text, {
5001
+ color: m.dueDate < today ? theme.bad : theme.accent,
5002
+ children: "◆"
5003
+ }),
5004
+ " ",
5005
+ m.name,
5006
+ /* @__PURE__ */ jsxs(Text, {
5007
+ color: m.dueDate < today ? theme.bad : theme.muted,
5008
+ children: [
5009
+ " ",
5010
+ "· ",
5011
+ m.dueDate < today ? "late, " : "",
5012
+ shortDay(m.dueDate, today)
5013
+ ]
5014
+ })
5015
+ ]
5016
+ })
5017
+ });
5018
+ for (const r of b.risks) lines.push({
5019
+ owner: i,
5020
+ node: riskLine(r, today)
5021
+ });
5022
+ lines.push({
5023
+ owner: i,
5024
+ node: /* @__PURE__ */ jsx(Text, { children: " " })
5025
+ });
5026
+ });
5027
+ return /* @__PURE__ */ jsxs(Box, {
5028
+ flexDirection: "column",
5029
+ height,
5030
+ children: [/* @__PURE__ */ jsx(Axis, {
5031
+ w,
5032
+ bar
5033
+ }), /* @__PURE__ */ jsx(Lines, {
5034
+ lines,
5035
+ selected: at,
5036
+ rows: Math.max(1, height - 1)
5037
+ })]
5038
+ });
5039
+ }
5040
+ /** A program, project by project: each with its bar, its open tasks by status and its worst risks. */
5041
+ function ProjectBands({ bands, at, active, width, height, w, today }) {
5042
+ const bar = Math.max(10, width - NAME$1 - 14);
5043
+ const lines = [];
5044
+ bands.forEach((b, i) => {
5045
+ const on = active && i === at;
5046
+ const p = b.project;
5047
+ lines.push({
5048
+ owner: i,
5049
+ node: /* @__PURE__ */ jsxs(Text, {
5050
+ wrap: "truncate-end",
5051
+ color: on ? theme.selected : void 0,
5052
+ bold: on,
5053
+ children: [
5054
+ on ? "› " : " ",
5055
+ /* @__PURE__ */ jsx(Text, {
5056
+ color: HEALTH_COLOR[p.health],
5057
+ children: "●"
5058
+ }),
5059
+ " ",
5060
+ fit(p.name, 22),
5061
+ " ",
5062
+ /* @__PURE__ */ jsx(Text, {
5063
+ color: HEALTH_COLOR[p.health],
5064
+ bold: false,
5065
+ children: roadmapCells(p.startDate, p.endDate, [], w, bar)
5066
+ }),
5067
+ " ",
5068
+ /* @__PURE__ */ jsxs(Text, {
5069
+ color: theme.muted,
5070
+ bold: false,
5071
+ children: [String(b.progress).padStart(3), "%"]
5072
+ })
5073
+ ]
5074
+ })
5075
+ });
5076
+ const open = STATUS_ORDER.filter((s) => s !== "done" && b.open[s]).map((s) => `${STATUS_LABEL[s].toLowerCase()} ${b.open[s]}`);
5077
+ lines.push({
5078
+ owner: i,
5079
+ node: /* @__PURE__ */ jsxs(Text, {
5080
+ wrap: "truncate-end",
5081
+ color: theme.muted,
5082
+ children: [
5083
+ " ",
5084
+ HEALTH_LABEL[p.health],
5085
+ " · ",
5086
+ open.length ? open.join(" · ") : "nothing open",
5087
+ b.late ? /* @__PURE__ */ jsxs(Text, {
5088
+ color: theme.bad,
5089
+ children: [
5090
+ " · ",
5091
+ b.late,
5092
+ " late"
5093
+ ]
5094
+ }) : null,
5095
+ " · ends",
5096
+ " ",
5097
+ shortDay(p.endDate, today)
5098
+ ]
5099
+ })
5100
+ });
5101
+ for (const r of b.risks) lines.push({
5102
+ owner: i,
5103
+ node: riskLine(r, today)
5104
+ });
5105
+ lines.push({
5106
+ owner: i,
5107
+ node: /* @__PURE__ */ jsx(Text, { children: " " })
5108
+ });
5109
+ });
5110
+ return /* @__PURE__ */ jsxs(Box, {
5111
+ flexDirection: "column",
5112
+ height,
5113
+ children: [/* @__PURE__ */ jsx(Axis, {
5114
+ w,
5115
+ bar
5116
+ }), /* @__PURE__ */ jsx(Lines, {
5117
+ lines,
5118
+ selected: at,
5119
+ rows: Math.max(1, height - 1)
5120
+ })]
5121
+ });
5122
+ }
5123
+
4740
5124
  //#endregion
4741
5125
  //#region src/tui/views/program.tsx
4742
5126
  /**
4743
5127
  * One program: its health (and why, when set by hand), next move, its
4744
5128
  * projects and its milestones. ←→ between the two lists. Projects set to
4745
- * Done stay out of the list until D shows them.
5129
+ * Done stay out of the list until D shows them. v reads it project by
5130
+ * project instead, as the website's By project layout.
4746
5131
  */
4747
5132
  function ProgramView({ programId, active, width, height, go, onBack }) {
4748
5133
  const { store, today } = useDeps();
@@ -4751,6 +5136,7 @@ function ProgramView({ programId, active, width, height, go, onBack }) {
4751
5136
  const tasks = useHarbor((s) => s.tasks);
4752
5137
  const milestones = useHarbor((s) => s.milestones);
4753
5138
  const people = useHarbor((s) => s.people);
5139
+ const raidItems = useHarbor((s) => s.raidItems);
4754
5140
  const portfolio = useHarbor((s) => s.portfolios.find((p) => p.id === program?.portfolioId));
4755
5141
  const [showDone, setShowDone] = useState(false);
4756
5142
  const split = useMemo(() => splitDone(projects.filter((p) => p.programId === programId)), [projects, programId]);
@@ -4761,14 +5147,19 @@ function ProgramView({ programId, active, width, height, go, onBack }) {
4761
5147
  today
4762
5148
  ]);
4763
5149
  const [side, setSide] = useState("projects");
5150
+ const [layout, setLayout] = useState("board");
4764
5151
  const [pAt, setPAt] = useState(0);
4765
5152
  const [mAt, setMAt] = useState(0);
4766
5153
  const project = mine[Math.min(pAt, mine.length - 1)];
4767
5154
  const mark = marks[Math.min(mAt, marks.length - 1)]?.milestone;
4768
5155
  useInput((input, key) => {
4769
5156
  if (key.escape || key.backspace) return onBack();
4770
- if (key.leftArrow || input === "h") return setSide("projects");
4771
- if (key.rightArrow || input === "l") return setSide("milestones");
5157
+ if (input === "v") {
5158
+ setSide("projects");
5159
+ return setLayout((l) => l === "board" ? "bands" : "board");
5160
+ }
5161
+ if (layout === "board" && (key.leftArrow || input === "h")) return setSide("projects");
5162
+ if (layout === "board" && (key.rightArrow || input === "l")) return setSide("milestones");
4772
5163
  if (input === "e") return go.openPanel("program", programId);
4773
5164
  if (input === "n") return go.create("project", { programId });
4774
5165
  if (input === "m") return go.create("milestone", { programId });
@@ -4798,6 +5189,11 @@ function ProgramView({ programId, active, width, height, go, onBack }) {
4798
5189
  tasks,
4799
5190
  projects
4800
5191
  };
5192
+ const bands = layout === "bands" ? projectBands({
5193
+ tasks,
5194
+ projects,
5195
+ raidItems
5196
+ }, programId, today(), showDone) : [];
4801
5197
  return /* @__PURE__ */ jsxs(Box, {
4802
5198
  flexDirection: "column",
4803
5199
  width,
@@ -4845,7 +5241,21 @@ function ProgramView({ programId, active, width, height, go, onBack }) {
4845
5241
  children: ["Next move: ", program.nextMove || "none written yet (e to write one)"]
4846
5242
  }),
4847
5243
  /* @__PURE__ */ jsx(Text, { children: " " }),
4848
- /* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsxs(Box, {
5244
+ layout === "bands" ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Heading, {
5245
+ right: `${mine.length}${split.done.length > 0 ? ` · ${split.done.length} done ${showDone ? "shown" : "hidden"} (D)` : ""} · v board`,
5246
+ children: "By project"
5247
+ }), mine.length === 0 ? /* @__PURE__ */ jsx(Text, {
5248
+ color: theme.muted,
5249
+ children: split.done.length > 0 ? "Every project here is done. D shows them." : "None yet. n adds one."
5250
+ }) : /* @__PURE__ */ jsx(ProjectBands, {
5251
+ bands,
5252
+ at: Math.min(pAt, bands.length - 1),
5253
+ active,
5254
+ width: width - 2,
5255
+ height: height - 4,
5256
+ w: roadmapWindow("half", today()),
5257
+ today: today()
5258
+ })] }) : /* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsxs(Box, {
4849
5259
  flexDirection: "column",
4850
5260
  width: half,
4851
5261
  marginRight: 1,
@@ -4930,20 +5340,54 @@ function ProgramView({ programId, active, width, height, go, onBack }) {
4930
5340
  ]
4931
5341
  });
4932
5342
  }
4933
- /** One portfolio: its health and its programs, each with theirs; Done ones wait for D. */
5343
+ /**
5344
+ * One portfolio, as the website's board: its programs, with its worst
5345
+ * risks, next milestones and money beside them when there's room. v reads
5346
+ * it program by program instead (By program). Done programs wait for D.
5347
+ */
4934
5348
  function PortfolioView({ portfolioId, active, width, height, go, onBack }) {
5349
+ const { today } = useDeps();
4935
5350
  const portfolio = useHarbor((s) => s.portfolios.find((p) => p.id === portfolioId));
4936
5351
  const programs = useHarbor((s) => s.programs);
4937
5352
  const projects = useHarbor((s) => s.projects);
5353
+ const tasks = useHarbor((s) => s.tasks);
5354
+ const milestones = useHarbor((s) => s.milestones);
5355
+ const raidItems = useHarbor((s) => s.raidItems);
5356
+ const constraints = useHarbor((s) => s.constraints);
4938
5357
  const [showDone, setShowDone] = useState(false);
5358
+ const [layout, setLayout] = useState("board");
4939
5359
  const split = useMemo(() => splitDone(programs.filter((p) => p.portfolioId === portfolioId)), [programs, portfolioId]);
4940
5360
  const list = showDone ? [...split.open, ...split.done] : split.open;
4941
5361
  const [at, setAt] = useState(0);
4942
5362
  const program = list[Math.min(at, list.length - 1)];
5363
+ const day = today();
5364
+ const data = useMemo(() => ({
5365
+ programs,
5366
+ projects,
5367
+ tasks,
5368
+ milestones,
5369
+ raidItems,
5370
+ constraints
5371
+ }), [
5372
+ programs,
5373
+ projects,
5374
+ tasks,
5375
+ milestones,
5376
+ raidItems,
5377
+ constraints
5378
+ ]);
5379
+ const bands = useMemo(() => layout === "bands" ? programBands(data, portfolioId, day, showDone) : [], [
5380
+ layout,
5381
+ data,
5382
+ portfolioId,
5383
+ day,
5384
+ showDone
5385
+ ]);
4943
5386
  useInput((input, key) => {
4944
5387
  if (key.escape || key.backspace) return onBack();
4945
5388
  if (input === "e") return go.openPanel("portfolio", portfolioId);
4946
5389
  if (input === "n") return go.create("program", { portfolioId });
5390
+ if (input === "v") return setLayout((l) => l === "board" ? "bands" : "board");
4947
5391
  if (input === "D" && split.done.length > 0) return setShowDone((v) => !v);
4948
5392
  if (key.upArrow || input === "k") setAt((i) => step(i, -1, list.length));
4949
5393
  else if (key.downArrow || input === "j") setAt((i) => step(i, 1, list.length));
@@ -4953,6 +5397,15 @@ function PortfolioView({ portfolioId, active, width, height, go, onBack }) {
4953
5397
  color: theme.muted,
4954
5398
  children: " That portfolio isn’t in this harbor any more. Esc goes back."
4955
5399
  });
5400
+ const side = layout === "board" && width >= 96 ? Math.floor((width - 3) * .4) : 0;
5401
+ const main = width - 2 - (side ? side + 1 : 0);
5402
+ const body = height - 4;
5403
+ const risks = sortRaid(portfolioRaidItems(data, portfolioId).filter(isActive));
5404
+ const ids = new Set(list.map((p) => p.id));
5405
+ const next = milestones.filter((m) => ids.has(m.programId) && !m.done).sort((a, b) => a.dueDate.localeCompare(b.dueDate));
5406
+ const budget = list.reduce((n, p) => n + programBudgetAllocated(data, p.id), 0);
5407
+ const spent = list.reduce((n, p) => n + programSpent(data, p.id), 0);
5408
+ const listRows = Math.max(1, body - 1);
4956
5409
  return /* @__PURE__ */ jsxs(Box, {
4957
5410
  flexDirection: "column",
4958
5411
  width,
@@ -4981,50 +5434,136 @@ function PortfolioView({ portfolioId, active, width, height, go, onBack }) {
4981
5434
  }) : null
4982
5435
  ]
4983
5436
  }),
4984
- /* @__PURE__ */ jsx(Text, {
5437
+ /* @__PURE__ */ jsxs(Text, {
4985
5438
  color: theme.muted,
4986
5439
  wrap: "truncate-end",
4987
- children: portfolio.theme || "Portfolio"
5440
+ children: [
5441
+ portfolio.theme || "Portfolio",
5442
+ " · ",
5443
+ layout === "board" ? "board" : "by program",
5444
+ " · v switches"
5445
+ ]
4988
5446
  }),
4989
5447
  /* @__PURE__ */ jsx(Text, { children: " " }),
4990
- /* @__PURE__ */ jsx(Heading, {
4991
- right: `${list.length} program${list.length === 1 ? "" : "s"}${split.done.length > 0 ? ` · ${split.done.length} done ${showDone ? "shown" : "hidden"} (D)` : ""}`,
4992
- children: "Programs"
4993
- }),
4994
- list.length === 0 ? /* @__PURE__ */ jsx(Text, {
4995
- color: theme.muted,
4996
- children: split.done.length > 0 ? "Every program here is done. D shows them." : "None yet. n adds one."
4997
- }) : null,
4998
- windowOf(list, at, height - 5).items.map((p) => {
4999
- const on = active && p.id === program?.id;
5000
- const count = projects.filter((x) => x.programId === p.id).length;
5001
- return /* @__PURE__ */ jsxs(Text, {
5002
- color: on ? theme.selected : void 0,
5003
- bold: on,
5004
- wrap: "truncate-end",
5005
- children: [
5006
- on ? "› " : " ",
5007
- /* @__PURE__ */ jsx(Text, {
5008
- color: HEALTH_COLOR[p.health],
5009
- children: "●"
5010
- }),
5011
- " ",
5012
- p.name,
5013
- " ",
5014
- /* @__PURE__ */ jsxs(Text, {
5015
- color: theme.muted,
5016
- children: [
5017
- HEALTH_LABEL[p.health],
5018
- " · ",
5019
- count,
5020
- " project",
5021
- count === 1 ? "" : "s",
5022
- p.nextMove ? ` · next: ${p.nextMove}` : ""
5023
- ]
5024
- })
5025
- ]
5026
- }, p.id);
5027
- })
5448
+ /* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsxs(Box, {
5449
+ flexDirection: "column",
5450
+ width: main,
5451
+ children: [/* @__PURE__ */ jsx(Heading, {
5452
+ right: `${list.length} program${list.length === 1 ? "" : "s"}${split.done.length > 0 ? ` · ${split.done.length} done ${showDone ? "shown" : "hidden"} (D)` : ""}`,
5453
+ children: layout === "board" ? "Programs" : "By program"
5454
+ }), list.length === 0 ? /* @__PURE__ */ jsx(Text, {
5455
+ color: theme.muted,
5456
+ children: split.done.length > 0 ? "Every program here is done. D shows them." : "None yet. n adds one."
5457
+ }) : layout === "bands" ? /* @__PURE__ */ jsx(ProgramBands, {
5458
+ bands,
5459
+ at: Math.min(at, bands.length - 1),
5460
+ active,
5461
+ width: main,
5462
+ height: listRows,
5463
+ w: roadmapWindow("half", day),
5464
+ today: day
5465
+ }) : windowOf(list, at, listRows).items.map((p) => {
5466
+ const on = active && p.id === program?.id;
5467
+ const count = projects.filter((x) => x.programId === p.id).length;
5468
+ return /* @__PURE__ */ jsxs(Text, {
5469
+ color: on ? theme.selected : void 0,
5470
+ bold: on,
5471
+ wrap: "truncate-end",
5472
+ children: [
5473
+ on ? "› " : " ",
5474
+ /* @__PURE__ */ jsx(Text, {
5475
+ color: HEALTH_COLOR[p.health],
5476
+ children: "●"
5477
+ }),
5478
+ " ",
5479
+ p.name,
5480
+ " ",
5481
+ /* @__PURE__ */ jsxs(Text, {
5482
+ color: theme.muted,
5483
+ children: [
5484
+ HEALTH_LABEL[p.health],
5485
+ " · ",
5486
+ count,
5487
+ " project",
5488
+ count === 1 ? "" : "s",
5489
+ p.nextMove ? ` · next: ${p.nextMove}` : ""
5490
+ ]
5491
+ })
5492
+ ]
5493
+ }, p.id);
5494
+ })]
5495
+ }), side ? /* @__PURE__ */ jsxs(Box, {
5496
+ flexDirection: "column",
5497
+ width: side,
5498
+ marginLeft: 1,
5499
+ children: [
5500
+ /* @__PURE__ */ jsx(Heading, {
5501
+ right: `${risks.length} open`,
5502
+ children: "Risks & issues"
5503
+ }),
5504
+ risks.length === 0 ? /* @__PURE__ */ jsx(Text, {
5505
+ color: theme.muted,
5506
+ children: "Nothing open."
5507
+ }) : null,
5508
+ risks.slice(0, 5).map((r) => /* @__PURE__ */ jsxs(Text, {
5509
+ wrap: "truncate-end",
5510
+ children: [
5511
+ /* @__PURE__ */ jsx(Text, {
5512
+ color: r.kind === "issue" ? theme.bad : r.kind === "risk" ? theme.warn : theme.muted,
5513
+ children: r.kind
5514
+ }),
5515
+ " ",
5516
+ r.title
5517
+ ]
5518
+ }, r.id)),
5519
+ /* @__PURE__ */ jsx(Text, { children: " " }),
5520
+ /* @__PURE__ */ jsx(Heading, { children: "Next milestones" }),
5521
+ next.length === 0 ? /* @__PURE__ */ jsx(Text, {
5522
+ color: theme.muted,
5523
+ children: "None to come."
5524
+ }) : null,
5525
+ next.slice(0, 4).map((m) => /* @__PURE__ */ jsxs(Text, {
5526
+ wrap: "truncate-end",
5527
+ children: [
5528
+ /* @__PURE__ */ jsx(Text, {
5529
+ color: m.dueDate < day ? theme.bad : theme.accent,
5530
+ children: "◆"
5531
+ }),
5532
+ " ",
5533
+ m.name,
5534
+ " ",
5535
+ /* @__PURE__ */ jsx(Text, {
5536
+ color: m.dueDate < day ? theme.bad : theme.muted,
5537
+ children: shortDay(m.dueDate, day)
5538
+ })
5539
+ ]
5540
+ }, m.id)),
5541
+ /* @__PURE__ */ jsx(Text, { children: " " }),
5542
+ /* @__PURE__ */ jsx(Heading, { children: "Money" }),
5543
+ /* @__PURE__ */ jsxs(Text, {
5544
+ wrap: "truncate-end",
5545
+ children: [
5546
+ money(spent),
5547
+ " ",
5548
+ /* @__PURE__ */ jsx(Text, {
5549
+ color: theme.muted,
5550
+ children: "spent of"
5551
+ }),
5552
+ " ",
5553
+ money(budget),
5554
+ budget > 0 ? /* @__PURE__ */ jsxs(Text, {
5555
+ color: spent > budget ? theme.bad : theme.muted,
5556
+ children: [
5557
+ " ",
5558
+ "(",
5559
+ Math.round(spent / budget * 100),
5560
+ "%)"
5561
+ ]
5562
+ }) : null
5563
+ ]
5564
+ })
5565
+ ]
5566
+ }) : null] })
5028
5567
  ]
5029
5568
  });
5030
5569
  }
@@ -6722,6 +7261,7 @@ function App({ ui }) {
6722
7261
  ["Enter", "open"],
6723
7262
  ["e", "edit"],
6724
7263
  ["n", "new"],
7264
+ ["v", "layout"],
6725
7265
  ...page.kind === "program" ? [["m", "milestone"], ["←→", "lists"]] : [],
6726
7266
  ["Esc", "back"]
6727
7267
  ] : [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.12.0",
3
+ "version": "0.13.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",